diff --git a/.gitignore b/.gitignore index cde3a45e7..a38ba52d9 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ out.html .claude/* !.claude/commands/ packages/ai/test/.temp-images/ + +changes/ diff --git a/docs/porting-from-pi-mono.md b/docs/porting-from-pi-mono.md index 5223dc128..6a6381b45 100644 --- a/docs/porting-from-pi-mono.md +++ b/docs/porting-from-pi-mono.md @@ -3,6 +3,16 @@ This guide is a repeatable checklist for porting changes from pi-mono into this repo. Use it for any merge: single file, feature branch, or full release sync. +## Last Sync Point + +**Commit:** `11dd2f47` +**Date:** January 2026 + +When starting a new sync, generate patches from this commit forward: +```bash +git format-patch 11dd2f47..HEAD --stdout > changes.patch +``` + ## 0) Define the scope - Identify the upstream reference (commit, tag, or PR). @@ -218,3 +228,91 @@ Use this as a final pass before you finish: - [ ] Assets load via Bun embed patterns (no copy scripts) - [ ] Tests or checks run (or explicitly noted as blocked) - [ ] No functionality regressions (see section 11) + +## 13) Intentional Divergences + +Our fork has architectural decisions that differ from upstream. **Do not port these upstream patterns:** + +### UI Architecture + +| Upstream | Our Fork | Reason | +|----------|----------|--------| +| `FooterDataProvider` class | `StatusLineComponent` | Simpler, integrated status line | +| `ctx.ui.setHeader()` / `ctx.ui.setFooter()` | Removed | Not implemented; StatusLineComponent handles status | +| `ctx.ui.setEditorComponent()` | Removed | Not implemented | +| `InteractiveModeOptions` interface | Positional constructor args | Existing pattern works fine | + +### Component Naming + +| Upstream | Our Fork | +|----------|----------| +| `extension-input.ts` | `hook-input.ts` | +| `extension-selector.ts` | `hook-selector.ts` | +| `ExtensionInputComponent` | `HookInputComponent` | +| `ExtensionSelectorComponent` | `HookSelectorComponent` | + +### File Consolidation + +| Upstream | Our Fork | Reason | +|----------|----------|--------| +| `clipboard.ts` + `clipboard-image.ts` | `clipboard.ts` only | Merged with Bun-native implementation | +| `@mariozechner/clipboard` dependency | Native platform commands | No external dependency needed | + +### Test Framework + +| Upstream | Our Fork | +|----------|----------| +| `vitest` with `vi.mock()` | `bun:test` with `vi` from bun | +| `node:test` assertions | `expect()` matchers | + +### Tool Architecture + +| Upstream | Our Fork | +|----------|----------| +| `createTool(cwd: string, options?)` | `createTool(session: ToolSession)` | +| Per-tool `*Operations` interfaces | Unified `FileOperations` in `ToolSession` | +| Node.js `fs/promises` | Bun APIs (`Bun.file()`, `Bun.write()`) | + +### Auth Storage + +| Upstream | Our Fork | +|----------|----------| +| `proper-lockfile` library | Native `O_EXCL` atomic file locking | +| Single credential per provider | Multi-credential with round-robin selection | + +### Extensions + +| Upstream | Our Fork | +|----------|----------| +| `jiti` for TypeScript loading | Native Bun `import()` | +| `pkg.pi` manifest field | `pkg.omp ?? pkg.pi` (prefer our namespace) | + +### Config Paths + +| Upstream | Our Fork | +|----------|----------| +| `~/.claude/` | `~/.omp/` (with fallback chain) | +| Single config dir | Multi-config: `.omp`, `.pi`, `.claude`, `.codex`, `.gemini` | + +### Skip These Upstream Features + +When porting, **skip** these files/features entirely: + +- `footer-data-provider.ts` — we use StatusLineComponent +- `clipboard-image.ts` — merged into clipboard.ts +- `compaction-extensions.test.ts` — different test architecture +- GitHub workflow files — we have our own CI +- `models.generated.ts` — auto-generated, regenerate locally + +### Features We Added (Preserve These) + +These exist in our fork but not upstream. **Never overwrite:** + +- `StatusLineComponent` in interactive mode +- Multi-credential auth with session affinity +- Capability-based discovery system (`loadSync`, `skillCapability`, etc.) +- Voice mode integration +- MCP/Exa/SSH integrations +- LSP writethrough for format-on-save +- Bash interception (`checkBashInterception`) +- Fuzzy path suggestions in read tool diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 89fd35458..1e8510922 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog ## [Unreleased] +### Added + +- Added `popLastSteer()` and `popLastFollowUp()` methods to remove and return the last queued message (LIFO) for dequeue operations +- `thinkingBudgets` option on `Agent` and `AgentOptions` to customize token budgets per thinking level +- `sessionId` option on `Agent` to forward session identifiers to LLM providers for session-based caching + +### Fixed + +- `minimal` thinking level now maps to `minimal` reasoning effort instead of being treated as `low` ## [3.37.1] - 2026-01-10 @@ -162,4 +171,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon - `Agent` constructor now has all options optional (empty options use defaults). -- `queueMessage()` is now synchronous (no longer returns a Promise). +- `queueMessage()` is now synchronous (no longer returns a Promise). \ No newline at end of file diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index b67690445..290555bf0 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -3,7 +3,15 @@ * No transport abstraction - calls streamSimple via the loop. */ -import { getModel, type ImageContent, type Message, type Model, streamSimple, type TextContent } from "@oh-my-pi/pi-ai"; +import { + getModel, + type ImageContent, + type Message, + type Model, + streamSimple, + type TextContent, + type ThinkingBudgets, +} from "@oh-my-pi/pi-ai"; import { agentLoop, agentLoopContinue } from "./agent-loop"; import type { AgentContext, @@ -73,6 +81,11 @@ export interface AgentOptions { */ getApiKey?: (provider: string) => Promise | string | undefined; + /** + * Custom token budgets for thinking levels (token-based providers only). + */ + thinkingBudgets?: ThinkingBudgets; + /** * Provides tool execution context, resolved per tool call. * Use for late-bound UI or session state access. @@ -104,6 +117,7 @@ export class Agent { private interruptMode: "immediate" | "wait"; public streamFn: StreamFn; private _sessionId?: string; + private _thinkingBudgets?: ThinkingBudgets; public getApiKey?: (provider: string) => Promise | string | undefined; private getToolContext?: () => AgentToolContext | undefined; private runningPrompt?: Promise; @@ -118,6 +132,7 @@ export class Agent { this.interruptMode = opts.interruptMode || "immediate"; this.streamFn = opts.streamFn || streamSimple; this._sessionId = opts.sessionId; + this._thinkingBudgets = opts.thinkingBudgets; this.getApiKey = opts.getApiKey; this.getToolContext = opts.getToolContext; } @@ -137,6 +152,20 @@ export class Agent { this._sessionId = value; } + /** + * Get the current thinking budgets. + */ + get thinkingBudgets(): ThinkingBudgets | undefined { + return this._thinkingBudgets; + } + + /** + * Set custom thinking budgets for token-based providers. + */ + set thinkingBudgets(value: ThinkingBudgets | undefined) { + this._thinkingBudgets = value; + } + get state(): AgentState { return this._state; } @@ -236,6 +265,22 @@ export class Agent { this.followUpQueue = []; } + /** + * Remove and return the last steering message from the queue (LIFO). + * Used by dequeue keybinding. + */ + popLastSteer(): AgentMessage | undefined { + return this.steeringQueue.pop(); + } + + /** + * Remove and return the last follow-up message from the queue (LIFO). + * Used by dequeue keybinding. + */ + popLastFollowUp(): AgentMessage | undefined { + return this.followUpQueue.pop(); + } + clearMessages() { this._state.messages = []; } @@ -342,6 +387,7 @@ export class Agent { reasoning, interruptMode: this.interruptMode, sessionId: this._sessionId, + thinkingBudgets: this._thinkingBudgets, convertToLlm: this.convertToLlm, transformContext: this.transformContext, getApiKey: this.getApiKey, diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 4d32d4ba6..a5e1873ba 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,7 +1,54 @@ -import { getModel } from "@oh-my-pi/pi-ai"; +import { + type AssistantMessage, + type AssistantMessageEvent, + EventStream, + getModel, + type ThinkingBudgets, + type Usage, +} from "@oh-my-pi/pi-ai"; import { describe, expect, it } from "vitest"; import { Agent } from "../src/index"; +class MockAssistantStream extends EventStream { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") return event.message; + if (event.type === "error") return event.error; + throw new Error("Unexpected event type"); + }, + ); + } +} + +function createUsage(): Usage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function createAssistantMessage( + content: AssistantMessage["content"], + stopReason: AssistantMessage["stopReason"] = "stop", +): AssistantMessage { + return { + role: "assistant", + content, + api: "openai-responses", + provider: "openai", + model: "mock", + usage: createUsage(), + stopReason, + timestamp: Date.now(), + }; +} + describe("Agent", () => { it("should create an agent instance with default state", () => { const agent = new Agent(); @@ -109,4 +156,35 @@ describe("Agent", () => { // Should not throw even if nothing is running expect(() => agent.abort()).not.toThrow(); }); + + it("forwards sessionId and thinkingBudgets to streamFn options", async () => { + let receivedSessionId: string | undefined; + let receivedBudgets: ThinkingBudgets | undefined; + + const agent = new Agent({ + sessionId: "session-abc", + thinkingBudgets: { minimal: 64, low: 256 }, + streamFn: (_model, _context, options) => { + receivedSessionId = options?.sessionId; + receivedBudgets = options?.thinkingBudgets; + const stream = new MockAssistantStream(); + queueMicrotask(() => { + const message = createAssistantMessage([{ type: "text", text: "ok" }]); + stream.push({ type: "done", reason: "stop", message }); + }); + return stream; + }, + }); + + await agent.prompt("hello"); + expect(receivedSessionId).toBe("session-abc"); + expect(receivedBudgets).toEqual({ minimal: 64, low: 256 }); + + agent.sessionId = "session-def"; + agent.thinkingBudgets = { medium: 512 }; + + await agent.prompt("hello again"); + expect(receivedSessionId).toBe("session-def"); + expect(receivedBudgets).toEqual({ medium: 512 }); + }); }); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index de3f06fb3..63846599e 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,37 @@ # Changelog ## [Unreleased] +### Added + +- OpenCode Zen provider support with 26 models (Claude, GPT, Gemini, Grok, Kimi, GLM, Qwen, etc.). Set `OPENCODE_API_KEY` env var to use. +- `thinkingBudgets` option in `SimpleStreamOptions` for customizing token budgets per thinking level on token-based providers +- `sessionId` option in `StreamOptions` for providers that support session-based caching. OpenAI Codex provider uses this to set `prompt_cache_key` and routing headers. +- `supportsUsageInStreaming` compatibility flag for OpenAI-compatible providers that reject `stream_options: { include_usage: true }`. Defaults to `true`. Set to `false` in model config for providers like gatewayz.ai. +- `GOOGLE_APPLICATION_CREDENTIALS` env var support for Vertex AI credential detection (standard for CI/production) +- Exported OpenAI Codex utilities: `CacheMetadata`, `getCodexInstructions`, `getModelFamily`, `ModelFamily`, `buildCodexPiBridge`, `buildCodexSystemPrompt`, `CodexSystemPrompt` +- Headless OAuth support for all callback-server providers (Google Gemini CLI, Antigravity, OpenAI Codex): paste redirect URL when browser callback is unreachable +- Cancellable GitHub Copilot device code polling via AbortSignal +- Improved error messages for OpenRouter providers by including raw metadata from upstream errors + +### Changed + +- Anthropic provider now maps tool names to Claude Code's exact tool names (Read, Write, Edit, Bash, Grep, Glob) instead of using prefixed names +- OpenAI Completions provider now disables strict mode on tools to allow optional parameters without null unions + +### Fixed + +- Google Cloud Code Assist OAuth for paid subscriptions: properly handles long-running operations for project provisioning, supports `GOOGLE_CLOUD_PROJECT` / `GOOGLE_CLOUD_PROJECT_ID` env vars for paid tiers +- `os.homedir()` calls at module load time; now resolved lazily when needed +- OpenAI Responses tool strict flag to use a boolean for LM Studio compatibility +- Gemini CLI abort handling: detect native `AbortError` in retry catch block, cancel SSE reader when abort signal fires +- Antigravity provider 429 errors by aligning request payload with CLIProxyAPI v6.6.89 +- Thinking block handling for cross-model conversations: thinking blocks are now converted to plain text when switching models +- OpenAI Codex context window from 400,000 to 272,000 tokens to match Codex CLI defaults +- Codex SSE error events to surface message, code, and status +- Context overflow detection for `context_length_exceeded` error codes +- Codex provider now always includes `reasoning.encrypted_content` even when custom `include` options are passed +- Codex requests now omit the `reasoning` field entirely when thinking is off +- Crash when pasting text with trailing whitespace exceeding terminal width ## [3.37.1] - 2026-01-10 ### Added diff --git a/packages/ai/README.md b/packages/ai/README.md index b9f26f9c6..4357c807e 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -909,12 +909,48 @@ Several providers require OAuth authentication instead of static API keys: - **Anthropic** (Claude Pro/Max subscription) - **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models) - **GitHub Copilot** (Copilot subscription) -- **Google Gemini CLI** (Free Gemini 2.0/2.5 via Google Cloud Code Assist) +- **Google Gemini CLI** (Gemini 2.0/2.5 via Google Cloud Code Assist; free tier or paid subscription) - **Antigravity** (Free Gemini 3, Claude, GPT-OSS via Google Cloud) +For paid Cloud Code Assist subscriptions, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` to your project ID. + ### Vertex AI (ADC) -Vertex AI models use Application Default Credentials. Run `gcloud auth application-default login`, set `GOOGLE_CLOUD_PROJECT` (or `GCLOUD_PROJECT`), and `GOOGLE_CLOUD_LOCATION`. You can also pass `project`/`location` in the call options. +Vertex AI models use Application Default Credentials (ADC): + +- **Local development**: Run `gcloud auth application-default login` +- **CI/Production**: Set `GOOGLE_APPLICATION_CREDENTIALS` to point to a service account JSON key file + +Also set `GOOGLE_CLOUD_PROJECT` (or `GCLOUD_PROJECT`) and `GOOGLE_CLOUD_LOCATION`. You can also pass `project`/`location` in the call options. + +Example: + +```bash +# Local (uses your user credentials) +gcloud auth application-default login +export GOOGLE_CLOUD_PROJECT="my-project" +export GOOGLE_CLOUD_LOCATION="us-central1" + +# CI/Production (service account key file) +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json" +``` + +```typescript +import { getModel, complete } from "@oh-my-pi/pi-ai"; + +(async () => { + const model = getModel("google-vertex", "gemini-2.5-flash"); + const response = await complete(model, { + messages: [{ role: "user", content: "Hello from Vertex AI" }], + }); + + for (const block of response.content) { + if (block.type === "text") console.log(block.text); + } +})().catch(console.error); +``` + +Official docs: [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) ### CLI Login diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts index fd1cb0773..969d4831e 100755 --- a/packages/ai/src/cli.ts +++ b/packages/ai/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { existsSync, readFileSync, writeFileSync } from "fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { createInterface } from "readline"; import { loginAnthropic } from "./utils/oauth/anthropic"; import { loginGitHubCopilot } from "./utils/oauth/github-copilot"; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index a8090cef6..004722b5c 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -32,12 +32,24 @@ import { transformMessages } from "./transorm-messages"; // Stealth mode: Mimic Claude Code's tool naming exactly const claudeCodeVersion = "2.1.2"; -// Prefix all tool names to avoid collisions with Claude Code's built-in tools -const toolNamePrefix = "cli_"; +// Map pi! tool names to Claude Code's exact tool names +const claudeCodeToolNames: Record = { + read: "Read", + write: "Write", + edit: "Edit", + bash: "Bash", + grep: "Grep", + find: "Glob", + ls: "Glob", +}; -const toClaudeCodeName = (name: string) => toolNamePrefix + name; -const fromClaudeCodeName = (name: string) => - name.startsWith(toolNamePrefix) ? name.slice(toolNamePrefix.length) : name; +const toClaudeCodeName = (name: string) => claudeCodeToolNames[name] || name; +const fromClaudeCodeName = (name: string) => { + for (const [piName, ccName] of Object.entries(claudeCodeToolNames)) { + if (ccName === name) return piName; + } + return name; +}; /** * Convert content blocks to Anthropic API format diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 42dd95674..3d643c73b 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -308,6 +308,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( for (const block of output.content) delete (block as any).index; output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = formatErrorMessageWithRetryAfter(error); + // Some providers via OpenRouter include extra details here. + const rawMetadata = (error as { error?: { metadata?: { raw?: string } } })?.error?.metadata?.raw; + if (rawMetadata) output.errorMessage += `\n${rawMetadata}`; stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } @@ -368,9 +371,12 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio model: model.id, messages, stream: true, - stream_options: { include_usage: true }, }; + if (compat.supportsUsageInStreaming !== false) { + (params as { stream_options?: { include_usage: boolean } }).stream_options = { include_usage: true }; + } + if (compat.supportsStore) { params.store = false; } @@ -610,6 +616,7 @@ function convertTools(tools: Tool[]): OpenAI.Chat.Completions.ChatCompletionTool name: tool.name, description: tool.description, parameters: tool.parameters as any, // TypeBox already generates JSON Schema + strict: false, // Disable strict mode to allow optional parameters without null unions }, })); } @@ -654,6 +661,7 @@ function detectCompatFromUrl(baseUrl: string): Required { supportsStore: !isNonStandard, supportsDeveloperRole: !isNonStandard, supportsReasoningEffort: !isGrok, + supportsUsageInStreaming: true, maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens", requiresToolResultName: isMistral, requiresAssistantAfterToolResult: false, // Mistral no longer requires this as of Dec 2024 @@ -674,6 +682,7 @@ function getCompat(model: Model<"openai-completions">): Required { supportsStore: model.compat.supportsStore ?? detected.supportsStore, supportsDeveloperRole: model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole, supportsReasoningEffort: model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort, + supportsUsageInStreaming: model.compat.supportsUsageInStreaming ?? detected.supportsUsageInStreaming, maxTokensField: model.compat.maxTokensField ?? detected.maxTokensField, requiresToolResultName: model.compat.requiresToolResultName ?? detected.requiresToolResultName, requiresAssistantAfterToolResult: diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index b9461f8c0..3782a1676 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -544,7 +544,7 @@ function convertTools(tools: Tool[]): OpenAITool[] { name: tool.name, description: tool.description, parameters: tool.parameters as any, // TypeBox already generates JSON Schema - strict: null, + strict: false, })); } diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 14a7d5c25..9db997381 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -26,13 +26,18 @@ import type { ThinkingLevel, } from "./types"; -const VERTEX_ADC_CREDENTIALS_PATH = join(homedir(), ".config", "gcloud", "application_default_credentials.json"); - let cachedVertexAdcCredentialsExists: boolean | null = null; function hasVertexAdcCredentials(): boolean { if (cachedVertexAdcCredentialsExists === null) { - cachedVertexAdcCredentialsExists = existsSync(VERTEX_ADC_CREDENTIALS_PATH); + const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS; + if (gacPath) { + cachedVertexAdcCredentialsExists = existsSync(gacPath); + } else { + cachedVertexAdcCredentialsExists = existsSync( + join(homedir(), ".config", "gcloud", "application_default_credentials.json"), + ); + } } return cachedVertexAdcCredentialsExists; } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 728faf9ee..94ac57a12 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -208,6 +208,8 @@ export interface OpenAICompat { supportsDeveloperRole?: boolean; /** Whether the provider supports `reasoning_effort`. Default: auto-detected from URL. */ supportsReasoningEffort?: boolean; + /** Whether the provider supports `stream_options: { include_usage: true }` for token usage in streaming responses. Default: true. */ + supportsUsageInStreaming?: boolean; /** Which field to use for max tokens. Default: auto-detected from URL. */ maxTokensField?: "max_completion_tokens" | "max_tokens"; /** Whether tool results require the `name` field. Default: auto-detected from URL. */ diff --git a/packages/ai/src/utils/oauth/google-gemini-cli.ts b/packages/ai/src/utils/oauth/google-gemini-cli.ts index 54e96aaae..f642a1f64 100644 --- a/packages/ai/src/utils/oauth/google-gemini-cli.ts +++ b/packages/ai/src/utils/oauth/google-gemini-cli.ts @@ -122,13 +122,28 @@ interface LoadCodeAssistPayload { allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; } -interface OnboardUserPayload { +/** + * Long-running operation response from onboardUser + */ +interface LongRunningOperationResponse { + name?: string; done?: boolean; response?: { cloudaicompanionProject?: { id?: string }; }; } +// Tier IDs as used by the Cloud Code API +const TIER_FREE = "free-tier"; +const TIER_LEGACY = "legacy-tier"; +const TIER_STANDARD = "standard-tier"; + +interface GoogleRpcErrorResponse { + error?: { + details?: Array<{ reason?: string }>; + }; +} + /** * Wait helper for onboarding retries */ @@ -137,18 +152,62 @@ function wait(ms: number): Promise { } /** - * Get default tier ID from allowed tiers + * Get default tier from allowed tiers */ -function getDefaultTierId(allowedTiers?: Array<{ id?: string; isDefault?: boolean }>): string | undefined { - if (!allowedTiers || allowedTiers.length === 0) return undefined; +function getDefaultTier(allowedTiers?: Array<{ id?: string; isDefault?: boolean }>): { id?: string } { + if (!allowedTiers || allowedTiers.length === 0) return { id: TIER_LEGACY }; const defaultTier = allowedTiers.find((t) => t.isDefault); - return defaultTier?.id ?? allowedTiers[0]?.id; + return defaultTier ?? { id: TIER_LEGACY }; +} + +function isVpcScAffectedUser(payload: unknown): boolean { + if (!payload || typeof payload !== "object") return false; + if (!("error" in payload)) return false; + const error = (payload as GoogleRpcErrorResponse).error; + if (!error?.details || !Array.isArray(error.details)) return false; + return error.details.some((detail) => detail.reason === "SECURITY_POLICY_VIOLATED"); +} + +/** + * Poll a long-running operation until completion + */ +async function pollOperation( + operationName: string, + headers: Record, + onProgress?: (message: string) => void, +): Promise { + let attempt = 0; + while (true) { + if (attempt > 0) { + onProgress?.(`Waiting for project provisioning (attempt ${attempt + 1})...`); + await wait(5000); + } + + const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { + method: "GET", + headers, + }); + + if (!response.ok) { + throw new Error(`Failed to poll operation: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as LongRunningOperationResponse; + if (data.done) { + return data; + } + + attempt += 1; + } } /** * Discover or provision a Google Cloud project for the user */ async function discoverProject(accessToken: string, onProgress?: (message: string) => void): Promise { + // Check for user-provided project ID via environment variable + const envProjectId = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; + const headers = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", @@ -162,62 +221,114 @@ async function discoverProject(accessToken: string, onProgress?: (message: strin method: "POST", headers, body: JSON.stringify({ + cloudaicompanionProject: envProjectId, metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI", + duetProject: envProjectId, }, }), }); - if (loadResponse.ok) { - const data = (await loadResponse.json()) as LoadCodeAssistPayload; + let data: LoadCodeAssistPayload; - // If we have an existing project, use it + if (!loadResponse.ok) { + let errorPayload: unknown; + try { + errorPayload = await loadResponse.clone().json(); + } catch { + errorPayload = undefined; + } + + if (isVpcScAffectedUser(errorPayload)) { + data = { currentTier: { id: TIER_STANDARD } }; + } else { + const errorText = await loadResponse.text(); + throw new Error(`loadCodeAssist failed: ${loadResponse.status} ${loadResponse.statusText}: ${errorText}`); + } + } else { + data = (await loadResponse.json()) as LoadCodeAssistPayload; + } + + // If user already has a current tier and project, use it + if (data.currentTier) { if (data.cloudaicompanionProject) { return data.cloudaicompanionProject; } - - // Otherwise, try to onboard with the FREE tier - const tierId = getDefaultTierId(data.allowedTiers) ?? "FREE"; - - onProgress?.("Provisioning Cloud Code Assist project (this may take a moment)..."); - - // Onboard with retries (the API may take time to provision) - for (let attempt = 0; attempt < 10; attempt++) { - const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, { - method: "POST", - headers, - body: JSON.stringify({ - tierId, - metadata: { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - }, - }), - }); - - if (onboardResponse.ok) { - const onboardData = (await onboardResponse.json()) as OnboardUserPayload; - const projectId = onboardData.response?.cloudaicompanionProject?.id; - - if (onboardData.done && projectId) { - return projectId; - } - } - - // Wait before retrying - if (attempt < 9) { - onProgress?.(`Waiting for project provisioning (attempt ${attempt + 2}/10)...`); - await wait(3000); - } + // User has a tier but no managed project - they need to provide one via env var + if (envProjectId) { + return envProjectId; } + throw new Error( + "This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID environment variable. " + + "See https://goo.gle/gemini-cli-auth-docs#workspace-gca", + ); + } + + // User needs to be onboarded - get the default tier + const tier = getDefaultTier(data.allowedTiers); + const tierId = tier?.id ?? TIER_FREE; + + if (tierId !== TIER_FREE && !envProjectId) { + throw new Error( + "This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID environment variable. " + + "See https://goo.gle/gemini-cli-auth-docs#workspace-gca", + ); + } + + onProgress?.("Provisioning Cloud Code Assist project (this may take a moment)..."); + + // Build onboard request - for free tier, don't include project ID (Google provisions one) + // For other tiers, include the user's project ID if available + const onboardBody: Record = { + tierId, + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }; + + if (tierId !== TIER_FREE && envProjectId) { + onboardBody.cloudaicompanionProject = envProjectId; + (onboardBody.metadata as Record).duetProject = envProjectId; + } + + // Start onboarding - this returns a long-running operation + const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, { + method: "POST", + headers, + body: JSON.stringify(onboardBody), + }); + + if (!onboardResponse.ok) { + const errorText = await onboardResponse.text(); + throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}: ${errorText}`); + } + + let lroData = (await onboardResponse.json()) as LongRunningOperationResponse; + + // If the operation isn't done yet, poll until completion + if (!lroData.done && lroData.name) { + lroData = await pollOperation(lroData.name, headers, onProgress); + } + + // Try to get project ID from the response + const projectId = lroData.response?.cloudaicompanionProject?.id; + if (projectId) { + return projectId; + } + + // If no project ID from onboarding, fall back to env var + if (envProjectId) { + return envProjectId; } throw new Error( "Could not discover or provision a Google Cloud project. " + - "Please ensure you have access to Google Cloud Code Assist (Gemini CLI).", + "Try setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID environment variable. " + + "See https://goo.gle/gemini-cli-auth-docs#workspace-gca", ); } diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index 0108c50ca..b19ac86be 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete, stream } from "../src/stream.js"; -import type { Api, Context, Model, OptionsForApi } from "../src/types.js"; -import { resolveApiKey } from "./oauth.js"; +import { getModel } from "../src/models"; +import { complete, stream } from "../src/stream"; +import type { Api, Context, Model, OptionsForApi } from "../src/types"; +import { resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) const [geminiCliToken, openaiCodexToken] = await Promise.all([ diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 85e8855ab..d0a3a85e2 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -11,14 +11,14 @@ * The isContextOverflow() function must return true for all providers. */ -import type { ChildProcess } from "child_process"; -import { execSync, spawn } from "child_process"; +import type { ChildProcess } from "node:child_process"; +import { execSync, spawn } from "node:child_process"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { AssistantMessage, Context, Model, Usage } from "../src/types.js"; -import { isContextOverflow } from "../src/utils/overflow.js"; -import { resolveApiKey } from "./oauth.js"; +import { getModel } from "../src/models"; +import { complete } from "../src/stream"; +import type { AssistantMessage, Context, Model, Usage } from "../src/types"; +import { isContextOverflow } from "../src/utils/overflow"; +import { resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index 958322152..7dbb5eaee 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Api, AssistantMessage, Context, Model, OptionsForApi, UserMessage } from "../src/types.js"; -import { resolveApiKey } from "./oauth.js"; +import { getModel } from "../src/models"; +import { complete } from "../src/stream"; +import type { Api, AssistantMessage, Context, Model, OptionsForApi, UserMessage } from "../src/types"; +import { resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ diff --git a/packages/ai/test/google-thinking-signature.test.ts b/packages/ai/test/google-thinking-signature.test.ts index 02ae63f27..7277a7844 100644 --- a/packages/ai/test/google-thinking-signature.test.ts +++ b/packages/ai/test/google-thinking-signature.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isThinkingPart, retainThoughtSignature } from "../src/providers/google-shared.js"; +import { isThinkingPart, retainThoughtSignature } from "../src/providers/google-shared"; describe("Google thinking detection (thoughtSignature)", () => { it("treats part.thought === true as thinking", () => { diff --git a/packages/ai/test/handoff.test.ts b/packages/ai/test/handoff.test.ts index 61dc3d78b..0414434e5 100644 --- a/packages/ai/test/handoff.test.ts +++ b/packages/ai/test/handoff.test.ts @@ -1,8 +1,8 @@ import { Type } from "@sinclair/typebox"; import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Api, AssistantMessage, Context, Message, Model, Tool, ToolResultMessage } from "../src/types.js"; +import { getModel } from "../src/models"; +import { complete } from "../src/stream"; +import type { Api, AssistantMessage, Context, Message, Model, Tool, ToolResultMessage } from "../src/types"; // Tool for testing const weatherSchema = Type.Object({ diff --git a/packages/ai/test/image-limits.test.ts b/packages/ai/test/image-limits.test.ts index e12e99dbe..0acb867db 100644 --- a/packages/ai/test/image-limits.test.ts +++ b/packages/ai/test/image-limits.test.ts @@ -72,9 +72,9 @@ import { mkdirSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Api, Context, ImageContent, Model, OptionsForApi, UserMessage } from "../src/types.js"; +import { getModel } from "../src/models"; +import { complete } from "../src/stream"; +import type { Api, Context, ImageContent, Model, OptionsForApi, UserMessage } from "../src/types"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 762e8495a..ad3d60ad5 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -2,10 +2,10 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { Type } from "@sinclair/typebox"; import { describe, expect, it } from "vitest"; -import type { Api, Context, Model, Tool, ToolResultMessage } from "../src/index.js"; -import { complete, getModel } from "../src/index.js"; -import type { OptionsForApi } from "../src/types.js"; -import { resolveApiKey } from "./oauth.js"; +import type { Api, Context, Model, Tool, ToolResultMessage } from "../src/index"; +import { complete, getModel } from "../src/index"; +import type { OptionsForApi } from "../src/types"; +import { resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ diff --git a/packages/ai/test/oauth.ts b/packages/ai/test/oauth.ts index b026906cc..f5f361f51 100644 --- a/packages/ai/test/oauth.ts +++ b/packages/ai/test/oauth.ts @@ -5,11 +5,11 @@ * OAuth tokens are automatically refreshed if expired and saved back to auth.json. */ -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "os"; import { dirname, join } from "path"; -import { getOAuthApiKey } from "../src/utils/oauth/index.js"; -import type { OAuthCredentials, OAuthProvider } from "../src/utils/oauth/types.js"; +import { getOAuthApiKey } from "../src/utils/oauth/index"; +import type { OAuthCredentials, OAuthProvider } from "../src/utils/oauth/types"; const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json"); diff --git a/packages/ai/test/openai-codex-include.test.ts b/packages/ai/test/openai-codex-include.test.ts index 23c427ea3..6ee91bd51 100644 --- a/packages/ai/test/openai-codex-include.test.ts +++ b/packages/ai/test/openai-codex-include.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { type RequestBody, transformRequestBody } from "../src/providers/openai-codex/request-transformer.js"; +import { type RequestBody, transformRequestBody } from "../src/providers/openai-codex/request-transformer"; describe("openai-codex include handling", () => { it("always includes reasoning.encrypted_content when caller include is custom", async () => { diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index 854599399..1c81981ac 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -2,8 +2,8 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { streamOpenAICodexResponses } from "../src/providers/openai-codex-responses.js"; -import type { Context, Model } from "../src/types.js"; +import { streamOpenAICodexResponses } from "../src/providers/openai-codex-responses"; +import type { Context, Model } from "../src/types"; const originalFetch = global.fetch; const originalAgentDir = process.env.PI_CODING_AGENT_DIR; diff --git a/packages/ai/test/openai-codex.test.ts b/packages/ai/test/openai-codex.test.ts index 9252656b4..a332ce325 100644 --- a/packages/ai/test/openai-codex.test.ts +++ b/packages/ai/test/openai-codex.test.ts @@ -2,9 +2,9 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getCodexInstructions } from "../src/providers/openai-codex/prompts/codex.js"; -import { type RequestBody, transformRequestBody } from "../src/providers/openai-codex/request-transformer.js"; -import { parseCodexError } from "../src/providers/openai-codex/response-handler.js"; +import { getCodexInstructions } from "../src/providers/openai-codex/prompts/codex"; +import { type RequestBody, transformRequestBody } from "../src/providers/openai-codex/request-transformer"; +import { parseCodexError } from "../src/providers/openai-codex/response-handler"; const DEFAULT_PROMPT_PREFIX = "You are an expert coding assistant. You help users with coding tasks by reading files, executing commands"; diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index d8ff4265b..988e68702 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1,14 +1,14 @@ +import { type ChildProcess, execSync, spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; import { Type } from "@sinclair/typebox"; -import { type ChildProcess, execSync, spawn } from "child_process"; -import { readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete, stream } from "../src/stream.js"; -import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "../src/types.js"; -import { StringEnum } from "../src/utils/typebox-helpers.js"; -import { resolveApiKey } from "./oauth.js"; +import { getModel } from "../src/models"; +import { complete, stream } from "../src/stream"; +import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "../src/types"; +import { StringEnum } from "../src/utils/typebox-helpers"; +import { resolveApiKey } from "./oauth"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -126,7 +126,7 @@ async function handleToolCall(model: Model, options?: Op expect(toolCall.arguments).not.toBeUndefined(); expect((toolCall.arguments as any).a).toBe(15); expect((toolCall.arguments as any).b).toBe(27); - expect((toolCall.arguments as any).operation).oneOf(["add", "subtract", "multiply", "divide"]); + expect(["add", "subtract", "multiply", "divide"]).toContain((toolCall.arguments as any).operation); } } } diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index f6b86f622..4ec62efb4 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { stream } from "../src/stream.js"; -import type { Api, Context, Model, OptionsForApi } from "../src/types.js"; -import { resolveApiKey } from "./oauth.js"; +import { getModel } from "../src/models"; +import { stream } from "../src/stream"; +import type { Api, Context, Model, OptionsForApi } from "../src/types"; +import { resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ diff --git a/packages/ai/test/tool-argument-coercion.test.ts b/packages/ai/test/tool-argument-coercion.test.ts index c381d9a6d..73afd44d4 100644 --- a/packages/ai/test/tool-argument-coercion.test.ts +++ b/packages/ai/test/tool-argument-coercion.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import { Type } from "@sinclair/typebox"; -import type { Tool, ToolCall } from "../src/types.js"; -import { validateToolArguments } from "../src/utils/validation.js"; +import type { Tool, ToolCall } from "../src/types"; +import { validateToolArguments } from "../src/utils/validation"; describe("Tool argument coercion", () => { it("coerces numeric strings when schema expects number", () => { diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 854762a3f..d3b4ae921 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -1,9 +1,9 @@ import { Type } from "@sinclair/typebox"; import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Api, Context, Model, OptionsForApi, Tool } from "../src/types.js"; -import { resolveApiKey } from "./oauth.js"; +import { getModel } from "../src/models"; +import { complete } from "../src/stream"; +import type { Api, Context, Model, OptionsForApi, Tool } from "../src/types"; +import { resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 13b4afdf1..e920b1b7d 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -13,10 +13,10 @@ */ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Api, Context, Model, OptionsForApi, Usage } from "../src/types.js"; -import { resolveApiKey } from "./oauth.js"; +import { getModel } from "../src/models"; +import { complete } from "../src/stream"; +import type { Api, Context, Model, OptionsForApi, Usage } from "../src/types"; +import { resolveApiKey } from "./oauth"; // Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index f848c15ac..ef2efdbfc 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -1,9 +1,9 @@ import { Type } from "@sinclair/typebox"; import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { complete } from "../src/stream.js"; -import type { Api, Context, Model, OptionsForApi, ToolResultMessage } from "../src/types.js"; -import { resolveApiKey } from "./oauth.js"; +import { getModel } from "../src/models"; +import { complete } from "../src/stream"; +import type { Api, Context, Model, OptionsForApi, ToolResultMessage } from "../src/types"; +import { resolveApiKey } from "./oauth"; // Empty schema for test tools - must be proper OBJECT type for Cloud Code Assist const emptySchema = Type.Object({}); diff --git a/packages/ai/test/xhigh.test.ts b/packages/ai/test/xhigh.test.ts index 95646e35d..c88952c26 100644 --- a/packages/ai/test/xhigh.test.ts +++ b/packages/ai/test/xhigh.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { getModel } from "../src/models.js"; -import { stream } from "../src/stream.js"; -import type { Context, Model } from "../src/types.js"; +import { getModel } from "../src/models"; +import { stream } from "../src/stream"; +import type { Context, Model } from "../src/types"; function makeContext(): Context { return { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 88cd4d283..82d9eb05f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,76 @@ # Changelog ## [Unreleased] +### Added + +- `--no-tools` flag to disable all built-in tools, enabling extension-only setups +- `--no-extensions` flag to disable extension discovery while still allowing explicit `-e` paths +- `blockImages` setting to prevent images from being sent to LLM providers +- `thinkingBudgets` setting to customize token budgets per thinking level +- `PI_SKIP_VERSION_CHECK` environment variable to disable new version notifications at startup +- Anthropic OAuth support via `/login` to authenticate with Claude Pro/Max subscription +- OpenCode Zen provider support via `OPENCODE_API_KEY` env var and `opencode/` syntax +- Session picker (`pi -r`) and `--session` flag support searching/resuming by session ID (UUID prefix) +- Session ID forwarding to LLM providers for session-based caching (used by OpenAI Codex for prompt caching) +- `dequeue` keybinding (`Alt+Up`) to restore queued steering/follow-up messages back into the editor +- Pluggable operations for built-in tools enabling remote execution via SSH or other transports (`ReadOperations`, `WriteOperations`, `EditOperations`, `BashOperations`, `LsOperations`, `GrepOperations`, `FindOperations`) +- `/model ` pre-filters the model selector or auto-selects on exact match; use `provider/model` syntax to disambiguate +- Managed binaries directory (`~/.omp/bin/`) for fd and rg tools +- `FooterDataProvider` for custom footers with `getGitBranch()`, `getExtensionStatuses()`, and `onBranchChange()` +- `ctx.ui.custom()` accepts `{ overlay: true }` option for floating modal components +- `ctx.ui.getAllThemes()`, `ctx.ui.getTheme(name)`, `ctx.ui.setTheme(name | Theme)` for theme management +- `setActiveTools()` for dynamic tool management +- `setModel()`, `getThinkingLevel()`, `setThinkingLevel()` methods for runtime model and thinking level changes +- `ctx.shutdown()` for requesting graceful shutdown +- `pi.sendUserMessage()` for sending user messages from extensions +- Extension UI dialogs (`select`, `confirm`, `input`) support `timeout` option with live countdown display +- Extension UI dialogs accept optional `AbortSignal` to programmatically dismiss dialogs +- Async extension factories for dynamic imports and lazy-loaded dependencies +- `user_bash` event for intercepting user `!`/`!!` commands +- Built-in renderers used automatically for tool overrides without custom `renderCall`/`renderResult` +- `InteractiveMode`, `runPrintMode()`, `runRpcMode()` exported for building custom run modes +- Copy link button on messages for deep linking to specific entries +- Codex injection info display showing system prompt modifications +- URL parameter support for `leafId` and `targetId` deep linking +- Wayland clipboard support for `/copy` command using wl-copy with xclip/xsel fallback + +### Changed + +- Default model for OpenCode provider changed from `claude-sonnet-4-5` to `claude-opus-4-5` +- Terminal color mode detection defaults to truecolor for modern terminals instead of 256color +- System prompt restructured with XML tags and clearer instructions format +- `before_agent_start` event receives `systemPrompt` in the event object and returns `systemPrompt` (full replacement) instead of `systemPromptAppend` +- `discoverSkills()` returns `{ skills: Skill[], warnings: SkillWarning[] }` instead of `Skill[]` +- `ctx.ui.custom()` factory signature changed from `(tui, theme, done)` to `(tui, theme, keybindings, done)` +- `ExtensionRunner.initialize()` signature changed from options object to positional params `(actions, contextActions, commandContextActions?, uiContext?)` + +### Fixed + +- Bash tool handles spawn errors gracefully instead of crashing the agent +- Components properly rebuild their content on theme change via `invalidate()` override +- `setTheme()` triggers a full rerender so previously rendered components update with new theme colors +- Session ID updates correctly when branching sessions +- External edits to `settings.json` while pi is running are preserved when pi saves settings +- Default thinking level from settings applies correctly when `enabledModels` is configured +- LM Studio compatibility for OpenAI Responses tool strict mapping +- Symlinked directories in `prompts/` folders are followed when loading prompt templates +- String `systemPrompt` in `createAgentSession()` works as a full replacement instead of having context files and skills appended +- Update notification for bun binary installs shows release download URL instead of npm command +- ESC key works during "Working..." state after auto-retry +- Abort messages show correct retry attempt count +- Antigravity provider returning 429 errors despite available quota +- Malformed thinking text in Gemini/Antigravity responses where thinking content appeared as regular text +- `--no-skills` flag correctly prevents skills from loading in interactive mode +- Overflow-based compaction skips if error came from a different model or was already handled +- OpenAI Codex context window reduced from 400k to 272k tokens to match Codex CLI defaults +- Context overflow detection recognizes `context_length_exceeded` errors +- Key presses no longer dropped when input is batched over SSH +- Clipboard image support works on Alpine Linux and other musl-based distros +- Queued steering/follow-up messages no longer wipe unsent editor input +- OAuth token refresh failure no longer crashes app at startup +- Status bar shows correct git branch when running in a git worktree +- Ctrl+V clipboard image paste works on Wayland sessions +- Extension directories in `settings.json` respect `package.json` manifests ## [3.37.1] - 2026-01-10 diff --git a/packages/coding-agent/DEVELOPMENT.md b/packages/coding-agent/DEVELOPMENT.md index 7e3f0d791..438aaec51 100644 --- a/packages/coding-agent/DEVELOPMENT.md +++ b/packages/coding-agent/DEVELOPMENT.md @@ -193,11 +193,14 @@ src/ │ │ ├── assistant-message.ts # Agent response rendering │ │ ├── bash-execution.ts # Bash output display │ │ ├── compaction.ts # Compaction status display +│ │ ├── countdown-timer.ts # Reusable countdown for dialogs │ │ ├── custom-editor.ts # Multi-line input editor │ │ ├── dynamic-border.ts # Adaptive border rendering │ │ ├── footer.ts # Status bar / footer │ │ ├── hook-input.ts # Hook input dialog │ │ ├── hook-selector.ts # Hook selection UI +│ │ ├── index.ts # Component exports +│ │ ├── login-dialog.ts # OAuth login dialog │ │ ├── model-selector.ts # Model picker │ │ ├── oauth-selector.ts # OAuth provider picker │ │ ├── queue-mode-selector.ts # Message queue mode picker @@ -288,6 +291,8 @@ Handles user preferences: - Thinking block visibility - Compaction settings - Hook/custom tool paths +- Thinking budgets (`thinkingBudgets` setting for custom token budgets per level) +- Image blocking (`blockImages` setting to prevent images from being sent to LLM) ### Hook System (core/hooks/) @@ -296,10 +301,33 @@ Extensibility layer for intercepting agent behavior: - **loader.ts**: Discovers and loads hooks from `~/.omp/agent/hooks/`, `.omp/hooks/`, and CLI - **runner.ts**: Dispatches events to registered hooks - **tool-wrapper.ts**: Wraps tools to emit `tool_call` and `tool_result` events -- **types.ts**: Event types (`session`, `tool_call`, `tool_result`, `message`, `error`) +- **types.ts**: Event types (`session`, `tool_call`, `tool_result`, `message`, `error`, `user_bash`) See [docs/hooks.md](docs/hooks.md) for full documentation. +### Extension System Architecture + +The extension system uses a shared runtime pattern: + +1. **ExtensionRuntime** (`core/extensions/types.ts`): Shared state and action handlers for all extensions +2. **Extension**: Per-extension registration data (handlers, tools, commands, shortcuts) +3. **ExtensionAPI**: Per-extension API that writes registrations to Extension and delegates actions to runtime +4. **ExtensionRunner**: Orchestrates event dispatch and provides context to handlers + +Extension factories can now be async, enabling dynamic imports and lazy loading: + +```typescript +const myExtension: ExtensionFactory = async (pi) => { + const dep = await import("heavy-dependency"); + pi.registerTool({ ... }); +}; +``` + +Key extension events: +- `before_agent_start`: Receives `systemPrompt` and can return full replacement (not just append) +- `user_bash`: Intercept `!`/`!!` commands for custom execution (e.g., remote SSH) +- `session_shutdown`: Cleanup notification before exit + ### Custom Tools (core/custom-tools/) System for adding LLM-callable tools: @@ -383,6 +411,37 @@ bun test --testNamePattern="RPC" bun test/rpc-example.ts ``` +### Pluggable Tool Operations + +Built-in tools support pluggable operations for remote execution: + +- **BashOperations**: Execute commands on remote systems +- **LsOperations**: Remote directory listing +- **GrepOperations**: Remote content search +- **FindOperations**: Remote file search +- **FileOperations**: Remote file read/write/edit + +Example: SSH extension overriding bash execution: + +```typescript +pi.on("user_bash", async (event) => { + if (shouldRunRemotely()) { + return { + operations: { + exec: async (cmd, cwd, opts) => { + // Execute via SSH + return { exitCode: 0 }; + }, + }, + }; + } +}); +``` + +### Managed Binaries + +Tools like `fd` and `rg` are auto-downloaded to `~/.omp/bin/` (migrated from `~/.omp/agent/tools/`). + ## Adding New Features ### Adding a New Slash Command @@ -452,6 +511,15 @@ private showMySelector(): void { 4. Add loader/handler in relevant core module 5. Update `docs/extensions.md` with the new capability type +### Adding a New Keybinding + +1. Add the action name to `AppAction` type in `core/keybindings.ts` +2. Add default binding to `DEFAULT_APP_KEYBINDINGS` +3. Add to `APP_ACTIONS` array +4. Handle the action in `CustomEditor` or `InteractiveMode` + +Example: The `dequeue` action (`Alt+Up`) restores queued messages to the editor. + ## Code Style - TypeScript with strict type checking (tsgo) @@ -460,6 +528,7 @@ private showMySelector(): void { - Formatting via Biome (`bun run check` or `bun run fix`) - Keep InteractiveMode focused on UI, delegate logic to AgentSession - Use event bus for tool/extension communication +- Components should override `invalidate()` to rebuild on theme changes ## Package Structure @@ -471,6 +540,25 @@ This is part of a monorepo with the following packages: - `@oh-my-pi/pi-git-tool` - Git tool integration - `@oh-my-pi/pi-ai` - External AI provider library +## CLI Flags + +Key CLI flags for development: + +- `--no-tools`: Disable all built-in tools (extension-only setups) +- `--no-extensions`: Disable extension discovery (explicit `-e` paths still work) +- `--no-skills`: Disable skill discovery +- `--session `: Resume by session ID prefix (UUID match) or path + +## SDK Exports + +The SDK (`src/index.ts`) exports run modes for programmatic usage: + +- `InteractiveMode`: Full TUI mode +- `runPrintMode()`: Non-interactive, process messages and exit +- `runRpcMode()`: JSON stdin/stdout protocol + +Extension types and utilities are also exported for building custom extensions. + ## Documentation See the `docs/` directory for detailed documentation: diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index b6726817a..60c3c1fba 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -30,8 +30,10 @@ export interface Args { sessionDir?: string; models?: string[]; tools?: string[]; + noTools?: boolean; hooks?: string[]; extensions?: string[]; + noExtensions?: boolean; print?: boolean; export?: string; noSkills?: boolean; @@ -96,6 +98,8 @@ export function parseArgs(args: string[], extensionFlags?: Map s.trim()); + } else if (arg === "--no-tools") { + result.noTools = true; } else if (arg === "--tools" && i + 1 < args.length) { const toolNames = args[++i].split(",").map((s) => s.trim()); const validTools: string[] = []; @@ -132,6 +136,8 @@ export function parseArgs(args: string[], extensionFlags?: Map Comma-separated model patterns for Ctrl+P cycling Supports globs (anthropic/*, *sonnet*) and fuzzy matching + --no-tools Disable all built-in tools --tools Comma-separated list of tools to enable (default: read,bash,edit,write) Available: read, bash, edit, write, grep, find, ls --thinking Set thinking level: off, minimal, low, medium, high, xhigh --hook Load a hook/extension file (can be used multiple times) --extension, -e Load an extension file (can be used multiple times) + --no-extensions Disable extension discovery (explicit -e paths still work) --no-skills Disable skills discovery and loading --skills Comma-separated glob patterns to filter skills (e.g., git-*,docker) --export Export session file to HTML and exit diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index d9532e93f..75b5d871e 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -45,21 +45,6 @@ export function getPackageDir(): string { return process.cwd(); } -/** Get path to README.md (optional, may not exist in binary) */ -export function getReadmePath(): string { - return resolve(join(getPackageDir(), "README.md")); -} - -/** Get path to docs directory (optional, may not exist in binary) */ -export function getDocsPath(): string { - return resolve(join(getPackageDir(), "docs")); -} - -/** Get path to examples directory (optional, may not exist in binary) */ -export function getExamplesPath(): string { - return resolve(join(getPackageDir(), "examples")); -} - /** Get path to CHANGELOG.md (optional, may not exist in binary) */ export function getChangelogPath(): string { return resolve(join(getPackageDir(), "CHANGELOG.md")); @@ -99,6 +84,11 @@ export function getToolsDir(): string { return join(getAgentDir(), "tools"); } +/** Get path to managed binaries directory (fd, rg) */ +export function getBinDir(): string { + return join(getAgentDir(), "bin"); +} + /** Get path to slash commands directory */ export function getCommandsDir(): string { return join(getAgentDir(), "commands"); diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 73fdf0504..3b9e41017 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -19,7 +19,7 @@ import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-a import type { Rule } from "../capability/rule"; import { getAuthPath } from "../config"; import { theme } from "../modes/interactive/theme/theme"; -import { type BashResult, executeBash as executeBashCommand } from "./bash-executor"; +import { type BashResult, executeBash as executeBashCommand, executeBashWithOperations } from "./bash-executor"; import { type CompactionResult, calculateContextTokens, @@ -52,9 +52,11 @@ import { parseModelString } from "./model-resolver"; import { expandPromptTemplate, type PromptTemplate, parseCommandArgs } from "./prompt-templates"; import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager"; import type { SettingsManager, SkillsSettings } from "./settings-manager"; +import type { Skill, SkillWarning } from "./skills"; import { expandSlashCommand, type FileSlashCommand } from "./slash-commands"; import { closeAllConnections } from "./ssh/connection-manager"; import { unmountAll } from "./ssh/sshfs-mount"; +import type { BashOperations } from "./tools/bash"; import type { TtsrManager } from "./ttsr"; /** Session-specific events that extend the core AgentEvent */ @@ -85,6 +87,10 @@ export interface AgentSessionConfig { slashCommands?: FileSlashCommand[]; /** Extension runner (created in main.ts with wrapped tools) */ extensionRunner?: ExtensionRunner; + /** Loaded skills (already discovered by SDK) */ + skills?: Skill[]; + /** Skill loading warnings (already captured by SDK) */ + skillWarnings?: SkillWarning[]; /** Custom commands (TypeScript slash commands) */ customCommands?: LoadedCustomCommand[]; skillsSettings?: Required; @@ -154,9 +160,9 @@ const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "hi const THINKING_LEVELS_WITH_XHIGH: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"]; const noOpUIContext: ExtensionUIContext = { - select: async () => undefined, - confirm: async () => false, - input: async () => undefined, + select: async (_title, _options, _dialogOptions) => undefined, + confirm: async (_title, _message, _dialogOptions) => false, + input: async (_title, _placeholder, _dialogOptions) => undefined, notify: () => {}, setStatus: () => {}, setWidget: () => {}, @@ -168,6 +174,9 @@ const noOpUIContext: ExtensionUIContext = { get theme() { return theme; }, + getAllThemes: () => [], + getTheme: () => undefined, + setTheme: (_theme) => ({ success: false, error: "UI not available" }), }; async function cleanupSshResources(): Promise { @@ -224,6 +233,9 @@ export class AgentSession { private _extensionRunner: ExtensionRunner | undefined = undefined; private _turnIndex = 0; + private _skills: Skill[]; + private _skillWarnings: SkillWarning[]; + // Custom commands (TypeScript slash commands) private _customCommands: LoadedCustomCommand[] = []; @@ -250,6 +262,8 @@ export class AgentSession { this._promptTemplates = config.promptTemplates ?? []; this._slashCommands = config.slashCommands ?? []; this._extensionRunner = config.extensionRunner; + this._skills = config.skills ?? []; + this._skillWarnings = config.skillWarnings ?? []; this._customCommands = config.customCommands ?? []; this._skillsSettings = config.skillsSettings; this._modelRegistry = config.modelRegistry; @@ -578,6 +592,11 @@ export class AgentSession { return this.agent.state.isStreaming; } + /** Current retry attempt (0 if not retrying) */ + get retryAttempt(): number { + return this._retryAttempt; + } + /** * Get the names of currently active tools. * Returns the names of tools currently set on the agent. @@ -788,7 +807,11 @@ export class AgentSession { // Emit before_agent_start extension event if (this._extensionRunner) { - const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, options?.images); + const result = await this._extensionRunner.emitBeforeAgentStart( + expandedText, + options?.images, + this._baseSystemPrompt, + ); if (result?.messages) { for (const msg of result.messages) { messages.push({ @@ -802,8 +825,8 @@ export class AgentSession { } } - if (result?.systemPromptAppend) { - this.agent.setSystemPrompt(`${this._baseSystemPrompt}\n\n${result.systemPromptAppend}`); + if (result?.systemPrompt !== undefined) { + this.agent.setSystemPrompt(result.systemPrompt); } else { this.agent.setSystemPrompt(this._baseSystemPrompt); } @@ -861,6 +884,10 @@ export class AgentSession { void this.abort(); }, hasPendingMessages: () => this.queuedMessageCount > 0, + shutdown: () => { + void this.dispose(); + process.exit(0); + }, hasQueuedMessages: () => this.queuedMessageCount > 0, waitForIdle: () => this.agent.waitForIdle(), newSession: async (options) => { @@ -905,7 +932,7 @@ export class AgentSession { const ctx = { ...baseCtx, hasQueuedMessages: baseCtx.hasPendingMessages, - } as HookCommandContext; + } as unknown as HookCommandContext; try { const args = parseCommandArgs(argsString); @@ -1052,6 +1079,45 @@ export class AgentSession { ); } + /** + * Send a user message to the agent. Always triggers a turn. + * When the agent is streaming, use deliverAs to specify how to queue the message. + * + * @param content User message content (string or content array) + * @param options.deliverAs Delivery mode when streaming: "steer" or "followUp" + */ + async sendUserMessage( + content: string | (TextContent | ImageContent)[], + options?: { deliverAs?: "steer" | "followUp" }, + ): Promise { + // Normalize content to text string + optional images + let text: string; + let images: ImageContent[] | undefined; + + if (typeof content === "string") { + text = content; + } else { + const textParts: string[] = []; + images = []; + for (const part of content) { + if (part.type === "text") { + textParts.push(part.text); + } else { + images.push(part); + } + } + text = textParts.join("\n"); + if (images.length === 0) images = undefined; + } + + // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion + await this.prompt(text, { + expandPromptTemplates: false, + streamingBehavior: options?.deliverAs, + images, + }); + } + /** * Clear queued messages and return them. * Useful for restoring to editor when user aborts. @@ -1075,10 +1141,40 @@ export class AgentSession { return { steering: this._steeringMessages, followUp: this._followUpMessages }; } + /** + * Pop the last queued message (steering first, then follow-up). + * Used by dequeue keybinding to restore messages to editor one at a time. + */ + popLastQueuedMessage(): string | undefined { + // Pop from steering first (LIFO) + if (this._steeringMessages.length > 0) { + const message = this._steeringMessages.pop(); + this.agent.popLastSteer(); + return message; + } + // Then from follow-up + if (this._followUpMessages.length > 0) { + const message = this._followUpMessages.pop(); + this.agent.popLastFollowUp(); + return message; + } + return undefined; + } + get skillsSettings(): Required | undefined { return this._skillsSettings; } + /** Skills loaded by SDK (empty if --no-skills or skills: [] was passed) */ + get skills(): readonly Skill[] { + return this._skills; + } + + /** Skill loading warnings captured by SDK */ + get skillWarnings(): readonly SkillWarning[] { + return this._skillWarnings; + } + /** * Abort current operation and wait for agent to become idle. */ @@ -1115,6 +1211,7 @@ export class AgentSession { this.agent.reset(); await this.sessionManager.flush(); this.sessionManager.newSession(options); + this.agent.sessionId = this.sessionManager.getSessionId(); this._steeringMessages = []; this._followUpMessages = []; this._pendingNextTurnMessages = []; @@ -1311,16 +1408,12 @@ export class AgentSession { /** * Set thinking level. - * Clamps to model capabilities: "off" if no reasoning, "high" if xhigh unsupported. + * Clamps to model capabilities based on available thinking levels. * Saves to session and settings. */ setThinkingLevel(level: ThinkingLevel): void { - let effectiveLevel = level; - if (!this.supportsThinking()) { - effectiveLevel = "off"; - } else if (level === "xhigh" && !this.supportsXhighThinking()) { - effectiveLevel = "high"; - } + const availableLevels = this.getAvailableThinkingLevels(); + const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels); this.agent.setThinkingLevel(effectiveLevel); this.sessionManager.appendThinkingLevelChange(effectiveLevel); this.settingsManager.setDefaultThinkingLevel(effectiveLevel); @@ -1344,8 +1437,10 @@ export class AgentSession { /** * Get available thinking levels for current model. + * The provider will clamp to what the specific model supports internally. */ getAvailableThinkingLevels(): ThinkingLevel[] { + if (!this.supportsThinking()) return ["off"]; return this.supportsXhighThinking() ? THINKING_LEVELS_WITH_XHIGH : THINKING_LEVELS; } @@ -1363,6 +1458,24 @@ export class AgentSession { return !!this.model?.reasoning; } + private _clampThinkingLevel(level: ThinkingLevel, availableLevels: ThinkingLevel[]): ThinkingLevel { + const ordered = THINKING_LEVELS_WITH_XHIGH; + const available = new Set(availableLevels); + const requestedIndex = ordered.indexOf(level); + if (requestedIndex === -1) { + return availableLevels[0] ?? "off"; + } + for (let i = requestedIndex; i < ordered.length; i++) { + const candidate = ordered[i]; + if (available.has(candidate)) return candidate; + } + for (let i = requestedIndex - 1; i >= 0; i--) { + const candidate = ordered[i]; + if (available.has(candidate)) return candidate; + } + return availableLevels[0] ?? "off"; + } + // ========================================================================= // Message Queue Mode Management // ========================================================================= @@ -1548,8 +1661,24 @@ export class AgentSession { const contextWindow = this.model?.contextWindow ?? 0; + // Skip overflow check if the message came from a different model. + // This handles the case where user switched from a smaller-context model (e.g. opus) + // to a larger-context model (e.g. codex) - the overflow error from the old model + // shouldn't trigger compaction for the new model. + const sameModel = + this.model && assistantMessage.provider === this.model.provider && assistantMessage.model === this.model.id; + + // Skip overflow check if the error is from before a compaction in the current path. + // This handles the case where an error was kept after compaction (in the "kept" region). + // The error shouldn't trigger another compaction since we already compacted. + // Example: opus fails → switch to codex → compact → switch back to opus → opus error + // is still in context but shouldn't trigger compaction again. + const compactionEntry = this.sessionManager.getBranch().find((e) => e.type === "compaction"); + const errorIsFromBeforeCompaction = + compactionEntry && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime(); + // Case 1: Overflow - LLM returned context overflow error - if (isContextOverflow(assistantMessage, contextWindow)) { + if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) { // Remove the error message from agent state (it IS saved to session for history, // but we don't want it in context for the retry) const messages = this.agent.state.messages; @@ -2005,7 +2134,7 @@ export class AgentSession { */ abortRetry(): void { this._retryAbortController?.abort(); - this._retryAttempt = 0; + // Note: _retryAttempt is reset in the catch block of _autoRetry this._resolveRetry(); } @@ -2046,51 +2175,63 @@ export class AgentSession { * @param command The bash command to execute * @param onChunk Optional streaming callback for output * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix) + * @param options.operations Custom BashOperations for remote execution */ async executeBash( command: string, onChunk?: (chunk: string) => void, - options?: { excludeFromContext?: boolean }, + options?: { excludeFromContext?: boolean; operations?: BashOperations }, ): Promise { this._bashAbortController = new AbortController(); try { - const result = await executeBashCommand(command, { - onChunk, - signal: this._bashAbortController.signal, - }); - - // Create and save message - const bashMessage: BashExecutionMessage = { - role: "bashExecution", - command, - output: result.output, - exitCode: result.exitCode, - cancelled: result.cancelled, - truncated: result.truncated, - fullOutputPath: result.fullOutputPath, - timestamp: Date.now(), - excludeFromContext: options?.excludeFromContext, - }; - - // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering - if (this.isStreaming) { - // Queue for later - will be flushed on agent_end - this._pendingBashMessages.push(bashMessage); - } else { - // Add to agent state immediately - this.agent.appendMessage(bashMessage); - - // Save to session - this.sessionManager.appendMessage(bashMessage); - } + const result = options?.operations + ? await executeBashWithOperations(command, process.cwd(), options.operations, { + onChunk, + signal: this._bashAbortController.signal, + }) + : await executeBashCommand(command, { + onChunk, + signal: this._bashAbortController.signal, + }); + this.recordBashResult(command, result, options); return result; } finally { this._bashAbortController = undefined; } } + /** + * Record a bash execution result in session history. + * Used by executeBash and by extensions that handle bash execution themselves. + */ + recordBashResult(command: string, result: BashResult, options?: { excludeFromContext?: boolean }): void { + const bashMessage: BashExecutionMessage = { + role: "bashExecution", + command, + output: result.output, + exitCode: result.exitCode, + cancelled: result.cancelled, + truncated: result.truncated, + fullOutputPath: result.fullOutputPath, + timestamp: Date.now(), + excludeFromContext: options?.excludeFromContext, + }; + + // If agent is streaming, defer adding to avoid breaking tool_use/tool_result ordering + if (this.isStreaming) { + // Queue for later - will be flushed on agent_end + this._pendingBashMessages.push(bashMessage); + } else { + // Add to agent state immediately + this.agent.appendMessage(bashMessage); + + // Save to session + this.sessionManager.appendMessage(bashMessage); + } + } + /** * Cancel running bash command. */ @@ -2163,6 +2304,7 @@ export class AgentSession { // Set new session await this.sessionManager.setSessionFile(sessionPath); + this.agent.sessionId = this.sessionManager.getSessionId(); // Reload messages const sessionContext = this.sessionManager.buildSessionContext(); @@ -2247,6 +2389,7 @@ export class AgentSession { } else { this.sessionManager.createBranchedSession(selectedEntry.parentId); } + this.agent.sessionId = this.sessionManager.getSessionId(); // Reload messages from entries (works for both file and in-memory mode) const sessionContext = this.sessionManager.buildSessionContext(); diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 646bca3dd..8d3759d33 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -1,6 +1,9 @@ /** * Credential storage for API keys and OAuth tokens. * Handles loading, saving, and refreshing credentials from auth.json. + * + * Uses file locking to prevent race conditions when multiple pi instances + * try to refresh tokens simultaneously. */ import { @@ -434,6 +437,10 @@ export class AuthStorage { onAuth: (info: { url: string; instructions?: string }) => void; onPrompt: (prompt: { message: string; placeholder?: string }) => Promise; onProgress?: (message: string) => void; + /** For providers with local callback servers (e.g., openai-codex), races with browser callback */ + onManualCodeInput?: () => Promise; + /** For cancellation support (e.g., github-copilot polling) */ + signal?: AbortSignal; }, ): Promise { let credentials: OAuthCredentials; @@ -450,16 +457,22 @@ export class AuthStorage { onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }), onPrompt: callbacks.onPrompt, onProgress: callbacks.onProgress, + signal: callbacks.signal, }); break; case "google-gemini-cli": - credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress); + credentials = await loginGeminiCli(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput); break; case "google-antigravity": - credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress); + credentials = await loginAntigravity(callbacks.onAuth, callbacks.onProgress, callbacks.onManualCodeInput); break; case "openai-codex": - credentials = await loginOpenAICodex(callbacks); + credentials = await loginOpenAICodex({ + onAuth: callbacks.onAuth, + onPrompt: callbacks.onPrompt, + onProgress: callbacks.onProgress, + onManualCodeInput: callbacks.onManualCodeInput, + }); break; default: throw new Error(`Unknown OAuth provider: ${provider}`); diff --git a/packages/coding-agent/src/core/bash-executor.ts b/packages/coding-agent/src/core/bash-executor.ts index c446ed4f0..5482b9fb2 100644 --- a/packages/coding-agent/src/core/bash-executor.ts +++ b/packages/coding-agent/src/core/bash-executor.ts @@ -14,6 +14,7 @@ import { nanoid } from "nanoid"; import stripAnsi from "strip-ansi"; import { getShellConfig, killProcessTree, sanitizeBinaryOutput } from "../utils/shell"; import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot"; +import type { BashOperations } from "./tools/bash"; import { DEFAULT_MAX_BYTES, truncateTail } from "./tools/truncate"; import { ScopeSignal } from "./utils"; @@ -59,6 +60,19 @@ function createSanitizer(): TransformStream { }); } +async function pumpStream(readable: ReadableStream, writer: WritableStreamDefaultWriter) { + const reader = readable.pipeThrough(createSanitizer()).getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + await writer.write(value); + } + } finally { + reader.releaseLock(); + } +} + function createOutputSink( spillThreshold: number, maxBuffer: number, @@ -156,21 +170,9 @@ export async function executeBash(command: string, options?: BashExecutorOptions const writer = sink.getWriter(); try { - async function pumpStream(readable: ReadableStream) { - const reader = readable.pipeThrough(createSanitizer()).getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - await writer.write(value); - } - } finally { - reader.releaseLock(); - } - } await Promise.all([ - pumpStream(child.stdout as ReadableStream), - pumpStream(child.stderr as ReadableStream), + pumpStream(child.stdout as ReadableStream, writer), + pumpStream(child.stderr as ReadableStream, writer), ]); } finally { await writer.close(); @@ -196,3 +198,66 @@ export async function executeBash(command: string, options?: BashExecutorOptions ...sink.dump(), }; } + +/** + * Execute a bash command using custom BashOperations. + * Used for remote execution (SSH, containers, etc.). + */ +export async function executeBashWithOperations( + command: string, + cwd: string, + operations: BashOperations, + options?: BashExecutorOptions, +): Promise { + const sink = createOutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk); + const writer = sink.getWriter(); + + // Create a ReadableStream from the callback-based operations.exec + let streamController: ReadableStreamDefaultController; + const dataStream = new ReadableStream({ + start(controller) { + streamController = controller; + }, + }); + + const onData = (data: Buffer) => { + streamController.enqueue(new Uint8Array(data)); + }; + + // Start pumping the stream (will complete when stream closes) + const pumpPromise = pumpStream(dataStream, writer); + + try { + const result = await operations.exec(command, cwd, { + onData, + signal: options?.signal, + timeout: options?.timeout, + }); + + streamController!.close(); + await pumpPromise; + await writer.close(); + + const cancelled = options?.signal?.aborted ?? false; + + return { + exitCode: cancelled ? undefined : (result.exitCode ?? undefined), + cancelled, + ...sink.dump(), + }; + } catch (err) { + streamController!.close(); + await pumpPromise; + await writer.close(); + + if (options?.signal?.aborted) { + return { + exitCode: undefined, + cancelled: true, + ...sink.dump(), + }; + } + + throw err; + } +} diff --git a/packages/coding-agent/src/core/custom-commands/types.ts b/packages/coding-agent/src/core/custom-commands/types.ts index d046e7ae8..e882933a2 100644 --- a/packages/coding-agent/src/core/custom-commands/types.ts +++ b/packages/coding-agent/src/core/custom-commands/types.ts @@ -23,7 +23,7 @@ export interface CustomCommandAPI { /** Injected @sinclair/typebox module */ typebox: typeof import("@sinclair/typebox"); /** Injected pi-coding-agent exports */ - pi: typeof import("../../index.js"); + pi: typeof import("../../index"); } /** diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts index 3e65fd328..fb63e4630 100644 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -40,7 +40,7 @@ export interface CustomToolAPI { /** Injected @sinclair/typebox module */ typebox: typeof import("@sinclair/typebox"); /** Injected pi-coding-agent exports */ - pi: typeof import("../../index.js"); + pi: typeof import("../../index"); } /** diff --git a/packages/coding-agent/src/core/export-html/index.ts b/packages/coding-agent/src/core/export-html/index.ts index 84713308c..895de8cde 100644 --- a/packages/coding-agent/src/core/export-html/index.ts +++ b/packages/coding-agent/src/core/export-html/index.ts @@ -1,6 +1,7 @@ import { existsSync, writeFileSync } from "node:fs"; import { basename } from "node:path"; -import type { AgentState } from "@oh-my-pi/pi-agent-core"; +import type { AgentState, AgentTool } from "@oh-my-pi/pi-agent-core"; +import { buildCodexPiBridge, getCodexInstructions } from "@oh-my-pi/pi-ai"; import { APP_NAME } from "../../config"; import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme"; import { SessionManager } from "../session-manager"; @@ -13,6 +14,33 @@ export interface ExportOptions { themeName?: string; } +/** Info about Codex injection to show inline with model_change entries. */ +interface CodexInjectionInfo { + /** Codex instructions text. */ + instructions: string; + /** Bridge text (tool list). */ + bridge: string; +} + +/** Build Codex injection info for display inline with model_change entries. */ +async function buildCodexInjectionInfo(tools?: AgentTool[]): Promise { + let instructions: string | null = null; + try { + instructions = await getCodexInstructions("gpt-5.1-codex"); + } catch { + // Cache miss is expected before the first Codex request. + } + + const bridgeText = buildCodexPiBridge(tools); + const instructionsText = + instructions ?? "(Codex instructions not cached. Run a Codex request to populate the local cache.)"; + + return { + instructions: instructionsText, + bridge: bridgeText, + }; +} + /** Parse a color string to RGB values. */ function parseColor(color: string): { r: number; g: number; b: number } | undefined { const hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/); @@ -97,6 +125,8 @@ interface SessionData { entries: ReturnType; leafId: string | null; systemPrompt?: string; + /** Info for rendering Codex injection inline with model_change entries. */ + codexInjectionInfo?: CodexInjectionInfo; tools?: { name: string; description: string }[]; } @@ -128,6 +158,7 @@ export async function exportSessionToHtml( entries: sm.getEntries(), leafId: sm.getLeafId(), systemPrompt: state?.systemPrompt, + codexInjectionInfo: await buildCodexInjectionInfo(state?.tools), tools: state?.tools?.map((t) => ({ name: t.name, description: t.description })), }; @@ -149,6 +180,7 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions header: sm.getHeader(), entries: sm.getEntries(), leafId: sm.getLeafId(), + codexInjectionInfo: await buildCodexInjectionInfo(), }; const html = generateHtml(sessionData, opts.themeName); diff --git a/packages/coding-agent/src/core/export-html/template.css b/packages/coding-agent/src/core/export-html/template.css index 31243ec85..1192ff009 100644 --- a/packages/coding-agent/src/core/export-html/template.css +++ b/packages/coding-agent/src/core/export-html/template.css @@ -273,10 +273,65 @@ color: var(--userMessageText); padding: var(--line-height); border-radius: 4px; + position: relative; } .assistant-message { padding: 0; + position: relative; + } + + /* Copy link button - appears on hover */ + .copy-link-btn { + position: absolute; + top: 8px; + right: 8px; + width: 28px; + height: 28px; + padding: 6px; + background: var(--container-bg); + border: 1px solid var(--dim); + border-radius: 4px; + color: var(--muted); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, background 0.15s, color 0.15s; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + } + + .user-message:hover .copy-link-btn, + .assistant-message:hover .copy-link-btn { + opacity: 1; + } + + .copy-link-btn:hover { + background: var(--accent); + color: var(--body-bg); + border-color: var(--accent); + } + + .copy-link-btn.copied { + background: var(--success, #22c55e); + color: white; + border-color: var(--success, #22c55e); + } + + /* Highlight effect for deep-linked messages */ + .user-message.highlight, + .assistant-message.highlight { + animation: highlight-pulse 2s ease-out; + } + + @keyframes highlight-pulse { + 0% { + box-shadow: 0 0 0 3px var(--accent); + } + 100% { + box-shadow: 0 0 0 0 transparent; + } } .assistant-message > .message-timestamp { @@ -446,6 +501,39 @@ font-weight: bold; } + .codex-bridge-toggle { + color: var(--muted); + cursor: pointer; + text-decoration: underline; + font-size: 10px; + } + + .codex-bridge-toggle:hover { + color: var(--accent); + } + + .codex-bridge-content { + display: none; + margin-top: 8px; + padding: 8px; + background: var(--exportCardBg, var(--container-bg)); + border-radius: 4px; + font-size: 11px; + max-height: 300px; + overflow: auto; + } + + .codex-bridge-content pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--muted); + } + + .model-change.show-bridge .codex-bridge-content { + display: block; + } + /* Compaction / Branch Summary - matches customMessage colors from TUI */ .compaction { background: var(--customMessageBg); @@ -501,6 +589,17 @@ margin-top: var(--line-height); } + .system-prompt.provider-prompt { + border-left: 3px solid var(--warning); + } + + .system-prompt-note { + font-size: 10px; + font-style: italic; + color: var(--muted); + margin-top: 4px; + } + /* Tools list */ .tools-list { background: var(--customMessageBg); diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js index b6137a9b8..d98db7fdb 100644 --- a/packages/coding-agent/src/core/export-html/template.js +++ b/packages/coding-agent/src/core/export-html/template.js @@ -12,7 +12,21 @@ bytes[i] = binary.charCodeAt(i); } const data = JSON.parse(new TextDecoder('utf-8').decode(bytes)); - const { header, entries, leafId, systemPrompt, tools } = data; + const { header, entries, leafId: defaultLeafId, systemPrompt, codexInjectionInfo, tools } = data; + + // ============================================================ + // URL PARAMETER HANDLING + // ============================================================ + + // Parse URL parameters for deep linking: leafId and targetId + // Check for injected params (when loaded in iframe via srcdoc) or use window.location + const injectedParams = document.querySelector('meta[name="pi-url-params"]'); + const searchString = injectedParams ? injectedParams.content : window.location.search.substring(1); + const urlParams = new URLSearchParams(searchString); + const urlLeafId = urlParams.get('leafId'); + const urlTargetId = urlParams.get('targetId'); + // Use URL leafId if provided, otherwise fall back to session default + const leafId = urlLeafId || defaultLeafId; // ============================================================ // DATA STRUCTURES @@ -777,16 +791,98 @@ return html; } + /** + * Build a shareable URL for a specific message. + * URL format: base?gistId&leafId=&targetId= + */ + function buildShareUrl(entryId) { + // Check for injected base URL (used when loaded in iframe via srcdoc) + const baseUrlMeta = document.querySelector('meta[name="pi-share-base-url"]'); + const baseUrl = baseUrlMeta ? baseUrlMeta.content : window.location.href.split('?')[0]; + + const url = new URL(window.location.href); + // Find the gist ID (first query param without value, e.g., ?abc123) + const gistId = Array.from(url.searchParams.keys()).find(k => !url.searchParams.get(k)); + + // Build the share URL + const params = new URLSearchParams(); + params.set('leafId', currentLeafId); + params.set('targetId', entryId); + + // If we have an injected base URL (iframe context), use it directly + if (baseUrlMeta) { + return `${baseUrl}&${params.toString()}`; + } + + // Otherwise build from current location (direct file access) + url.search = gistId ? `?${gistId}&${params.toString()}` : `?${params.toString()}`; + return url.toString(); + } + + /** + * Copy text to clipboard with visual feedback. + * Uses navigator.clipboard with fallback to execCommand for HTTP contexts. + */ + async function copyToClipboard(text, button) { + let success = false; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + success = true; + } + } catch { + // Clipboard API failed, try fallback + } + + // Fallback for HTTP or when Clipboard API is unavailable + if (!success) { + try { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + success = document.execCommand('copy'); + document.body.removeChild(textarea); + } catch { + } + } + + if (success && button) { + const originalHtml = button.innerHTML; + button.innerHTML = '✓'; + button.classList.add('copied'); + setTimeout(() => { + button.innerHTML = originalHtml; + button.classList.remove('copied'); + }, 1500); + } + } + + /** + * Render the copy-link button HTML for a message. + */ + function renderCopyLinkButton(entryId) { + return ``; + } + function renderEntry(entry) { const ts = formatTimestamp(entry.timestamp); const tsHtml = ts ? `
${ts}
` : ''; const entryId = `entry-${entry.id}`; + const copyBtnHtml = renderCopyLinkButton(entry.id); if (entry.type === 'message') { const msg = entry.message; if (msg.role === 'user') { - let html = `
${tsHtml}`; + let html = `
${copyBtnHtml}${tsHtml}`; const content = msg.content; if (Array.isArray(content)) { @@ -810,7 +906,7 @@ } if (msg.role === 'assistant') { - let html = `
${tsHtml}`; + let html = `
${copyBtnHtml}${tsHtml}`; for (const block of msg.content) { if (block.type === 'text' && block.text.trim()) { @@ -857,7 +953,16 @@ } if (entry.type === 'model_change') { - return `
${tsHtml}Switched to model: ${escapeHtml(entry.provider)}/${escapeHtml(entry.modelId)}
`; + let html = `
${tsHtml}Switched to model: ${escapeHtml(entry.provider)}/${escapeHtml(entry.modelId)}`; + + if (entry.provider === 'openai-codex' && codexInjectionInfo) { + const fullContent = `# Codex Instructions\n${codexInjectionInfo.instructions}\n\n# Codex-Pi Bridge\n${codexInjectionInfo.bridge}`; + html += ` [bridge prompt]`; + html += `
${escapeHtml(fullContent)}
`; + } + + html += '
'; + return html; } if (entry.type === 'compaction') { @@ -1010,7 +1115,7 @@ return node; } - function navigateTo(targetId, scrollMode = 'target') { + function navigateTo(targetId, scrollMode = 'target', scrollToEntryId = null) { currentLeafId = targetId; const path = getPath(targetId); @@ -1032,15 +1137,30 @@ messagesEl.innerHTML = ''; messagesEl.appendChild(fragment); + // Attach click handlers for copy-link buttons + messagesEl.querySelectorAll('.copy-link-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const entryId = btn.dataset.entryId; + const shareUrl = buildShareUrl(entryId); + copyToClipboard(shareUrl, btn); + }); + }); + // Use setTimeout(0) to ensure DOM is fully laid out before scrolling setTimeout(() => { const content = document.getElementById('content'); if (scrollMode === 'bottom') { content.scrollTop = content.scrollHeight; } else if (scrollMode === 'target') { - const targetEl = document.getElementById(`entry-${targetId}`); + const scrollTargetId = scrollToEntryId || targetId; + const targetEl = document.getElementById(`entry-${scrollTargetId}`); if (targetEl) { targetEl.scrollIntoView({ block: 'center' }); + if (scrollToEntryId) { + targetEl.classList.add('highlight'); + setTimeout(() => targetEl.classList.remove('highlight'), 2000); + } } } }, 0); @@ -1175,9 +1295,14 @@ } }); - // Initial render - don't scroll, stay at top + // Initial render + // If URL has targetId, scroll to that specific message; otherwise stay at top if (leafId) { - navigateTo(leafId, 'none'); + if (urlTargetId && byId.has(urlTargetId)) { + navigateTo(leafId, 'target', urlTargetId); + } else { + navigateTo(leafId, 'none'); + } } else if (entries.length > 0) { // Fallback: use last entry if no leafId navigateTo(entries[entries.length - 1].id, 'none'); diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 33440b895..4e1f08073 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -2,8 +2,14 @@ * Extension system for lifecycle events and custom tools. */ -export { discoverAndLoadExtensions, loadExtensionFromFactory, loadExtensions } from "./loader"; -export type { BranchHandler, ExtensionErrorListener, NavigateTreeHandler, NewSessionHandler } from "./runner"; +export { createExtensionRuntime, discoverAndLoadExtensions, loadExtensionFromFactory, loadExtensions } from "./loader"; +export type { + BranchHandler, + ExtensionErrorListener, + NavigateTreeHandler, + NewSessionHandler, + ShutdownHandler, +} from "./runner"; export { ExtensionRunner } from "./runner"; export type { AgentEndEvent, @@ -11,6 +17,7 @@ export type { // Re-exports AgentToolResult, AgentToolUpdateCallback, + AppAction, AppendEntryHandler, BashToolResultEvent, BeforeAgentStartEvent, @@ -23,26 +30,32 @@ export type { EditToolResultEvent, ExecOptions, ExecResult, + Extension, + ExtensionActions, // API ExtensionAPI, ExtensionCommandContext, + ExtensionCommandContextActions, // Context ExtensionContext, + ExtensionContextActions, // Errors ExtensionError, ExtensionEvent, ExtensionFactory, ExtensionFlag, ExtensionHandler, + ExtensionRuntime, ExtensionShortcut, ExtensionUIContext, + ExtensionUIDialogOptions, FindToolResultEvent, GetActiveToolsHandler, GetAllToolsHandler, + GetThinkingLevelHandler, GrepToolResultEvent, + KeybindingsManager, LoadExtensionsResult, - // Loaded Extension - LoadedExtension, LsToolResultEvent, // Message Rendering MessageRenderer, @@ -52,6 +65,7 @@ export type { RegisteredCommand, RegisteredTool, SendMessageHandler, + SendUserMessageHandler, SessionBeforeBranchEvent, SessionBeforeBranchResult, SessionBeforeCompactEvent, @@ -69,6 +83,8 @@ export type { SessionSwitchEvent, SessionTreeEvent, SetActiveToolsHandler, + SetModelHandler, + SetThinkingLevelHandler, // Events - Tool ToolCallEvent, ToolCallEventResult, @@ -80,6 +96,8 @@ export type { TreePreparation, TurnEndEvent, TurnStartEvent, + UserBashEvent, + UserBashEventResult, WriteToolResultEvent, } from "./types"; // Type guards diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index e588dbd5b..93519e38d 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -11,28 +11,19 @@ import { type ExtensionModule, extensionModuleCapability } from "../../capabilit import { loadSync } from "../../discovery"; import { getExtensionNameFromPath } from "../../discovery/helpers"; import * as piCodingAgent from "../../index"; -import { theme } from "../../modes/interactive/theme/theme"; import { createEventBus, type EventBus } from "../event-bus"; import type { ExecOptions } from "../exec"; import { execCommand } from "../exec"; import { logger } from "../logger"; import type { - AppendEntryHandler, + Extension, ExtensionAPI, ExtensionContext, ExtensionFactory, - ExtensionFlag, - ExtensionShortcut, - ExtensionUIContext, - GetActiveToolsHandler, - GetAllToolsHandler, + ExtensionRuntime, LoadExtensionsResult, - LoadedExtension, MessageRenderer, RegisteredCommand, - RegisteredTool, - SendMessageHandler, - SetActiveToolsHandler, ToolDefinition, } from "./types"; @@ -61,80 +52,62 @@ function resolvePath(extPath: string, cwd: string): string { return path.resolve(cwd, expanded); } -function createNoOpUIContext(): ExtensionUIContext { +type HandlerFn = (...args: unknown[]) => Promise; + +/** + * Create a runtime with throwing stubs for action methods. + * Runner.initialize() replaces these with real implementations. + */ +export function createExtensionRuntime(): ExtensionRuntime { + const notInitialized = () => { + throw new Error("Extension runtime not initialized. Action methods cannot be called during extension loading."); + }; + return { - select: async () => undefined, - confirm: async () => false, - input: async () => undefined, - notify: () => {}, - setStatus: () => {}, - setWidget: () => {}, - setTitle: () => {}, - custom: async () => undefined as never, - setEditorText: () => {}, - getEditorText: () => "", - editor: async () => undefined, - get theme() { - return theme; - }, + sendMessage: notInitialized, + sendUserMessage: notInitialized, + appendEntry: notInitialized, + getActiveTools: notInitialized, + getAllTools: notInitialized, + setActiveTools: notInitialized, + setModel: () => Promise.reject(new Error("Extension runtime not initialized")), + getThinkingLevel: notInitialized, + setThinkingLevel: notInitialized, + flagValues: new Map(), }; } -type HandlerFn = (...args: unknown[]) => Promise; - +/** + * Create the ExtensionAPI for an extension. + * Registration methods write to the extension object. + * Action methods delegate to the shared runtime. + */ function createExtensionAPI( - handlers: Map, - tools: Map, + extension: Extension, + runtime: ExtensionRuntime, cwd: string, - extensionPath: string, eventBus: EventBus, - _sharedUI: { ui: ExtensionUIContext; hasUI: boolean }, -): { - api: ExtensionAPI; - messageRenderers: Map; - commands: Map; - flags: Map; - flagValues: Map; - shortcuts: Map; - setSendMessageHandler: (handler: SendMessageHandler) => void; - setAppendEntryHandler: (handler: AppendEntryHandler) => void; - setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void; - setGetAllToolsHandler: (handler: GetAllToolsHandler) => void; - setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void; - setFlagValue: (name: string, value: boolean | string) => void; -} { - let sendMessageHandler: SendMessageHandler = () => {}; - let appendEntryHandler: AppendEntryHandler = () => {}; - let getActiveToolsHandler: GetActiveToolsHandler = () => []; - let getAllToolsHandler: GetAllToolsHandler = () => []; - let setActiveToolsHandler: SetActiveToolsHandler = () => {}; - - const messageRenderers = new Map(); - const commands = new Map(); - const flags = new Map(); - const flagValues = new Map(); - const shortcuts = new Map(); - +): ExtensionAPI { const api = { logger, typebox: TypeBox, pi: piCodingAgent, on(event: string, handler: HandlerFn): void { - const list = handlers.get(event) ?? []; + const list = extension.handlers.get(event) ?? []; list.push(handler); - handlers.set(event, list); + extension.handlers.set(event, list); }, registerTool(tool: ToolDefinition): void { - tools.set(tool.name, { + extension.tools.set(tool.name, { definition: tool, - extensionPath, + extensionPath: extension.path, }); }, registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void { - commands.set(name, { name, ...options }); + extension.commands.set(name, { name, ...options }); }, registerShortcut( @@ -144,33 +117,38 @@ function createExtensionAPI( handler: (ctx: ExtensionContext) => Promise | void; }, ): void { - shortcuts.set(shortcut, { shortcut, extensionPath, ...options }); + extension.shortcuts.set(shortcut, { shortcut, extensionPath: extension.path, ...options }); }, registerFlag( name: string, options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, ): void { - flags.set(name, { name, extensionPath, ...options }); + extension.flags.set(name, { name, extensionPath: extension.path, ...options }); if (options.default !== undefined) { - flagValues.set(name, options.default); + runtime.flagValues.set(name, options.default); } }, - getFlag(name: string): boolean | string | undefined { - return flagValues.get(name); + registerMessageRenderer(customType: string, renderer: MessageRenderer): void { + extension.messageRenderers.set(customType, renderer as MessageRenderer); }, - registerMessageRenderer(customType: string, renderer: MessageRenderer): void { - messageRenderers.set(customType, renderer as MessageRenderer); + getFlag(name: string): boolean | string | undefined { + if (!extension.flags.has(name)) return undefined; + return runtime.flagValues.get(name); }, sendMessage(message, options): void { - sendMessageHandler(message, options); + runtime.sendMessage(message, options); + }, + + sendUserMessage(content, options): void { + runtime.sendUserMessage(content, options); }, appendEntry(customType: string, data?: unknown): void { - appendEntryHandler(customType, data); + runtime.appendEntry(customType, data); }, exec(command: string, args: string[], options?: ExecOptions) { @@ -178,45 +156,48 @@ function createExtensionAPI( }, getActiveTools(): string[] { - return getActiveToolsHandler(); + return runtime.getActiveTools(); }, getAllTools(): string[] { - return getAllToolsHandler(); + return runtime.getAllTools(); }, setActiveTools(toolNames: string[]): void { - setActiveToolsHandler(toolNames); + runtime.setActiveTools(toolNames); + }, + + setModel(model) { + return runtime.setModel(model); + }, + + getThinkingLevel() { + return runtime.getThinkingLevel(); + }, + + setThinkingLevel(level) { + runtime.setThinkingLevel(level); }, events: eventBus, } as ExtensionAPI; + return api; +} + +/** + * Create an Extension object with empty collections. + */ +function createExtension(extensionPath: string, resolvedPath: string): Extension { return { - api, - messageRenderers, - commands, - flags, - flagValues, - shortcuts, - setSendMessageHandler: (handler: SendMessageHandler) => { - sendMessageHandler = handler; - }, - setAppendEntryHandler: (handler: AppendEntryHandler) => { - appendEntryHandler = handler; - }, - setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => { - getActiveToolsHandler = handler; - }, - setGetAllToolsHandler: (handler: GetAllToolsHandler) => { - getAllToolsHandler = handler; - }, - setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => { - setActiveToolsHandler = handler; - }, - setFlagValue: (name: string, value: boolean | string) => { - flagValues.set(name, value); - }, + path: extensionPath, + resolvedPath, + handlers: new Map(), + tools: new Map(), + messageRenderers: new Map(), + commands: new Map(), + flags: new Map(), + shortcuts: new Map(), }; } @@ -224,8 +205,8 @@ async function loadExtension( extensionPath: string, cwd: string, eventBus: EventBus, - sharedUI: { ui: ExtensionUIContext; hasUI: boolean }, -): Promise<{ extension: LoadedExtension | null; error: string | null }> { + runtime: ExtensionRuntime, +): Promise<{ extension: Extension | null; error: string | null }> { const resolvedPath = resolvePath(extensionPath, cwd); try { @@ -233,48 +214,17 @@ async function loadExtension( const factory = (module.default ?? module) as ExtensionFactory; if (typeof factory !== "function") { - return { extension: null, error: "Extension must export a default function" }; + return { + extension: null, + error: `Extension does not export a valid factory function: ${extensionPath}`, + }; } - const handlers = new Map(); - const tools = new Map(); - const { - api, - messageRenderers, - commands, - flags, - flagValues, - shortcuts, - setSendMessageHandler, - setAppendEntryHandler, - setGetActiveToolsHandler, - setGetAllToolsHandler, - setSetActiveToolsHandler, - setFlagValue, - } = createExtensionAPI(handlers, tools, cwd, extensionPath, eventBus, sharedUI); + const extension = createExtension(extensionPath, resolvedPath); + const api = createExtensionAPI(extension, runtime, cwd, eventBus); + await factory(api); - factory(api); - - return { - extension: { - path: extensionPath, - resolvedPath, - handlers, - tools, - messageRenderers, - commands, - flags, - flagValues, - shortcuts, - setSendMessageHandler, - setAppendEntryHandler, - setGetActiveToolsHandler, - setGetAllToolsHandler, - setSetActiveToolsHandler, - setFlagValue, - }, - error: null, - }; + return { extension, error: null }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { extension: null, error: `Failed to load extension: ${message}` }; @@ -282,64 +232,32 @@ async function loadExtension( } /** - * Create a LoadedExtension from an inline factory function. + * Create an Extension from an inline factory function. */ -export function loadExtensionFromFactory( +export async function loadExtensionFromFactory( factory: ExtensionFactory, cwd: string, eventBus: EventBus, - sharedUI: { ui: ExtensionUIContext; hasUI: boolean }, + runtime: ExtensionRuntime, name = "", -): LoadedExtension { - const handlers = new Map(); - const tools = new Map(); - const { - api, - messageRenderers, - commands, - flags, - flagValues, - shortcuts, - setSendMessageHandler, - setAppendEntryHandler, - setGetActiveToolsHandler, - setGetAllToolsHandler, - setSetActiveToolsHandler, - setFlagValue, - } = createExtensionAPI(handlers, tools, cwd, name, eventBus, sharedUI); - - factory(api); - - return { - path: name, - resolvedPath: name, - handlers, - tools, - messageRenderers, - commands, - flags, - flagValues, - shortcuts, - setSendMessageHandler, - setAppendEntryHandler, - setGetActiveToolsHandler, - setGetAllToolsHandler, - setSetActiveToolsHandler, - setFlagValue, - }; +): Promise { + const extension = createExtension(name, name); + const api = createExtensionAPI(extension, runtime, cwd, eventBus); + await factory(api); + return extension; } /** * Load extensions from paths. */ export async function loadExtensions(paths: string[], cwd: string, eventBus?: EventBus): Promise { - const extensions: LoadedExtension[] = []; + const extensions: Extension[] = []; const errors: Array<{ path: string; error: string }> = []; const resolvedEventBus = eventBus ?? createEventBus(); - const sharedUI = { ui: createNoOpUIContext(), hasUI: false }; + const runtime = createExtensionRuntime(); for (const extPath of paths) { - const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, sharedUI); + const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime); if (error) { errors.push({ path: extPath, error }); @@ -354,10 +272,7 @@ export async function loadExtensions(paths: string[], cwd: string, eventBus?: Ev return { extensions, errors, - setUIContext(uiContext, hasUI) { - sharedUI.ui = uiContext; - sharedUI.hasUI = hasUI; - }, + runtime, }; } @@ -385,6 +300,39 @@ function isExtensionFile(name: string): boolean { return name.endsWith(".ts") || name.endsWith(".js"); } +/** + * Resolve extension entry points from a directory. + */ +function resolveExtensionEntries(dir: string): string[] | null { + const packageJsonPath = path.join(dir, "package.json"); + if (existsSync(packageJsonPath)) { + const manifest = readExtensionManifest(packageJsonPath); + if (manifest?.extensions?.length) { + const entries: string[] = []; + for (const extPath of manifest.extensions) { + const resolvedExtPath = path.resolve(dir, extPath); + if (existsSync(resolvedExtPath)) { + entries.push(resolvedExtPath); + } + } + if (entries.length > 0) { + return entries; + } + } + } + + const indexTs = path.join(dir, "index.ts"); + const indexJs = path.join(dir, "index.js"); + if (existsSync(indexTs)) { + return [indexTs]; + } + if (existsSync(indexJs)) { + return [indexJs]; + } + + return null; +} + /** * Discover extensions in a directory. * @@ -416,29 +364,9 @@ function discoverExtensionsInDir(dir: string): string[] { // 2 & 3. Subdirectories if (entry.isDirectory() || entry.isSymbolicLink()) { - // Check for package.json with "omp"/"pi" field first - const packageJsonPath = path.join(entryPath, "package.json"); - if (existsSync(packageJsonPath)) { - const manifest = readExtensionManifest(packageJsonPath); - if (manifest?.extensions) { - // Load paths declared in manifest (relative to package.json dir) - for (const extPath of manifest.extensions) { - const resolvedExtPath = path.resolve(entryPath, extPath); - if (existsSync(resolvedExtPath)) { - discovered.push(resolvedExtPath); - } - } - continue; - } - } - - // Check for index.ts or index.js - const indexTs = path.join(entryPath, "index.ts"); - const indexJs = path.join(entryPath, "index.js"); - if (existsSync(indexTs)) { - discovered.push(indexTs); - } else if (existsSync(indexJs)) { - discovered.push(indexJs); + const entries = resolveExtensionEntries(entryPath); + if (entries) { + discovered.push(...entries); } } } @@ -491,10 +419,20 @@ export async function discoverAndLoadExtensions( for (const configuredPath of configuredPaths) { const resolved = resolvePath(configuredPath, cwd); if (existsSync(resolved) && statSync(resolved).isDirectory()) { - addPaths(discoverExtensionsInDir(resolved)); - } else { - addPath(resolved); + const entries = resolveExtensionEntries(resolved); + if (entries) { + addPaths(entries); + continue; + } + + const discovered = discoverExtensionsInDir(resolved); + if (discovered.length > 0) { + addPaths(discovered); + } + continue; } + + addPath(resolved); } return loadExtensions(allPaths, cwd, eventBus); diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index e8ff61a61..4c3b8cc81 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -5,41 +5,43 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, Model } from "@oh-my-pi/pi-ai"; import type { KeyId } from "@oh-my-pi/pi-tui"; -import { theme } from "../../modes/interactive/theme/theme"; +import { type Theme, theme } from "../../modes/interactive/theme/theme"; +import { logger } from "../logger"; import type { ModelRegistry } from "../model-registry"; import type { SessionManager } from "../session-manager"; import type { - AppendEntryHandler, BeforeAgentStartEvent, BeforeAgentStartEventResult, ContextEvent, ContextEventResult, + Extension, + ExtensionActions, ExtensionCommandContext, + ExtensionCommandContextActions, ExtensionContext, + ExtensionContextActions, ExtensionError, ExtensionEvent, ExtensionFlag, + ExtensionRuntime, ExtensionShortcut, ExtensionUIContext, - GetActiveToolsHandler, - GetAllToolsHandler, - LoadedExtension, MessageRenderer, RegisteredCommand, RegisteredTool, - SendMessageHandler, SessionBeforeCompactResult, SessionBeforeTreeResult, - SetActiveToolsHandler, ToolCallEvent, ToolCallEventResult, ToolResultEventResult, + UserBashEvent, + UserBashEventResult, } from "./types"; /** Combined result from all before_agent_start handlers */ interface BeforeAgentStartCombinedResult { messages?: NonNullable[]; - systemPromptAppend?: string; + systemPrompt?: string; } export type ExtensionErrorListener = (error: ExtensionError) => void; @@ -56,10 +58,26 @@ export type NavigateTreeHandler = ( options?: { summarize?: boolean }, ) => Promise<{ cancelled: boolean }>; +export type ShutdownHandler = () => void; + +/** + * Helper function to emit session_shutdown event to extensions. + * Returns true if the event was emitted, false if there were no handlers. + */ +export async function emitSessionShutdownEvent(extensionRunner: ExtensionRunner | undefined): Promise { + if (extensionRunner?.hasHandlers("session_shutdown")) { + await extensionRunner.emit({ + type: "session_shutdown", + }); + return true; + } + return false; +} + const noOpUIContext: ExtensionUIContext = { - select: async () => undefined, - confirm: async () => false, - input: async () => undefined, + select: async (_title, _options, _dialogOptions) => undefined, + confirm: async (_title, _message, _dialogOptions) => false, + input: async (_title, _placeholder, _dialogOptions) => undefined, notify: () => {}, setStatus: () => {}, setWidget: () => {}, @@ -71,12 +89,15 @@ const noOpUIContext: ExtensionUIContext = { get theme() { return theme; }, + getAllThemes: () => [], + getTheme: () => undefined, + setTheme: (_theme: string | Theme) => ({ success: false, error: "UI not available" }), }; export class ExtensionRunner { - private extensions: LoadedExtension[]; + private extensions: Extension[]; + private runtime: ExtensionRuntime; private uiContext: ExtensionUIContext; - private hasUI: boolean; private cwd: string; private sessionManager: SessionManager; private modelRegistry: ModelRegistry; @@ -89,72 +110,64 @@ export class ExtensionRunner { private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); private branchHandler: BranchHandler = async () => ({ cancelled: false }); private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); + private shutdownHandler: ShutdownHandler = () => {}; constructor( - extensions: LoadedExtension[], + extensions: Extension[], + runtime: ExtensionRuntime, cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry, ) { this.extensions = extensions; + this.runtime = runtime; this.uiContext = noOpUIContext; - this.hasUI = false; this.cwd = cwd; this.sessionManager = sessionManager; this.modelRegistry = modelRegistry; } - initialize(options: { - getModel: () => Model | undefined; - sendMessageHandler: SendMessageHandler; - appendEntryHandler: AppendEntryHandler; - getActiveToolsHandler: GetActiveToolsHandler; - getAllToolsHandler: GetAllToolsHandler; - setActiveToolsHandler: SetActiveToolsHandler; - newSessionHandler?: NewSessionHandler; - branchHandler?: BranchHandler; - navigateTreeHandler?: NavigateTreeHandler; - isIdle?: () => boolean; - waitForIdle?: () => Promise; - abort?: () => void; - hasPendingMessages?: () => boolean; - uiContext?: ExtensionUIContext; - hasUI?: boolean; - }): void { - this.getModel = options.getModel; - this.isIdleFn = options.isIdle ?? (() => true); - this.waitForIdleFn = options.waitForIdle ?? (async () => {}); - this.abortFn = options.abort ?? (() => {}); - this.hasPendingMessagesFn = options.hasPendingMessages ?? (() => false); + initialize( + actions: ExtensionActions, + contextActions: ExtensionContextActions, + commandContextActions?: ExtensionCommandContextActions, + uiContext?: ExtensionUIContext, + ): void { + // Copy actions into the shared runtime (all extension APIs reference this) + this.runtime.sendMessage = actions.sendMessage; + this.runtime.sendUserMessage = actions.sendUserMessage; + this.runtime.appendEntry = actions.appendEntry; + this.runtime.getActiveTools = actions.getActiveTools; + this.runtime.getAllTools = actions.getAllTools; + this.runtime.setActiveTools = actions.setActiveTools; + this.runtime.setModel = actions.setModel; + this.runtime.getThinkingLevel = actions.getThinkingLevel; + this.runtime.setThinkingLevel = actions.setThinkingLevel; - if (options.newSessionHandler) { - this.newSessionHandler = options.newSessionHandler; - } - if (options.branchHandler) { - this.branchHandler = options.branchHandler; - } - if (options.navigateTreeHandler) { - this.navigateTreeHandler = options.navigateTreeHandler; + // Context actions (required) + this.getModel = contextActions.getModel; + this.isIdleFn = contextActions.isIdle; + this.abortFn = contextActions.abort; + this.hasPendingMessagesFn = contextActions.hasPendingMessages; + this.shutdownHandler = contextActions.shutdown; + + // Command context actions (optional, only for interactive mode) + if (commandContextActions) { + this.waitForIdleFn = commandContextActions.waitForIdle; + this.newSessionHandler = commandContextActions.newSession; + this.branchHandler = commandContextActions.branch; + this.navigateTreeHandler = commandContextActions.navigateTree; } - for (const ext of this.extensions) { - ext.setSendMessageHandler(options.sendMessageHandler); - ext.setAppendEntryHandler(options.appendEntryHandler); - ext.setGetActiveToolsHandler(options.getActiveToolsHandler); - ext.setGetAllToolsHandler(options.getAllToolsHandler); - ext.setSetActiveToolsHandler(options.setActiveToolsHandler); - } - - this.uiContext = options.uiContext ?? noOpUIContext; - this.hasUI = options.hasUI ?? false; + this.uiContext = uiContext ?? noOpUIContext; } - getUIContext(): ExtensionUIContext | null { + getUIContext(): ExtensionUIContext { return this.uiContext; } - getHasUI(): boolean { - return this.hasUI; + hasUI(): boolean { + return this.uiContext !== noOpUIContext; } getExtensionPaths(): string[] { @@ -183,11 +196,7 @@ export class ExtensionRunner { } setFlagValue(name: string, value: boolean | string): void { - for (const ext of this.extensions) { - if (ext.flags.has(name)) { - ext.setFlagValue(name, value); - } - } + this.runtime.flagValues.set(name, value); } private static readonly RESERVED_SHORTCUTS = new Set([ @@ -214,17 +223,20 @@ export class ExtensionRunner { const normalizedKey = key.toLowerCase() as KeyId; if (ExtensionRunner.RESERVED_SHORTCUTS.has(normalizedKey)) { - console.warn( - `Extension shortcut '${key}' from ${shortcut.extensionPath} conflicts with built-in shortcut. Skipping.`, - ); + logger.warn("Extension shortcut conflicts with built-in shortcut", { + key, + extensionPath: shortcut.extensionPath, + }); continue; } const existing = allShortcuts.get(normalizedKey); if (existing) { - console.warn( - `Extension shortcut conflict: '${key}' registered by both ${existing.extensionPath} and ${shortcut.extensionPath}. Using ${shortcut.extensionPath}.`, - ); + logger.warn("Extension shortcut conflict", { + key, + extensionPath: shortcut.extensionPath, + existingExtensionPath: existing.extensionPath, + }); } allShortcuts.set(normalizedKey, shortcut); } @@ -283,10 +295,10 @@ export class ExtensionRunner { return undefined; } - private createContext(): ExtensionContext { + createContext(): ExtensionContext { return { ui: this.uiContext, - hasUI: this.hasUI, + hasUI: this.hasUI(), cwd: this.cwd, sessionManager: this.sessionManager, modelRegistry: this.modelRegistry, @@ -294,10 +306,18 @@ export class ExtensionRunner { isIdle: () => this.isIdleFn(), abort: () => this.abortFn(), hasPendingMessages: () => this.hasPendingMessagesFn(), + shutdown: () => this.shutdownHandler(), hasQueuedMessages: () => this.hasPendingMessagesFn(), }; } + /** + * Request a graceful shutdown. Called by extension tools and event handlers. + */ + shutdown(): void { + this.shutdownHandler(); + } + createCommandContext(): ExtensionCommandContext { return { ...this.createContext(), @@ -394,6 +414,35 @@ export class ExtensionRunner { return result; } + async emitUserBash(event: UserBashEvent): Promise { + const ctx = this.createContext(); + + for (const ext of this.extensions) { + const handlers = ext.handlers.get("user_bash"); + if (!handlers || handlers.length === 0) continue; + + for (const handler of handlers) { + try { + const handlerResult = await handler(event, ctx); + if (handlerResult) { + return handlerResult as UserBashEventResult; + } + } 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: "user_bash", + error: message, + stack, + }); + } + } + } + + return undefined; + } + async emitContext(messages: AgentMessage[]): Promise { const ctx = this.createContext(); let currentMessages = structuredClone(messages); @@ -428,11 +477,13 @@ export class ExtensionRunner { async emitBeforeAgentStart( prompt: string, - images?: ImageContent[], + images: ImageContent[] | undefined, + systemPrompt: string, ): Promise { const ctx = this.createContext(); const messages: NonNullable[] = []; - const systemPromptAppends: string[] = []; + let currentSystemPrompt = systemPrompt; + let systemPromptModified = false; for (const ext of this.extensions) { const handlers = ext.handlers.get("before_agent_start"); @@ -440,7 +491,12 @@ export class ExtensionRunner { for (const handler of handlers) { try { - const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images }; + const event: BeforeAgentStartEvent = { + type: "before_agent_start", + prompt, + images, + systemPrompt: currentSystemPrompt, + }; const handlerResult = await handler(event, ctx); if (handlerResult) { @@ -448,8 +504,9 @@ export class ExtensionRunner { if (result.message) { messages.push(result.message); } - if (result.systemPromptAppend) { - systemPromptAppends.push(result.systemPromptAppend); + if (result.systemPrompt !== undefined) { + currentSystemPrompt = result.systemPrompt; + systemPromptModified = true; } } } catch (err) { @@ -465,10 +522,10 @@ export class ExtensionRunner { } } - if (messages.length > 0 || systemPromptAppends.length > 0) { + if (messages.length > 0 || systemPromptModified) { return { messages: messages.length > 0 ? messages : undefined, - systemPromptAppend: systemPromptAppends.length > 0 ? systemPromptAppends.join("\n\n") : undefined, + systemPrompt: systemPromptModified ? currentSystemPrompt : undefined, }; } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 594c644ee..d28be22a0 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -8,14 +8,17 @@ * - Interact with the user via UI primitives */ -import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; +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, 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"; +import type { BashResult } from "../bash-executor"; import type { CompactionPreparation, CompactionResult } from "../compaction"; import type { EventBus } from "../event-bus"; import type { ExecOptions, ExecResult } from "../exec"; +import type { KeybindingsManager } from "../keybindings"; import type { CustomMessage } from "../messages"; import type { ModelRegistry } from "../model-registry"; import type { @@ -26,28 +29,38 @@ import type { SessionManager, } from "../session-manager"; import type { BashToolDetails, FindToolDetails, GrepToolDetails, LsToolDetails, ReadToolDetails } from "../tools"; +import type { BashOperations } from "../tools/bash"; import type { EditToolDetails } from "../tools/edit"; export type { ExecOptions, ExecResult } from "../exec"; export type { AgentToolResult, AgentToolUpdateCallback }; +export type { AppAction, KeybindingsManager } from "../keybindings"; // ============================================================================ // UI Context // ============================================================================ +/** + * UI dialog options for extensions. + */ +export interface ExtensionUIDialogOptions { + signal?: AbortSignal; + timeout?: number; +} + /** * UI context for extensions to request interactive UI. * Each mode (interactive, RPC, print) provides its own implementation. */ export interface ExtensionUIContext { /** Show a selector and return the user's choice. */ - select(title: string, options: string[]): Promise; + select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise; /** Show a confirmation dialog. */ - confirm(title: string, message: string): Promise; + confirm(title: string, message: string, dialogOptions?: ExtensionUIDialogOptions): Promise; /** Show a text input dialog. */ - input(title: string, placeholder?: string): Promise; + input(title: string, placeholder?: string, dialogOptions?: ExtensionUIDialogOptions): Promise; /** Show a notification to the user. */ notify(message: string, type?: "info" | "warning" | "error"): void; @@ -67,8 +80,10 @@ export interface ExtensionUIContext { factory: ( tui: TUI, theme: Theme, + keybindings: KeybindingsManager, done: (result: T) => void, ) => (Component & { dispose?(): void }) | Promise, + options?: { overlay?: boolean }, ): Promise; /** Set the text in the core input editor. */ @@ -82,6 +97,15 @@ export interface ExtensionUIContext { /** Get the current theme for styling. */ readonly theme: Theme; + + /** Get all available themes with names and paths. */ + getAllThemes(): { name: string; path: string | undefined }[]; + + /** Load a theme by name without switching to it. */ + getTheme(name: string): Theme | undefined; + + /** Set the current theme by name or Theme object. */ + setTheme(theme: string | Theme): { success: boolean; error?: string }; } // ============================================================================ @@ -110,6 +134,8 @@ export interface ExtensionContext { abort(): void; /** Whether there are queued messages waiting */ hasPendingMessages(): boolean; + /** Gracefully shutdown and exit. */ + shutdown(): void; /** @deprecated Use hasPendingMessages() instead */ hasQueuedMessages(): boolean; } @@ -299,6 +325,7 @@ export interface BeforeAgentStartEvent { type: "before_agent_start"; prompt: string; images?: ImageContent[]; + systemPrompt: string; } /** Fired when an agent loop starts */ @@ -327,6 +354,21 @@ export interface TurnEndEvent { toolResults: ToolResultMessage[]; } +// ============================================================================ +// User Bash Events +// ============================================================================ + +/** Fired when user executes a bash command via ! or !! prefix */ +export interface UserBashEvent { + type: "user_bash"; + /** The command to execute */ + command: string; + /** True if !! prefix was used (excluded from LLM context) */ + excludeFromContext: boolean; + /** Current working directory */ + cwd: string; +} + // ============================================================================ // Tool Events // ============================================================================ @@ -430,6 +472,7 @@ export type ExtensionEvent = | AgentEndEvent | TurnStartEvent | TurnEndEvent + | UserBashEvent | ToolCallEvent | ToolResultEvent; @@ -446,6 +489,14 @@ export interface ToolCallEventResult { reason?: string; } +/** Result from user_bash event handler */ +export interface UserBashEventResult { + /** Custom operations to use for execution */ + operations?: BashOperations; + /** Full replacement: extension handled execution, use this result */ + result?: BashResult; +} + export interface ToolResultEventResult { content?: (TextContent | ImageContent)[]; details?: unknown; @@ -454,7 +505,8 @@ export interface ToolResultEventResult { export interface BeforeAgentStartEventResult { message?: Pick; - systemPromptAppend?: string; + /** Replace the system prompt for this turn. If multiple extensions return this, they are chained. */ + systemPrompt?: string; } export interface SessionBeforeSwitchResult { @@ -526,7 +578,7 @@ export interface ExtensionAPI { typebox: typeof import("@sinclair/typebox"); /** Injected pi-coding-agent exports for accessing SDK utilities */ - pi: typeof import("../../index.js"); + pi: typeof piCodingAgent; // ========================================================================= // Event Subscription @@ -559,6 +611,7 @@ export interface ExtensionAPI { on(event: "turn_end", handler: ExtensionHandler): void; on(event: "tool_call", handler: ExtensionHandler): void; on(event: "tool_result", handler: ExtensionHandler): void; + on(event: "user_bash", handler: ExtensionHandler): void; // ========================================================================= // Tool Registration @@ -613,6 +666,12 @@ export interface ExtensionAPI { options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ): void; + /** Send a user message to the agent. Always triggers a turn. */ + sendUserMessage( + content: string | (TextContent | ImageContent)[], + options?: { deliverAs?: "steer" | "followUp" }, + ): void; + /** Append a custom entry to the session for state persistence (not sent to LLM). */ appendEntry(customType: string, data?: T): void; @@ -628,12 +687,21 @@ export interface ExtensionAPI { /** Set the active tools by name. */ setActiveTools(toolNames: string[]): void; + /** Set the current model. Returns false if no API key available. */ + setModel(model: Model): Promise; + + /** Get current thinking level. */ + getThinkingLevel(): ThinkingLevel; + + /** Set thinking level (clamped to model capabilities). */ + setThinkingLevel(level: ThinkingLevel): void; + /** Shared event bus for extension communication. */ events: EventBus; } -/** Extension factory function type. */ -export type ExtensionFactory = (pi: ExtensionAPI) => void; +/** Extension factory function type. Supports both sync and async initialization. */ +export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise; // ============================================================================ // Loaded Extension Types @@ -666,6 +734,11 @@ export type SendMessageHandler = ( options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ) => void; +export type SendUserMessageHandler = ( + content: string | (TextContent | ImageContent)[], + options?: { deliverAs?: "steer" | "followUp" }, +) => void; + export type AppendEntryHandler = (customType: string, data?: T) => void; export type GetActiveToolsHandler = () => string[]; @@ -674,8 +747,55 @@ export type GetAllToolsHandler = () => string[]; export type SetActiveToolsHandler = (toolNames: string[]) => void; +export type SetModelHandler = (model: Model) => Promise; + +export type GetThinkingLevelHandler = () => ThinkingLevel; + +export type SetThinkingLevelHandler = (level: ThinkingLevel) => void; + +/** Shared state created by loader, used during registration and runtime. */ +export interface ExtensionRuntimeState { + flagValues: Map; +} + +/** Action implementations for ExtensionAPI methods. */ +export interface ExtensionActions { + sendMessage: SendMessageHandler; + sendUserMessage: SendUserMessageHandler; + appendEntry: AppendEntryHandler; + getActiveTools: GetActiveToolsHandler; + getAllTools: GetAllToolsHandler; + setActiveTools: SetActiveToolsHandler; + setModel: SetModelHandler; + getThinkingLevel: GetThinkingLevelHandler; + setThinkingLevel: SetThinkingLevelHandler; +} + +/** Actions for ExtensionContext (ctx.* in event handlers). */ +export interface ExtensionContextActions { + getModel: () => Model | undefined; + isIdle: () => boolean; + abort: () => void; + hasPendingMessages: () => boolean; + shutdown: () => void; +} + +/** Actions for ExtensionCommandContext (ctx.* in command handlers). */ +export interface ExtensionCommandContextActions { + waitForIdle: () => Promise; + newSession: (options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; + }) => Promise<{ cancelled: boolean }>; + branch: (entryId: string) => Promise<{ cancelled: boolean }>; + navigateTree: (targetId: string, options?: { summarize?: boolean }) => Promise<{ cancelled: boolean }>; +} + +/** Full runtime = state + actions. */ +export interface ExtensionRuntime extends ExtensionRuntimeState, ExtensionActions {} + /** Loaded extension with all registered items. */ -export interface LoadedExtension { +export interface Extension { path: string; resolvedPath: string; handlers: Map; @@ -683,21 +803,14 @@ export interface LoadedExtension { messageRenderers: Map; commands: Map; flags: Map; - flagValues: Map; shortcuts: Map; - setSendMessageHandler: (handler: SendMessageHandler) => void; - setAppendEntryHandler: (handler: AppendEntryHandler) => void; - setGetActiveToolsHandler: (handler: GetActiveToolsHandler) => void; - setGetAllToolsHandler: (handler: GetAllToolsHandler) => void; - setSetActiveToolsHandler: (handler: SetActiveToolsHandler) => void; - setFlagValue: (name: string, value: boolean | string) => void; } /** Result of loading extensions. */ export interface LoadExtensionsResult { - extensions: LoadedExtension[]; + extensions: Extension[]; errors: Array<{ path: string; error: string }>; - setUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void; + runtime: ExtensionRuntime; } // ============================================================================ diff --git a/packages/coding-agent/src/core/extensions/wrapper.ts b/packages/coding-agent/src/core/extensions/wrapper.ts index f3dcc5a1c..e15eeeebd 100644 --- a/packages/coding-agent/src/core/extensions/wrapper.ts +++ b/packages/coding-agent/src/core/extensions/wrapper.ts @@ -6,12 +6,12 @@ import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-m import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import type { Theme } from "../../modes/interactive/theme/theme"; import type { ExtensionRunner } from "./runner"; -import type { ExtensionContext, RegisteredTool, ToolCallEventResult, ToolResultEventResult } from "./types"; +import type { RegisteredTool, ToolCallEventResult, ToolResultEventResult } from "./types"; /** * Wrap a RegisteredTool into an AgentTool. */ -export function wrapRegisteredTool(registeredTool: RegisteredTool, getContext: () => ExtensionContext): AgentTool { +export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: ExtensionRunner): AgentTool { const { definition } = registeredTool; return { name: definition.name, @@ -19,7 +19,7 @@ export function wrapRegisteredTool(registeredTool: RegisteredTool, getContext: ( description: definition.description, parameters: definition.parameters, execute: (toolCallId, params, signal, onUpdate) => - definition.execute(toolCallId, params, onUpdate, getContext(), signal), + definition.execute(toolCallId, params, onUpdate, runner.createContext(), signal), renderCall: definition.renderCall ? (args, theme) => definition.renderCall?.(args, theme as Theme) : undefined, renderResult: definition.renderResult ? (result, options, theme) => @@ -35,11 +35,8 @@ export function wrapRegisteredTool(registeredTool: RegisteredTool, getContext: ( /** * Wrap all registered tools into AgentTools. */ -export function wrapRegisteredTools( - registeredTools: RegisteredTool[], - getContext: () => ExtensionContext, -): AgentTool[] { - return registeredTools.map((rt) => wrapRegisteredTool(rt, getContext)); +export function wrapRegisteredTools(registeredTools: RegisteredTool[], runner: ExtensionRunner): AgentTool[] { + return registeredTools.map((rt) => wrapRegisteredTool(rt, runner)); } /** diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 16a69ebe8..6dab39d1f 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -747,7 +747,7 @@ export interface HookAPI { /** Injected @sinclair/typebox module */ typebox: typeof import("@sinclair/typebox"); /** Injected pi-coding-agent exports */ - pi: typeof import("../../index.js"); + pi: typeof import("../../index"); } /** diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 4de2f51de..ef07cc663 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -11,7 +11,7 @@ export { type PromptOptions, type SessionStats, } from "./agent-session"; -export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor"; +export { type BashExecutorOptions, type BashResult, executeBash, executeBashWithOperations } from "./bash-executor"; export type { CompactionResult } from "./compaction/index"; export { discoverAndLoadExtensions, @@ -21,6 +21,7 @@ export { type ExtensionFactory, ExtensionRunner, type ExtensionUIContext, + type ExtensionUIDialogOptions, loadExtensionFromFactory, type ToolDefinition, } from "./extensions/index"; diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 4233a0c9f..3262a011a 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -26,7 +26,8 @@ export type AppAction = | "expandTools" | "toggleThinking" | "externalEditor" - | "followUp"; + | "followUp" + | "dequeue"; /** * All configurable actions. @@ -56,6 +57,7 @@ export const DEFAULT_APP_KEYBINDINGS: Record = { toggleThinking: "ctrl+t", externalEditor: "ctrl+g", followUp: "alt+enter", + dequeue: "alt+up", }; /** @@ -80,6 +82,7 @@ const APP_ACTIONS: AppAction[] = [ "toggleThinking", "externalEditor", "followUp", + "dequeue", ]; function isAppAction(action: string): action is AppAction { diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 1cede56cc..904161e0e 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -384,7 +384,7 @@ export class ModelRegistry { * Find a model by provider and ID. */ find(provider: string, modelId: string): Model | undefined { - return this.models.find((m) => m.provider === provider && m.id === modelId) ?? undefined; + return this.models.find((m) => m.provider === provider && m.id === modelId); } /** diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index ce2c42f3b..bd2b1d0b1 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -25,12 +25,13 @@ export const defaultModelPerProvider: Record = { cerebras: "zai-glm-4.6", zai: "glm-4.6", mistral: "devstral-medium-latest", - opencode: "claude-sonnet-4-5", + opencode: "claude-opus-4-5", }; export interface ScopedModel { model: Model; thinkingLevel: ThinkingLevel; + explicitThinkingLevel?: boolean; } /** Priority chain for auto-discovering smol/fast models */ @@ -124,6 +125,7 @@ export interface ParsedModelResult { model: Model | undefined; thinkingLevel: ThinkingLevel; warning: string | undefined; + explicitThinkingLevel: boolean; } /** @@ -143,14 +145,14 @@ export function parseModelPattern(pattern: string, availableModels: Model[] // Try exact match first const exactMatch = tryMatchModel(pattern, availableModels); if (exactMatch) { - return { model: exactMatch, thinkingLevel: "off", warning: undefined }; + return { model: exactMatch, thinkingLevel: "off", warning: undefined, explicitThinkingLevel: false }; } // No match - try splitting on last colon if present const lastColonIndex = pattern.lastIndexOf(":"); if (lastColonIndex === -1) { // No colons, pattern simply doesn't match any model - return { model: undefined, thinkingLevel: "off", warning: undefined }; + return { model: undefined, thinkingLevel: "off", warning: undefined, explicitThinkingLevel: false }; } const prefix = pattern.substring(0, lastColonIndex); @@ -162,10 +164,12 @@ export function parseModelPattern(pattern: string, availableModels: Model[] if (result.model) { // Only use this thinking level if no warning from inner recursion // (if there was an invalid suffix deeper, we already have "off") + const isExplicit = !result.warning; return { model: result.model, - thinkingLevel: result.warning ? "off" : suffix, + thinkingLevel: isExplicit ? suffix : "off", warning: result.warning, + explicitThinkingLevel: isExplicit, }; } return result; @@ -176,7 +180,8 @@ export function parseModelPattern(pattern: string, availableModels: Model[] return { model: result.model, thinkingLevel: "off", - warning: `Invalid thinking level "${suffix}" in pattern "${pattern}". Using "off" instead.`, + warning: `Invalid thinking level "${suffix}" in pattern "${pattern}". Using default instead.`, + explicitThinkingLevel: false, }; } return result; @@ -205,11 +210,13 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model const colonIdx = pattern.lastIndexOf(":"); let globPattern = pattern; let thinkingLevel: ThinkingLevel = "off"; + let explicitThinkingLevel = false; if (colonIdx !== -1) { const suffix = pattern.substring(colonIdx + 1); if (isValidThinkingLevel(suffix)) { thinkingLevel = suffix; + explicitThinkingLevel = true; globPattern = pattern.substring(0, colonIdx); } } @@ -228,13 +235,13 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model for (const model of matchingModels) { if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) { - scopedModels.push({ model, thinkingLevel }); + scopedModels.push({ model, thinkingLevel, explicitThinkingLevel }); } } continue; } - const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels); + const { model, thinkingLevel, warning, explicitThinkingLevel } = parseModelPattern(pattern, availableModels); if (warning) { console.warn(chalk.yellow(`Warning: ${warning}`)); @@ -247,7 +254,7 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model // Avoid duplicates if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) { - scopedModels.push({ model, thinkingLevel }); + scopedModels.push({ model, thinkingLevel, explicitThinkingLevel }); } } @@ -304,9 +311,11 @@ export async function findInitialModel(options: { // 2. Use first model from scoped models (skip if continuing/resuming) if (scopedModels.length > 0 && !isContinuing) { + const scoped = scopedModels[0]; + const scopedThinkingLevel = scoped.explicitThinkingLevel ? scoped.thinkingLevel : (defaultThinkingLevel ?? "off"); return { - model: scopedModels[0].model, - thinkingLevel: scopedModels[0].thinkingLevel, + model: scoped.model, + thinkingLevel: scopedThinkingLevel, fallbackMessage: undefined, }; } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index d6e784a97..62528e05a 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -27,8 +27,8 @@ */ import { join } from "node:path"; -import { Agent, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; -import type { Model } from "@oh-my-pi/pi-ai"; +import { Agent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import type { Message, Model } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import chalk from "chalk"; // Import discovery to register all providers on startup @@ -51,9 +51,10 @@ import { type ExtensionContext, type ExtensionFactory, ExtensionRunner, + type ExtensionUIContext, type LoadExtensionsResult, - type LoadedExtension, loadExtensionFromFactory, + loadExtensions, type ToolDefinition, wrapRegisteredTools, wrapToolWithExtensions, @@ -66,7 +67,7 @@ import { formatModelString, parseModelString } from "./model-resolver"; import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates"; import { SessionManager } from "./session-manager"; import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager"; -import { loadSkills as loadSkillsInternal, type Skill } from "./skills"; +import { loadSkills as loadSkillsInternal, type Skill, type SkillWarning } from "./skills"; import { type FileSlashCommand, loadSlashCommands as loadSlashCommandsInternal } from "./slash-commands"; import { closeAllConnections } from "./ssh/connection-manager"; import { unmountAll } from "./ssh/sshfs-mount"; @@ -129,11 +130,13 @@ export interface CreateAgentSessionOptions { extensions?: ExtensionFactory[]; /** Additional extension paths to load (merged with discovery). */ additionalExtensionPaths?: string[]; + /** Disable extension discovery (explicit paths still load). */ + disableExtensionDiscovery?: boolean; /** * Pre-loaded extensions (skips file discovery). * @internal Used by CLI when extensions are loaded early to parse custom flags. */ - preloadedExtensions?: LoadedExtension[]; + preloadedExtensions?: LoadExtensionsResult; /** Shared event bus for tool/extension communication. Default: creates new bus. */ eventBus?: EventBus; @@ -172,8 +175,10 @@ export interface CreateAgentSessionOptions { export interface CreateAgentSessionResult { /** The created session */ session: AgentSession; - /** Extensions result (for UI context setup in interactive mode) */ + /** Extensions result (loaded extensions + runtime) */ extensionsResult: LoadExtensionsResult; + /** Update tool UI context (interactive mode) */ + setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void; /** MCP manager for server lifecycle management (undefined if MCP disabled) */ mcpManager?: MCPManager; /** Warning if session was restored with a different model than saved */ @@ -274,12 +279,15 @@ export async function discoverExtensions(cwd?: string): Promise 0) { - extensionsResult = { - extensions: options.preloadedExtensions, - errors: [], - setUIContext: () => {}, - }; + if (options.disableExtensionDiscovery) { + const configuredPaths = options.additionalExtensionPaths ?? []; + extensionsResult = await loadExtensions(configuredPaths, cwd, eventBus); + time("loadExtensions"); + for (const { path, error } of extensionsResult.errors) { + logger.error("Failed to load extension", { path, error }); + } + } else if (options.preloadedExtensions) { + extensionsResult = options.preloadedExtensions; } else { // Merge CLI extension paths with settings extension paths const configuredPaths = [...(options.additionalExtensionPaths ?? []), ...settingsManager.getExtensionPaths()]; @@ -746,36 +767,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Load inline extensions from factories if (inlineExtensions.length > 0) { - const uiHolder: { ui: any; hasUI: boolean } = { - ui: { - select: async () => undefined, - confirm: async () => false, - input: async () => undefined, - notify: () => {}, - setStatus: () => {}, - setWidget: () => {}, - setTitle: () => {}, - custom: async () => undefined as never, - setEditorText: () => {}, - getEditorText: () => "", - editor: async () => undefined, - get theme() { - return {} as any; - }, - }, - hasUI: false, - }; for (let i = 0; i < inlineExtensions.length; i++) { const factory = inlineExtensions[i]; - const loaded = loadExtensionFromFactory(factory, cwd, eventBus, uiHolder, ``); + const loaded = await loadExtensionFromFactory( + factory, + cwd, + eventBus, + extensionsResult.runtime, + ``, + ); extensionsResult.extensions.push(loaded); } - const originalSetUIContext = extensionsResult.setUIContext; - extensionsResult.setUIContext = (uiContext, hasUI) => { - originalSetUIContext(uiContext, hasUI); - uiHolder.ui = uiContext; - uiHolder.hasUI = hasUI; - }; } // Discover custom commands (TypeScript slash commands) @@ -787,7 +789,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} let extensionRunner: ExtensionRunner | undefined; if (extensionsResult.extensions.length > 0) { - extensionRunner = new ExtensionRunner(extensionsResult.extensions, cwd, sessionManager, modelRegistry); + extensionRunner = new ExtensionRunner( + extensionsResult.extensions, + extensionsResult.runtime, + cwd, + sessionManager, + modelRegistry, + ); } const getSessionContext = () => ({ @@ -810,35 +818,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return { definition, extensionPath: "" }; }) ?? []), ]; - const wrappedExtensionTools = wrapRegisteredTools(allCustomTools, () => ({ - ui: extensionRunner?.getUIContext() ?? { - select: async () => undefined, - confirm: async () => false, - input: async () => undefined, - notify: () => {}, - setStatus: () => {}, - setWidget: () => {}, - setTitle: () => {}, - custom: async () => undefined as never, - setEditorText: () => {}, - getEditorText: () => "", - editor: async () => undefined, - get theme() { - return {} as any; - }, - }, - hasUI: extensionRunner?.getHasUI() ?? false, - cwd, - sessionManager, - modelRegistry, - model: agent.state.model, - isIdle: () => !session.isStreaming, - abort: () => { - session.abort(); - }, - hasPendingMessages: () => session.queuedMessageCount > 0, - hasQueuedMessages: () => session.queuedMessageCount > 0, - })); + const wrappedExtensionTools = extensionRunner ? wrapRegisteredTools(allCustomTools, extensionRunner) : []; // All built-in tools are active (conditional tools like git/ask return null from factory if disabled) const toolRegistry = new Map(); @@ -894,9 +874,44 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd); time("discoverSlashCommands"); - const baseSetUIContext = extensionsResult.setUIContext; - extensionsResult.setUIContext = (uiContext, hasUI) => { - baseSetUIContext(uiContext, hasUI); + // Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth) + const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => { + const converted = convertToLlm(messages); + // Check setting dynamically so mid-session changes take effect + if (!settingsManager.getBlockImages()) { + return converted; + } + // Filter out ImageContent from all messages, replacing with text placeholder + return converted.map((msg) => { + if (msg.role === "user" || msg.role === "toolResult") { + const content = msg.content; + if (Array.isArray(content)) { + const hasImages = content.some((c) => c.type === "image"); + if (hasImages) { + const filteredContent = content + .map((c) => + c.type === "image" ? { type: "text" as const, text: "Image reading is disabled." } : c, + ) + .filter( + (c, i, arr) => + // Dedupe consecutive "Image reading is disabled." texts + !( + c.type === "text" && + c.text === "Image reading is disabled." && + i > 0 && + arr[i - 1].type === "text" && + (arr[i - 1] as { type: "text"; text: string }).text === "Image reading is disabled." + ), + ); + return { ...msg, content: filteredContent }; + } + } + } + return msg; + }); + }; + + const setToolUIContext = (uiContext: ExtensionUIContext, hasUI: boolean) => { toolContextStore.setUIContext(uiContext, hasUI); }; @@ -907,7 +922,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} thinkingLevel, tools: Array.from(toolRegistry.values()), }, - convertToLlm, + convertToLlm: convertToLlmWithBlockImages, + sessionId: sessionManager.getSessionId(), transformContext: extensionRunner ? async (messages) => { return extensionRunner.emitContext(messages); @@ -916,6 +932,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} steeringMode: settingsManager.getSteeringMode(), followUpMode: settingsManager.getFollowUpMode(), interruptMode: settingsManager.getInterruptMode(), + thinkingBudgets: settingsManager.getThinkingBudgets(), getToolContext: toolContextStore.getContext, getApiKey: async () => { const currentModel = agent.state.model; @@ -951,6 +968,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} slashCommands, extensionRunner, customCommands: customCommandsResult.commands, + skills, + skillWarnings, skillsSettings: settingsManager.getSkillsSettings(), modelRegistry, toolRegistry, @@ -980,6 +999,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return { session, extensionsResult, + setToolUIContext, mcpManager, modelFallbackMessage, lspServers, diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 610f9fce8..f4513920e 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -45,6 +45,14 @@ export interface TerminalSettings { export interface ImageSettings { autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility) + blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers +} + +export interface ThinkingBudgetsSettings { + minimal?: number; + low?: number; + medium?: number; + high?: number; } export type NotificationMethod = "bell" | "osc99" | "osc9" | "auto" | "off"; @@ -179,6 +187,7 @@ export interface Settings { shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) 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 */ env?: Record; extensions?: string[]; // Array of extension file paths @@ -489,23 +498,29 @@ export class SettingsManager { } private save(): void { - if (!this.persist || !this.settingsPath) return; + if (this.persist && this.settingsPath) { + try { + const dir = dirname(this.settingsPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } - try { - const dir = dirname(this.settingsPath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); + // Re-read current file to preserve any settings added externally while running + const currentFileSettings = SettingsManager.loadFromFile(this.settingsPath); + // Merge: file settings as base, globalSettings (in-memory changes) as overrides + const mergedSettings = deepMergeSettings(currentFileSettings, this.globalSettings); + this.globalSettings = mergedSettings; + + // Save merged settings (project settings are read-only) + writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8"); + } catch (error) { + console.error(`Warning: Could not save settings file: ${error}`); } - - // Save only global settings (project settings are read-only) - writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8"); - - // Re-merge project settings into active settings (preserve overrides) - const projectSettings = this.loadProjectSettings(); - this.rebuildSettings(projectSettings); - } catch (error) { - console.error(`Warning: Could not save settings file: ${error}`); } + + // Always re-merge to update active settings (needed for both file and inMemory modes) + const projectSettings = this.loadProjectSettings(); + this.rebuildSettings(projectSettings); } getLastChangelogVersion(): string | undefined { @@ -668,6 +683,10 @@ export class SettingsManager { }; } + getThinkingBudgets(): ThinkingBudgetsSettings | undefined { + return this.settings.thinkingBudgets; + } + getHideThinkingBlock(): boolean { return this.settings.hideThinkingBlock ?? false; } @@ -773,6 +792,18 @@ export class SettingsManager { this.save(); } + getBlockImages(): boolean { + return this.settings.images?.blockImages ?? false; + } + + setBlockImages(blocked: boolean): void { + if (!this.globalSettings.images) { + this.globalSettings.images = {}; + } + this.globalSettings.images.blockImages = blocked; + this.save(); + } + getEnabledModels(): string[] | undefined { return this.settings.enabledModels; } diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 242412625..60f6ca5d2 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -9,7 +9,6 @@ import chalk from "chalk"; import { contextFileCapability } from "../capability/context-file"; import type { Rule } from "../capability/rule"; import { systemPromptCapability } from "../capability/system-prompt"; -import { getDocsPath, getExamplesPath, getReadmePath } from "../config"; import { type ContextFile, loadSync, type SystemPrompt as SystemPromptFile } from "../discovery/index"; import systemPromptTemplate from "../prompts/system-prompt.md" with { type: "text" }; import type { SkillsSettings } from "./settings-manager"; @@ -772,7 +771,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin const contextFiles = providedContextFiles ?? loadProjectContextFiles({ cwd: resolvedCwd }); // Build tools list based on selected tools - const toolsList = toolNames?.map((name) => `- ${name}: ${toolDescriptions[name as ToolName]}`).join("\n") ?? ""; + const selectedToolNames = toolNames ?? (["read", "bash", "edit", "write"] as ToolName[]); + const toolsList = + selectedToolNames.length > 0 + ? selectedToolNames.map((name) => `- ${name}: ${toolDescriptions[name as ToolName]}`).join("\n") + : "(none)"; // Resolve skills: use provided or discover const skills = @@ -804,11 +807,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin return prompt; } - // Get absolute paths to documentation and examples - const readmePath = getReadmePath(); - const docsPath = getDocsPath(); - const examplesPath = getExamplesPath(); - // Generate anti-bash rules (returns null if not applicable) const antiBashSection = generateAntiBashRules(Array.from(tools?.keys() ?? [])); const environmentInfo = formatEnvironmentInfo(); @@ -821,11 +819,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin const hasWrite = tools?.has("write"); const hasRead = tools?.has("read"); - // Read-only mode notice (no bash, edit, or write) - if (!hasBash && !hasEdit && !hasWrite) { - guidelinesList.push("You are in READ-ONLY mode - you cannot modify files or execute arbitrary commands"); - } - // Bash without edit/write = read-only bash mode if (hasBash && !hasEdit && !hasWrite) { guidelinesList.push( @@ -870,9 +863,6 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin antiBashSection: antiBashBlock, guidelines, environmentInfo, - readmePath, - docsPath, - examplesPath, }); prompt = appendBlock(prompt, resolvedAppendPrompt); diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index 1dc0ca856..cef564514 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -5,10 +5,11 @@ import { Text } from "@oh-my-pi/pi-tui"; import { Type } from "@sinclair/typebox"; import type { Theme } from "../../modes/interactive/theme/theme"; import bashDescription from "../../prompts/tools/bash.md" with { type: "text" }; -import { executeBash } from "../bash-executor"; +import { type BashExecutorOptions, executeBash, executeBashWithOperations } from "../bash-executor"; import type { RenderResultOptions } from "../custom-tools/types"; import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor"; import type { ToolSession } from "./index"; +import { resolveToCwd } from "./path-utils"; import { createToolUIKit } from "./render-utils"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate"; @@ -25,7 +26,28 @@ export interface BashToolDetails { fullOutputPath?: string; } -export function createBashTool(session: ToolSession): AgentTool { +/** + * Pluggable operations for bash execution. + * Override to delegate command execution to remote systems. + */ +export interface BashOperations { + exec: ( + command: string, + cwd: string, + options: { + onData: (data: Buffer) => void; + signal?: AbortSignal; + timeout?: number; + }, + ) => Promise<{ exitCode: number | null }>; +} + +export interface BashToolOptions { + /** Custom operations for command execution. Default: local shell */ + operations?: BashOperations; +} + +export function createBashTool(session: ToolSession, options?: BashToolOptions): AgentTool { return { name: "bash", label: "Bash", @@ -53,11 +75,22 @@ export function createBashTool(session: ToolSession): AgentTool>; + try { + cwdStat = await Bun.file(commandCwd).stat(); + } catch { + throw new Error(`Working directory does not exist: ${commandCwd}`); + } + if (!cwdStat.isDirectory()) { + throw new Error(`Working directory is not a directory: ${commandCwd}`); + } + // Track output for streaming updates let currentOutput = ""; - const result = await executeBash(command, { - cwd: workdir ?? session.cwd, + const executorOptions: BashExecutorOptions = { + cwd: commandCwd, timeout: timeout ? timeout * 1000 : undefined, // Convert to milliseconds signal, onChunk: (chunk) => { @@ -72,7 +105,12 @@ export function createBashTool(session: ToolSession): AgentTool Bun.file(path).text(), + writeFile: async (path, content) => { + await Bun.write(path, content); + }, + exists: async (path) => Bun.file(path).exists(), +}; + const editSchema = Type.Object({ path: Type.String({ description: "Path to the file to edit (relative or absolute)" }), oldText: Type.String({ @@ -45,6 +54,8 @@ export function createEditTool(session: ToolSession): AgentTool 0) { + if (diagnostics && messages && messages.length > 0) { resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`; resultText += messages.map((d) => ` ${d}`).join("\n"); } diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index f5d867db8..963411ec2 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -44,6 +44,22 @@ export interface FindToolDetails { error?: string; } +/** + * Pluggable operations for the find tool. + * Override these to delegate file search to remote systems (e.g., SSH). + */ +export interface FindOperations { + /** Check if path exists */ + exists: (absolutePath: string) => Promise | boolean; + /** Find files matching glob pattern. Returns relative paths. */ + glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise | string[]; +} + +export interface FindToolOptions { + /** Custom operations for find. Default: local filesystem + fd */ + operations?: FindOperations; +} + async function captureCommandOutput( command: string, args: string[], @@ -91,7 +107,9 @@ async function captureCommandOutput( return { stdout, stderr, exitCode, aborted: scope.aborted }; } -export function createFindTool(session: ToolSession): AgentTool { +export function createFindTool(session: ToolSession, options?: FindToolOptions): AgentTool { + const customOps = options?.operations; + return { name: "find", label: "Find", @@ -117,12 +135,6 @@ export function createFindTool(session: ToolSession): AgentTool { return untilAborted(signal, async () => { - // Ensure fd is available - const fdPath = await ensureTool("fd", true); - if (!fdPath) { - throw new Error("fd is not available and could not be downloaded"); - } - const searchPath = resolveToCwd(searchDir || ".", session.cwd); const scopePath = (() => { const relative = path.relative(session.cwd, searchPath).replace(/\\/g, "/"); @@ -133,6 +145,73 @@ export function createFindTool(session: ToolSession): AgentTool { + if (p.startsWith(searchPath)) { + return p.slice(searchPath.length + 1); + } + return path.relative(searchPath, p); + }); + + const resultLimitReached = relativized.length >= effectiveLimit; + const rawOutput = relativized.join("\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + + let resultOutput = truncation.content; + const details: FindToolDetails = { + scopePath, + fileCount: relativized.length, + files: relativized, + truncated: resultLimitReached || truncation.truncated, + }; + const notices: string[] = []; + + if (resultLimitReached) { + notices.push( + `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`, + ); + details.resultLimitReached = effectiveLimit; + } + + if (truncation.truncated) { + notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); + details.truncation = truncation; + } + + if (notices.length > 0) { + resultOutput += `\n\n[${notices.join(". ")}]`; + } + + return { + content: [{ type: "text", text: resultOutput }], + details: Object.keys(details).length > 0 ? details : undefined, + }; + } + + // Default: use fd + const fdPath = await ensureTool("fd", true); + if (!fdPath) { + throw new Error("fd is not available and could not be downloaded"); + } + // Build fd arguments // When pattern contains path separators (e.g. "reports/**"), use --full-path // so fd matches against the full path, not just the filename. diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 6128bbb20..fbb9c06bf 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -70,7 +70,29 @@ export interface GrepToolDetails { error?: string; } -export function createGrepTool(session: ToolSession): AgentTool { +/** + * Pluggable operations for the grep tool. + * Override these to delegate search to remote systems (e.g., SSH). + */ +export interface GrepOperations { + /** Check if path is a directory. Throws if path doesn't exist. */ + isDirectory: (absolutePath: string) => Promise | boolean; + /** Read file contents for context lines */ + readFile: (absolutePath: string) => Promise | string; +} + +const defaultGrepOperations: GrepOperations = { + isDirectory: async (p) => (await Bun.file(p).stat()).isDirectory(), + readFile: (p) => Bun.file(p).text(), +}; + +export interface GrepToolOptions { + /** Custom operations for grep. Default: local filesystem + ripgrep */ + operations?: GrepOperations; +} + +export function createGrepTool(session: ToolSession, options?: GrepToolOptions): AgentTool { + const ops = options?.operations ?? defaultGrepOperations; return { name: "grep", label: "Grep", @@ -120,14 +142,13 @@ export function createGrepTool(session: ToolSession): AgentTool>; + + let isDirectory: boolean; try { - searchStat = await Bun.file(searchPath).stat(); + isDirectory = await ops.isDirectory(searchPath); } catch { throw new Error(`Path not found: ${searchPath}`); } - - const isDirectory = searchStat.isDirectory(); const contextValue = context && context > 0 ? context : 0; const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); const effectiveOutputMode = outputMode ?? "content"; @@ -150,7 +171,7 @@ export function createGrepTool(session: ToolSession): AgentTool { try { - const content = await Bun.file(filePath).text(); + const content = await ops.readFile(filePath); return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); } catch { return []; diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 9fb8217ee..72b71db3a 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -1,16 +1,16 @@ export { type AskToolDetails, askTool, createAskTool } from "./ask"; -export { type BashToolDetails, createBashTool } from "./bash"; +export { type BashOperations, type BashToolDetails, createBashTool } from "./bash"; export { type CalculatorToolDetails, createCalculatorTool } from "./calculator"; export { createCompleteTool } from "./complete"; -export { createEditTool } from "./edit"; +export { createEditTool, type EditToolDetails } from "./edit"; // Exa MCP tools (22 tools) export { exaTools } from "./exa/index"; export type { ExaRenderDetails, ExaSearchResponse, ExaSearchResult } from "./exa/types"; -export { createFindTool, type FindToolDetails } from "./find"; +export { createFindTool, type FindOperations, type FindToolDetails, type FindToolOptions } from "./find"; export { setPreferredImageProvider } from "./gemini-image"; export { createGitTool, type GitToolDetails, gitTool } from "./git"; -export { createGrepTool, type GrepToolDetails } from "./grep"; -export { createLsTool, type LsToolDetails } from "./ls"; +export { createGrepTool, type GrepOperations, type GrepToolDetails, type GrepToolOptions } from "./grep"; +export { createLsTool, type LsOperations, type LsToolDetails, type LsToolOptions } from "./ls"; export { createLspTool, type FileDiagnosticsResult, @@ -29,7 +29,16 @@ export { reportFindingTool, type SubmitReviewDetails } from "./review"; export { filterRulebookRules, formatRulesForPrompt, type RulebookToolDetails } from "./rulebook"; export { createSshTool, type SSHToolDetails } from "./ssh"; export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index"; -export type { TruncationResult } from "./truncate"; +export { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + type TruncationOptions, + type TruncationResult, + truncateHead, + truncateLine, + truncateTail, +} from "./truncate"; export { createWebFetchTool, type WebFetchToolDetails } from "./web-fetch"; export { companyWebSearchTools, @@ -80,6 +89,19 @@ import { createWriteTool } from "./write"; /** Tool type (AgentTool from pi-ai) */ export type Tool = AgentTool; +/** + * Pluggable file operations for tools. + * Override these to delegate file editing to remote systems (e.g., SSH). + */ +export interface FileOperations { + /** Read file contents as text */ + readFile: (absolutePath: string) => Promise; + /** Write content to a file */ + writeFile: (absolutePath: string, content: string) => Promise; + /** Check if file exists */ + exists: (absolutePath: string) => Promise; +} + /** Session context for tool factories */ export interface ToolSession { /** Current working directory */ @@ -114,6 +136,8 @@ export interface ToolSession { getBashInterceptorSimpleLsEnabled(): boolean; getBashInterceptorRules(): BashInterceptorRule[]; }; + /** Custom file operations (for remote editing, e.g., SSH) */ + fileOperations?: FileOperations; } type ToolFactory = (session: ToolSession) => Tool | null | Promise; @@ -151,7 +175,7 @@ export type ToolName = keyof typeof BUILTIN_TOOLS; */ export async function createTools(session: ToolSession, toolNames?: string[]): Promise { const includeComplete = session.requireCompleteTool === true; - const requestedTools = toolNames && toolNames.length > 0 ? [...new Set(toolNames)] : undefined; + const requestedTools = toolNames ? [...new Set(toolNames)] : undefined; const allTools: Record = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS }; if (includeComplete && requestedTools && !requestedTools.includes("complete")) { requestedTools.push("complete"); diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index 1ef9caa0f..49245df0a 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -28,6 +28,22 @@ const lsSchema = Type.Object({ const DEFAULT_LIMIT = 500; +/** + * Pluggable operations for the ls tool. + * Override these to delegate directory listing to remote systems (e.g., SSH). + */ +export interface LsOperations { + /** Check if path exists and return stats. Returns undefined if not found. */ + stat: (absolutePath: string) => Promise<{ isDirectory: () => boolean; mtimeMs: number } | undefined>; + /** Read directory entries (names only) */ + readdir: (absolutePath: string) => Promise; +} + +export interface LsToolOptions { + /** Custom operations for directory listing. Default: local filesystem via Bun */ + operations?: LsOperations; +} + export interface LsToolDetails { entries?: string[]; dirCount?: number; @@ -37,7 +53,24 @@ export interface LsToolDetails { entryLimitReached?: number; } -export function createLsTool(session: ToolSession): AgentTool { +/** Default operations using Bun APIs */ +const defaultLsOperations: LsOperations = { + async stat(absolutePath: string) { + try { + const s = await Bun.file(absolutePath).stat(); + return { isDirectory: () => s.isDirectory(), mtimeMs: s.mtimeMs }; + } catch { + return undefined; + } + }, + async readdir(absolutePath: string) { + return Array.fromAsync(new Bun.Glob("*").scan({ cwd: absolutePath, dot: true, onlyFiles: false })); + }, +}; + +export function createLsTool(session: ToolSession, options?: LsToolOptions): AgentTool { + const ops = options?.operations ?? defaultLsOperations; + return { name: "ls", label: "Ls", @@ -53,10 +86,8 @@ export function createLsTool(session: ToolSession): AgentTool { const effectiveLimit = limit ?? DEFAULT_LIMIT; // Check if path exists and is a directory - let dirStat: Awaited>; - try { - dirStat = await Bun.file(dirPath).stat(); - } catch { + const dirStat = await ops.stat(dirPath); + if (!dirStat) { throw new Error(`Path not found: ${dirPath}`); } @@ -67,7 +98,7 @@ export function createLsTool(session: ToolSession): AgentTool { // Read directory entries let entries: string[]; try { - entries = await Array.fromAsync(new Bun.Glob("*").scan({ cwd: dirPath, dot: true, onlyFiles: false })); + entries = await ops.readdir(dirPath); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Cannot read directory: ${message}`); @@ -93,22 +124,22 @@ export function createLsTool(session: ToolSession): AgentTool { let suffix = ""; let age = ""; - try { - const entryStat = await Bun.file(fullPath).stat(); - if (entryStat.isDirectory()) { - suffix = "/"; - dirCount += 1; - } else { - fileCount += 1; - } - // Calculate age from mtime - const ageSeconds = Math.floor((Date.now() - entryStat.mtimeMs) / 1000); - age = formatAge(ageSeconds); - } catch { + const entryStat = await ops.stat(fullPath); + if (!entryStat) { // Skip entries we can't stat continue; } + if (entryStat.isDirectory()) { + suffix = "/"; + dirCount += 1; + } else { + fileCount += 1; + } + // Calculate age from mtime + const ageSeconds = Math.floor((Date.now() - entryStat.mtimeMs) / 1000); + age = formatAge(ageSeconds); + // Format: "name/ (2d ago)" or "name (just now)" const line = age ? `${entry}${suffix} (${age})` : entry + suffix; results.push(line); diff --git a/packages/coding-agent/src/core/tools/render-utils.ts b/packages/coding-agent/src/core/tools/render-utils.ts index 357053aa5..69da511f4 100644 --- a/packages/coding-agent/src/core/tools/render-utils.ts +++ b/packages/coding-agent/src/core/tools/render-utils.ts @@ -5,6 +5,7 @@ * tool renderers to ensure a unified TUI experience. */ +import { homedir } from "node:os"; import type { Theme } from "../../modes/interactive/theme/theme"; // ============================================================================= @@ -490,7 +491,7 @@ export function truncateDiffByHunk( // ============================================================================= export function shortenPath(filePath: string, homeDir?: string): string { - const home = homeDir ?? process.env.HOME ?? process.env.USERPROFILE; + const home = homeDir ?? homedir(); if (home && filePath.startsWith(home)) { return `~${filePath.slice(home.length)}`; } diff --git a/packages/coding-agent/src/core/tools/task/worker.ts b/packages/coding-agent/src/core/tools/task/worker.ts index 1d3d80e9f..8eda728aa 100644 --- a/packages/coding-agent/src/core/tools/task/worker.ts +++ b/packages/coding-agent/src/core/tools/task/worker.ts @@ -197,20 +197,43 @@ async function runTask(runState: RunState, payload: SubagentWorkerStartPayload): // Note: Does not support --extension CLI flag or extension CLI flags const extensionRunner = session.extensionRunner; if (extensionRunner) { - extensionRunner.initialize({ - getModel: () => session.model, - sendMessageHandler: (message, options) => { - session.sendCustomMessage(message, options).catch((e) => { - console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`); - }); + extensionRunner.initialize( + // ExtensionActions + { + sendMessage: (message, options) => { + session.sendCustomMessage(message, options).catch((e) => { + console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`); + }); + }, + sendUserMessage: (content, options) => { + session.sendUserMessage(content, options).catch((e) => { + console.error(`Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}`); + }); + }, + appendEntry: (customType, data) => { + session.sessionManager.appendCustomEntry(customType, data); + }, + getActiveTools: () => session.getActiveToolNames(), + getAllTools: () => session.getAllToolNames(), + setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), + setModel: async (model) => { + const key = await session.modelRegistry.getApiKey(model); + if (!key) return false; + await session.setModel(model); + return true; + }, + getThinkingLevel: () => session.thinkingLevel, + setThinkingLevel: (level) => session.setThinkingLevel(level), }, - appendEntryHandler: (customType, data) => { - session.sessionManager.appendCustomEntry(customType, data); + // ExtensionContextActions + { + getModel: () => session.model, + isIdle: () => !session.isStreaming, + abort: () => session.abort(), + hasPendingMessages: () => session.queuedMessageCount > 0, + shutdown: () => {}, }, - getActiveToolsHandler: () => session.getActiveToolNames(), - getAllToolsHandler: () => session.getAllToolNames(), - setActiveToolsHandler: (toolNamesList: string[]) => session.setActiveToolsByName(toolNamesList), - }); + ); extensionRunner.onError((err) => { console.error(`Extension error (${err.extensionPath}): ${err.error}`); }); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 92ae0d6db..e88436379 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -60,11 +60,40 @@ export type { RenderResultOptions, } from "./core/custom-tools/index"; export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index"; -// Extension types -export type { ExtensionAPI, ExtensionContext, ExtensionFactory } from "./core/extensions/types"; -export type * from "./core/hooks/index"; -// Hook system types and type guards +// Extension types and utilities +export type { + AppAction, + Extension, + ExtensionActions, + ExtensionAPI, + ExtensionCommandContext, + ExtensionCommandContextActions, + ExtensionContext, + ExtensionContextActions, + ExtensionError, + ExtensionEvent, + ExtensionFactory, + ExtensionFlag, + ExtensionHandler, + ExtensionRuntime, + ExtensionShortcut, + ExtensionUIContext, + ExtensionUIDialogOptions, + KeybindingsManager, + LoadExtensionsResult, + MessageRenderer, + MessageRenderOptions, + RegisteredCommand, + ToolResultEvent, + TurnEndEvent, + TurnStartEvent, + UserBashEvent, + UserBashEventResult, +} from "./core/extensions/index"; export { + createExtensionRuntime, + discoverAndLoadExtensions, + ExtensionRunner, isBashToolResult, isEditToolResult, isFindToolResult, @@ -72,7 +101,9 @@ export { isLsToolResult, isReadToolResult, isWriteToolResult, -} from "./core/hooks/index"; +} from "./core/extensions/index"; +// Hook system types (legacy re-export) +export type * from "./core/hooks/index"; // Logging export { type Logger, logger } from "./core/logger"; export { convertToLlm } from "./core/messages"; @@ -153,25 +184,49 @@ export { } from "./core/skills"; // Slash commands export { type FileSlashCommand, loadSlashCommands as discoverSlashCommands } from "./core/slash-commands"; -// Tools (detail types only - factories exported from sdk) +// Tools (detail types and utilities) export { + type BashOperations, type BashToolDetails, + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + type FindOperations, type FindToolDetails, + type FindToolOptions, + formatSize, type GitToolDetails, + type GrepOperations, type GrepToolDetails, + type GrepToolOptions, gitTool, + type LsOperations, type LsToolDetails, + type LsToolOptions, type ReadToolDetails, + type TruncationOptions, type TruncationResult, + truncateHead, + truncateLine, + truncateTail, type WriteToolDetails, } from "./core/tools/index"; export type { FileDiagnosticsResult } from "./core/tools/lsp/index"; // Main entry point export { main } from "./main"; +// Run modes for programmatic SDK usage +export { InteractiveMode, type PrintModeOptions, runPrintMode, runRpcMode } from "./modes/index"; // UI components for hooks and custom tools export { BorderedLoader } from "./modes/interactive/components/bordered-loader"; +export { DynamicBorder } from "./modes/interactive/components/dynamic-border"; // Theme utilities for custom tools -export { getMarkdownTheme, getSettingsListTheme, type Theme } from "./modes/interactive/theme/theme"; +export { + getMarkdownTheme, + getSelectListTheme, + getSettingsListTheme, + initTheme, + Theme, + type ThemeColor, +} from "./modes/interactive/theme/theme"; // TypeBox helper for string enums (convenience for custom tools) import { type TSchema, Type } from "@sinclair/typebox"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index b49490f18..83d9057c5 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -145,6 +145,28 @@ async function prepareInitialMessage( }; } +/** + * Resolve a session argument to a file path. + * If it looks like a path, use as-is. Otherwise try to match as session ID prefix. + */ +function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): string { + // If it looks like a file path, use as-is + if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) { + return sessionArg; + } + + // 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)); + + if (matches.length >= 1) { + return matches[0].path; // Already sorted by modified time (most recent first) + } + + // No match - return original (will create new session) + return sessionArg; +} + function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | undefined { if (parsed.continue || parsed.resume) { return undefined; @@ -175,7 +197,8 @@ async function createSessionManager(parsed: Args, cwd: string): Promise 0 && !parsed.continue && !parsed.resume) { + } else if ( + scopedModels.length > 0 && + scopedModels[0].explicitThinkingLevel === true && + !parsed.continue && + !parsed.resume + ) { options.thinkingLevel = scopedModels[0].thinkingLevel; } - // Scoped models for Ctrl+P cycling + // Scoped models for Ctrl+P cycling - fill in default thinking levels when not explicit if (scopedModels.length > 0) { - options.scopedModels = scopedModels; + const defaultThinkingLevel = settingsManager.getDefaultThinkingLevel() ?? "off"; + options.scopedModels = scopedModels.map((scopedModel) => ({ + model: scopedModel.model, + thinkingLevel: scopedModel.explicitThinkingLevel ? scopedModel.thinkingLevel : defaultThinkingLevel, + })); } // API key from CLI - set in authStorage @@ -321,7 +353,9 @@ async function buildSessionOptions( } // Tools - if (parsed.tools) { + if (parsed.noTools) { + options.toolNames = parsed.tools && parsed.tools.length > 0 ? parsed.tools : []; + } else if (parsed.tools) { options.toolNames = parsed.tools; } @@ -344,6 +378,10 @@ async function buildSessionOptions( options.additionalExtensionPaths = cliExtensionPaths; } + if (parsed.noExtensions) { + options.disableExtensionDiscovery = true; + } + return options; } @@ -504,7 +542,7 @@ export async function main(args: string[]) { } time("buildSessionOptions"); - const { session, extensionsResult, modelFallbackMessage, lspServers } = await createAgentSession(sessionOptions); + const { session, setToolUIContext, modelFallbackMessage, lspServers } = await createAgentSession(sessionOptions); time("createAgentSession"); // Re-parse CLI args with extension flags and apply values @@ -550,11 +588,12 @@ export async function main(args: string[]) { const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined); const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager); - if (scopedModels.length > 0) { - const modelList = scopedModels - .map((sm) => { - const thinkingStr = sm.thinkingLevel !== "off" ? `:${sm.thinkingLevel}` : ""; - return `${sm.model.id}${thinkingStr}`; + const scopedModelsForDisplay = sessionOptions.scopedModels ?? scopedModels; + if (scopedModelsForDisplay.length > 0) { + const modelList = scopedModelsForDisplay + .map((scopedModel) => { + const thinkingStr = scopedModel.thinkingLevel !== "off" ? `:${scopedModel.thinkingLevel}` : ""; + return `${scopedModel.model.id}${thinkingStr}`; }) .join(", "); console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`)); @@ -574,14 +613,19 @@ export async function main(args: string[]) { migratedProviders, versionCheckPromise, parsed.messages, - extensionsResult.setUIContext, + setToolUIContext, lspServers, initialMessage, initialImages, fdPath, ); } else { - await runPrintMode(session, mode, parsed.messages, initialMessage, initialImages); + await runPrintMode(session, { + mode, + messages: parsed.messages, + initialMessage, + initialImages, + }); stopThemeWatcher(); if (process.stdout.writableLength > 0) { await new Promise((resolve) => process.stdout.once("drain", resolve)); diff --git a/packages/coding-agent/src/migrations.ts b/packages/coding-agent/src/migrations.ts index 24bebddd4..7f4b2f334 100644 --- a/packages/coding-agent/src/migrations.ts +++ b/packages/coding-agent/src/migrations.ts @@ -2,10 +2,10 @@ * One-time migrations that run on startup. */ -import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import chalk from "chalk"; -import { getAgentDir } from "./config"; +import { getAgentDir, getBinDir } from "./config"; /** * Migrate PI_* environment variables to OMP_* equivalents. @@ -143,6 +143,50 @@ export function migrateSessionsFromAgentRoot(): void { } } +/** + * Move fd/rg binaries from tools/ to bin/ if they exist. + */ +function migrateToolsToBin(): void { + const agentDir = getAgentDir(); + const toolsDir = join(agentDir, "tools"); + const binDir = getBinDir(); + + if (!existsSync(toolsDir)) return; + + const binaries = ["fd", "rg", "fd.exe", "rg.exe"]; + let movedAny = false; + + for (const bin of binaries) { + const oldPath = join(toolsDir, bin); + const newPath = join(binDir, bin); + + if (existsSync(oldPath)) { + if (!existsSync(binDir)) { + mkdirSync(binDir, { recursive: true }); + } + if (!existsSync(newPath)) { + try { + renameSync(oldPath, newPath); + movedAny = true; + } catch { + // Ignore errors + } + } else { + // Target exists, just delete the old one + try { + rmSync(oldPath, { force: true }); + } catch { + // Ignore + } + } + } + } + + if (movedAny) { + console.log(chalk.green(`Migrated managed binaries tools/ → bin/`)); + } +} + /** * Run all migrations. Called once on startup. * @@ -159,6 +203,7 @@ export async function runMigrations(_cwd: string): Promise<{ // Then: run data migrations const migratedAuthProviders = migrateAuthToAuthJson(); migrateSessionsFromAgentRoot(); + migrateToolsToBin(); // Collect deprecation warnings const deprecationWarnings: string[] = []; diff --git a/packages/coding-agent/src/modes/index.ts b/packages/coding-agent/src/modes/index.ts index f6b4977a7..a9dd008d3 100644 --- a/packages/coding-agent/src/modes/index.ts +++ b/packages/coding-agent/src/modes/index.ts @@ -42,7 +42,7 @@ export function installTerminalCrashHandlers(): void { } export { InteractiveMode } from "./interactive/interactive-mode"; -export { runPrintMode } from "./print-mode"; +export { type PrintModeOptions, runPrintMode } from "./print-mode"; export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client"; export { runRpcMode } from "./rpc/rpc-mode"; export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc/rpc-types"; diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts index 08e30857b..61b75214e 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -8,6 +8,7 @@ import { getMarkdownTheme, theme } from "../theme/theme"; export class AssistantMessageComponent extends Container { private contentContainer: Container; private hideThinkingBlock: boolean; + private lastMessage?: AssistantMessage; constructor(message?: AssistantMessage, hideThinkingBlock = false) { super(); @@ -23,20 +24,28 @@ export class AssistantMessageComponent extends Container { } } + override invalidate(): void { + super.invalidate(); + if (this.lastMessage) { + this.updateContent(this.lastMessage); + } + } + setHideThinkingBlock(hide: boolean): void { this.hideThinkingBlock = hide; } updateContent(message: AssistantMessage): void { + this.lastMessage = message; + // Clear content container this.contentContainer.clear(); - if ( - message.content.length > 0 && - message.content.some( - (c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()), - ) - ) { + const hasVisibleContent = message.content.some( + (c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()), + ); + + if (hasVisibleContent) { this.contentContainer.addChild(new Spacer(1)); } @@ -75,7 +84,16 @@ export class AssistantMessageComponent extends Container { const hasToolCalls = message.content.some((c) => c.type === "toolCall"); if (!hasToolCalls) { if (message.stopReason === "aborted") { - this.contentContainer.addChild(new Text(theme.fg("error", "\nAborted"), 1, 0)); + const abortMessage = + message.errorMessage && message.errorMessage !== "Request was aborted" + ? message.errorMessage + : "Operation aborted"; + if (hasVisibleContent) { + this.contentContainer.addChild(new Spacer(1)); + } else { + this.contentContainer.addChild(new Spacer(1)); + } + this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0)); } else if (message.stopReason === "error") { const errorMsg = message.errorMessage || "Unknown error"; this.contentContainer.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts index 77c168354..07691ffef 100644 --- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts @@ -72,6 +72,11 @@ export class BashExecutionComponent extends Container { this.updateDisplay(); } + override invalidate(): void { + super.invalidate(); + this.updateDisplay(); + } + appendOutput(chunk: string): void { // Strip ANSI codes and normalize line endings // Note: binary data is already sanitized in tui-renderer.ts executeBashCommand diff --git a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts index f0d8ee459..50342024c 100644 --- a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts @@ -21,6 +21,11 @@ export class BranchSummaryMessageComponent extends Box { this.updateDisplay(); } + override invalidate(): void { + super.invalidate(); + this.updateDisplay(); + } + private updateDisplay(): void { this.clear(); diff --git a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts index 023655030..a19e69f49 100644 --- a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts @@ -21,6 +21,11 @@ export class CompactionSummaryMessageComponent extends Box { this.updateDisplay(); } + override invalidate(): void { + super.invalidate(); + this.updateDisplay(); + } + private updateDisplay(): void { this.clear(); diff --git a/packages/coding-agent/src/modes/interactive/components/countdown-timer.ts b/packages/coding-agent/src/modes/interactive/components/countdown-timer.ts new file mode 100644 index 000000000..18416814d --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/countdown-timer.ts @@ -0,0 +1,38 @@ +/** + * Reusable countdown timer for dialog components. + */ + +import type { TUI } from "@oh-my-pi/pi-tui"; + +export class CountdownTimer { + private intervalId: ReturnType | undefined; + private remainingSeconds: number; + + constructor( + timeoutMs: number, + private tui: TUI | undefined, + private onTick: (seconds: number) => void, + private onExpire: () => void, + ) { + this.remainingSeconds = Math.ceil(timeoutMs / 1000); + this.onTick(this.remainingSeconds); + + this.intervalId = setInterval(() => { + this.remainingSeconds--; + this.onTick(this.remainingSeconds); + this.tui?.requestRender(); + + if (this.remainingSeconds <= 0) { + this.dispose(); + this.onExpire(); + } + }, 1000); + } + + dispose(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = undefined; + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index d08bf1aea..e3b4d672f 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -38,6 +38,8 @@ export class CustomEditor extends Editor { public onCtrlY?: () => void; /** Called when Ctrl+V is pressed. Returns true if handled (image found), false to fall through to text paste. */ public onCtrlV?: () => Promise; + /** Called when Alt+Up is pressed (dequeue keybinding). */ + public onAltUp?: () => void; /** Custom key handlers from extensions */ private customKeyHandlers = new Map void>(); @@ -157,6 +159,12 @@ export class CustomEditor extends Editor { return; } + // Intercept Alt+Up for dequeue (restore queued message to editor) + if (matchesKey(data, "alt+up") && this.onAltUp) { + this.onAltUp(); + return; + } + // Intercept ? when editor is empty to show hotkeys if (data === "?" && this.getText().length === 0 && this.onQuestionMark) { this.onQuestionMark(); diff --git a/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/packages/coding-agent/src/modes/interactive/components/custom-message.ts index b1bb8c43b..494f23671 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -36,6 +36,11 @@ export class CustomMessageComponent extends Container { } } + override invalidate(): void { + super.invalidate(); + this.rebuild(); + } + private rebuild(): void { // Remove previous content component if (this.customComponent) { diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 95556dd71..9d5f24add 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -1,4 +1,5 @@ import { existsSync, type FSWatcher, readFileSync, watch } from "node:fs"; +import { homedir } from "node:os"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import { dirname, join } from "path"; @@ -200,7 +201,7 @@ export class FooterComponent implements Component { // Replace home directory with ~ let pwd = process.cwd(); - const home = process.env.HOME || process.env.USERPROFILE; + const home = homedir(); if (home && pwd.startsWith(home)) { pwd = `~${pwd.slice(home.length)}`; } diff --git a/packages/coding-agent/src/modes/interactive/components/hook-input.ts b/packages/coding-agent/src/modes/interactive/components/hook-input.ts index 663006a7e..6592dabe5 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-input.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-input.ts @@ -2,63 +2,72 @@ * Simple text input component for hooks. */ -import { Container, Input, isEnter, isEscape, Spacer, Text } from "@oh-my-pi/pi-tui"; +import { Container, Input, isEnter, isEscape, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; import { theme } from "../theme/theme"; +import { CountdownTimer } from "./countdown-timer"; import { DynamicBorder } from "./dynamic-border"; +export interface HookInputOptions { + tui?: TUI; + timeout?: number; +} + export class HookInputComponent extends Container { private input: Input; private onSubmitCallback: (value: string) => void; private onCancelCallback: () => void; + private titleText: Text; + private baseTitle: string; + private countdown: CountdownTimer | undefined; constructor( title: string, _placeholder: string | undefined, onSubmit: (value: string) => void, onCancel: () => void, + opts?: HookInputOptions, ) { super(); this.onSubmitCallback = onSubmit; this.onCancelCallback = onCancel; + this.baseTitle = title; - // Add top border this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); - // Add title - this.addChild(new Text(theme.fg("accent", title), 1, 0)); + this.titleText = new Text(theme.fg("accent", title), 1, 0); + this.addChild(this.titleText); this.addChild(new Spacer(1)); - // Create input + if (opts?.timeout && opts.timeout > 0 && opts.tui) { + this.countdown = new CountdownTimer( + opts.timeout, + opts.tui, + (s) => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)), + () => this.onCancelCallback(), + ); + } + this.input = new Input(); this.addChild(this.input); - this.addChild(new Spacer(1)); - - // Add hint this.addChild(new Text(theme.fg("dim", "enter submit esc cancel"), 1, 0)); - this.addChild(new Spacer(1)); - - // Add bottom border this.addChild(new DynamicBorder()); } handleInput(keyData: string): void { - // Enter if (isEnter(keyData) || keyData === "\n") { this.onSubmitCallback(this.input.getValue()); - return; - } - - // Escape to cancel - if (isEscape(keyData)) { + } else if (isEscape(keyData)) { this.onCancelCallback(); - return; + } else { + this.input.handleInput(keyData); } + } - // Forward to input - this.input.handleInput(keyData); + dispose(): void { + this.countdown?.dispose(); } } diff --git a/packages/coding-agent/src/modes/interactive/components/hook-selector.ts b/packages/coding-agent/src/modes/interactive/components/hook-selector.ts index d7c87793b..fd72cebf8 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-selector.ts @@ -3,89 +3,103 @@ * Displays a list of string options with keyboard navigation. */ -import { Container, isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape, Spacer, Text } from "@oh-my-pi/pi-tui"; +import { + Container, + isArrowDown, + isArrowUp, + isCtrlC, + isEnter, + isEscape, + Spacer, + Text, + type TUI, +} from "@oh-my-pi/pi-tui"; import { theme } from "../theme/theme"; +import { CountdownTimer } from "./countdown-timer"; import { DynamicBorder } from "./dynamic-border"; +export interface HookSelectorOptions { + tui?: TUI; + timeout?: number; +} + export class HookSelectorComponent extends Container { private options: string[]; private selectedIndex = 0; private listContainer: Container; private onSelectCallback: (option: string) => void; private onCancelCallback: () => void; + private titleText: Text; + private baseTitle: string; + private countdown: CountdownTimer | undefined; - constructor(title: string, options: string[], onSelect: (option: string) => void, onCancel: () => void) { + constructor( + title: string, + options: string[], + onSelect: (option: string) => void, + onCancel: () => void, + opts?: HookSelectorOptions, + ) { super(); this.options = options; this.onSelectCallback = onSelect; this.onCancelCallback = onCancel; + this.baseTitle = title; - // Add top border this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); - // Add title - this.addChild(new Text(theme.fg("accent", title), 1, 0)); + this.titleText = new Text(theme.fg("accent", title), 1, 0); + this.addChild(this.titleText); this.addChild(new Spacer(1)); - // Create list container + if (opts?.timeout && opts.timeout > 0 && opts.tui) { + this.countdown = new CountdownTimer( + opts.timeout, + opts.tui, + (s) => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)), + () => this.onCancelCallback(), + ); + } + this.listContainer = new Container(); this.addChild(this.listContainer); - this.addChild(new Spacer(1)); - - // Add hint - this.addChild(new Text(theme.fg("dim", "↑↓ navigate enter select esc cancel"), 1, 0)); - + this.addChild(new Text(theme.fg("dim", "up/down navigate enter select esc cancel"), 1, 0)); this.addChild(new Spacer(1)); - - // Add bottom border this.addChild(new DynamicBorder()); - // Initial render this.updateList(); } private updateList(): void { this.listContainer.clear(); - for (let i = 0; i < this.options.length; i++) { - const option = this.options[i]; const isSelected = i === this.selectedIndex; - - let text = ""; - if (isSelected) { - text = theme.fg("accent", `${theme.nav.cursor} `) + theme.fg("accent", option); - } else { - text = ` ${theme.fg("text", option)}`; - } - + const text = isSelected + ? theme.fg("accent", `${theme.nav.cursor} `) + theme.fg("accent", this.options[i]) + : ` ${theme.fg("text", this.options[i])}`; this.listContainer.addChild(new Text(text, 1, 0)); } } handleInput(keyData: string): void { - // Up arrow or k if (isArrowUp(keyData) || keyData === "k") { this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.updateList(); - } - // Down arrow or j - else if (isArrowDown(keyData) || keyData === "j") { + } else if (isArrowDown(keyData) || keyData === "j") { this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1); this.updateList(); - } - // Enter - else if (isEnter(keyData) || keyData === "\n") { + } else if (isEnter(keyData) || keyData === "\n") { const selected = this.options[this.selectedIndex]; - if (selected) { - this.onSelectCallback(selected); - } - } - // Escape or Ctrl+C - else if (isEscape(keyData) || isCtrlC(keyData)) { + if (selected) this.onSelectCallback(selected); + } else if (isEscape(keyData) || isCtrlC(keyData)) { this.onCancelCallback(); } } + + dispose(): void { + this.countdown?.dispose(); + } } diff --git a/packages/coding-agent/src/modes/interactive/components/index.ts b/packages/coding-agent/src/modes/interactive/components/index.ts new file mode 100644 index 000000000..25fc3c3c0 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/index.ts @@ -0,0 +1,39 @@ +// UI Components barrel export +export { ArminComponent } from "./armin"; +export { AssistantMessageComponent } from "./assistant-message"; +export { BashExecutionComponent } from "./bash-execution"; +export { BorderedLoader } from "./bordered-loader"; +export { BranchSummaryMessageComponent } from "./branch-summary-message"; +export { CompactionSummaryMessageComponent } from "./compaction-summary-message"; +export { CountdownTimer } from "./countdown-timer"; +export { CustomEditor } from "./custom-editor"; +export { CustomMessageComponent } from "./custom-message"; +export { type RenderDiffOptions, renderDiff } from "./diff"; +export { DynamicBorder } from "./dynamic-border"; +export { FooterComponent } from "./footer"; +export { HookEditorComponent } from "./hook-editor"; +export { HookInputComponent, type HookInputOptions } from "./hook-input"; +export { HookMessageComponent } from "./hook-message"; +export { HookSelectorComponent } from "./hook-selector"; +export { LoginDialogComponent } from "./login-dialog"; +export { ModelSelectorComponent } from "./model-selector"; +export { OAuthSelectorComponent } from "./oauth-selector"; +export { QueueModeSelectorComponent } from "./queue-mode-selector"; +export { SessionSelectorComponent } from "./session-selector"; +export { + type SettingChangeHandler, + type SettingsCallbacks, + type SettingsRuntimeContext, + SettingsSelectorComponent, +} from "./settings-selector"; +export { ShowImagesSelectorComponent } from "./show-images-selector"; +export { StatusLineComponent } from "./status-line"; +export { ThemeSelectorComponent } from "./theme-selector"; +export { ThinkingSelectorComponent } from "./thinking-selector"; +export { ToolExecutionComponent, type ToolExecutionOptions } from "./tool-execution"; +export { TreeSelectorComponent } from "./tree-selector"; +export { TtsrNotificationComponent } from "./ttsr-notification"; +export { UserMessageComponent } from "./user-message"; +export { UserMessageSelectorComponent } from "./user-message-selector"; +export { truncateToVisualLines, type VisualTruncateResult } from "./visual-truncate"; +export { type LspServerInfo, type RecentSession, WelcomeComponent } from "./welcome"; diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts new file mode 100644 index 000000000..33ec703e3 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -0,0 +1,160 @@ +import { getOAuthProviders } from "@oh-my-pi/pi-ai"; +import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { theme } from "../theme/theme"; +import { DynamicBorder } from "./dynamic-border"; + +/** + * Login dialog component - replaces editor during OAuth login flow + */ +export class LoginDialogComponent extends Container { + private contentContainer: Container; + private input: Input; + private tui: TUI; + private abortController = new AbortController(); + private inputResolver?: (value: string) => void; + private inputRejecter?: (error: Error) => void; + + constructor( + tui: TUI, + providerId: string, + private onComplete: (success: boolean, message?: string) => void, + ) { + super(); + this.tui = tui; + + const providerInfo = getOAuthProviders().find((p) => p.id === providerId); + const providerName = providerInfo?.name || providerId; + + // Top border + this.addChild(new DynamicBorder()); + + // Title + this.addChild(new Text(theme.fg("warning", `Login to ${providerName}`), 1, 0)); + + // Dynamic content area + this.contentContainer = new Container(); + this.addChild(this.contentContainer); + + // Input (always present, used when needed) + this.input = new Input(); + this.input.onSubmit = () => { + if (this.inputResolver) { + this.inputResolver(this.input.getValue()); + this.inputResolver = undefined; + this.inputRejecter = undefined; + } + }; + this.input.onEscape = () => { + this.cancel(); + }; + + // Bottom border + this.addChild(new DynamicBorder()); + } + + get signal(): AbortSignal { + return this.abortController.signal; + } + + private cancel(): void { + this.abortController.abort(); + if (this.inputRejecter) { + this.inputRejecter(new Error("Login cancelled")); + this.inputResolver = undefined; + this.inputRejecter = undefined; + } + this.onComplete(false, "Login cancelled"); + } + + /** + * Called by onAuth callback - show URL and optional instructions + */ + showAuth(url: string, instructions?: string): void { + this.contentContainer.clear(); + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("accent", url), 1, 0)); + + const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open"; + const hyperlink = `\x1b]8;;${url}\x07${clickHint}\x1b]8;;\x07`; + this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0)); + + if (instructions) { + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("warning", instructions), 1, 0)); + } + + // Try to open browser using Bun.spawn + const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; + Bun.spawn([openCmd, url], { stdout: "ignore", stderr: "ignore" }); + + this.tui.requestRender(); + } + + /** + * Show input for manual code/URL entry (for callback server providers) + */ + showManualInput(prompt: string): Promise { + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("dim", prompt), 1, 0)); + this.contentContainer.addChild(this.input); + this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0)); + this.tui.requestRender(); + + return new Promise((resolve, reject) => { + this.inputResolver = resolve; + this.inputRejecter = reject; + }); + } + + /** + * Called by onPrompt callback - show prompt and wait for input + * Note: Does NOT clear content, appends to existing (preserves URL from showAuth) + */ + showPrompt(message: string, placeholder?: string): Promise { + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("text", message), 1, 0)); + if (placeholder) { + this.contentContainer.addChild(new Text(theme.fg("dim", `e.g., ${placeholder}`), 1, 0)); + } + this.contentContainer.addChild(this.input); + this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel, Enter to submit)"), 1, 0)); + + this.input.setValue(""); + this.tui.requestRender(); + + return new Promise((resolve, reject) => { + this.inputResolver = resolve; + this.inputRejecter = reject; + }); + } + + /** + * Show waiting message (for polling flows like GitHub Copilot) + */ + showWaiting(message: string): void { + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); + this.contentContainer.addChild(new Text(theme.fg("dim", "(Escape to cancel)"), 1, 0)); + this.tui.requestRender(); + } + + /** + * Called by onProgress callback + */ + showProgress(message: string): void { + this.contentContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); + this.tui.requestRender(); + } + + handleInput(data: string): void { + const kb = getEditorKeybindings(); + + if (kb.matches(data, "selectCancel")) { + this.cancel(); + return; + } + + // Pass to input + this.input.handleInput(data); + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index 73d5a66a6..ff80f1db3 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -93,7 +93,7 @@ export class ModelSelectorComponent extends Container { scopedModels: ReadonlyArray, onSelect: (model: Model, role: string) => void, onCancel: () => void, - options?: { temporaryOnly?: boolean }, + options?: { temporaryOnly?: boolean; initialSearchInput?: string }, ) { super(); @@ -105,6 +105,7 @@ export class ModelSelectorComponent extends Container { this.onSelectCallback = onSelect; this.onCancelCallback = onCancel; this.temporaryOnly = options?.temporaryOnly ?? false; + const initialSearchInput = options?.initialSearchInput; // Load current role assignments from settings this._loadRoleModels(); @@ -129,6 +130,9 @@ export class ModelSelectorComponent extends Container { // Create search input this.searchInput = new Input(); + if (initialSearchInput) { + this.searchInput.setValue(initialSearchInput); + } this.searchInput.onSubmit = () => { // Enter on search input opens menu if we have a selection if (this.filteredModels[this.selectedIndex]) { @@ -156,7 +160,11 @@ export class ModelSelectorComponent extends Container { this.loadModels().then(() => { this.buildProviderTabs(); this.updateTabBar(); - this.updateList(); + if (initialSearchInput) { + this.filterModels(initialSearchInput); + } else { + this.updateList(); + } // Request re-render after models are loaded this.tui.requestRender(); }); 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 852ff0a52..63d0390f5 100644 --- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts @@ -47,7 +47,11 @@ class SessionList implements Component { } private filterSessions(query: string): void { - this.filteredSessions = fuzzyFilter(this.allSessions, query, (session) => session.allMessagesText); + this.filteredSessions = fuzzyFilter( + this.allSessions, + query, + (session) => `${session.id} ${session.allMessagesText}`, + ); this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1)); } 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 e79efa809..10897b233 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-defs.ts @@ -135,6 +135,15 @@ export const SETTINGS_DEFS: SettingDef[] = [ get: (sm) => sm.getImageAutoResize(), set: (sm, v) => sm.setImageAutoResize(v), }, + { + id: "blockImages", + tab: "config", + type: "boolean", + label: "Block images", + description: "Prevent images from being sent to LLM providers", + get: (sm) => sm.getBlockImages(), + set: (sm, v) => sm.setBlockImages(v), + }, { id: "steeringMode", tab: "config", diff --git a/packages/coding-agent/src/modes/interactive/components/status-line/segments.ts b/packages/coding-agent/src/modes/interactive/components/status-line/segments.ts index 6aa79d646..880ef0e31 100644 --- a/packages/coding-agent/src/modes/interactive/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/interactive/components/status-line/segments.ts @@ -1,4 +1,4 @@ -import { hostname as osHostname } from "node:os"; +import { homedir, hostname as osHostname } from "node:os"; import { theme } from "../../theme/theme"; import type { RenderedSegment, SegmentContext, StatusLineSegment, StatusLineSegmentId } from "./types"; @@ -76,7 +76,7 @@ const pathSegment: StatusLineSegment = { const opts = ctx.options.path ?? {}; let pwd = process.cwd(); - const home = process.env.HOME || process.env.USERPROFILE; + const home = homedir(); if (opts.abbreviate !== false && home && pwd.startsWith(home)) { pwd = `~${pwd.slice(home.length)}`; 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 84c8db16d..dd61e3202 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -297,6 +297,11 @@ export class ToolExecutionComponent extends Container { this.updateDisplay(); } + override invalidate(): void { + super.invalidate(); + this.updateDisplay(); + } + private updateDisplay(): void { // Set background based on state const bgFn = this.isPartial 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 64db6bba9..b62750586 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -1,3 +1,4 @@ +import { homedir } from "node:os"; import { type Component, Container, @@ -608,7 +609,7 @@ class TreeList implements Component { private formatToolCall(name: string, args: Record): string { const shortenPath = (p: string): string => { - const home = process.env.HOME || process.env.USERPROFILE || ""; + const home = homedir(); if (home && p.startsWith(home)) return `~${p.slice(home.length)}`; return p; }; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 52f00f5d3..3d9771104 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -27,6 +27,7 @@ import { nanoid } from "nanoid"; import { getAuthPath, getDebugLogPath } from "../../config"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session"; import type { ExtensionUIContext } from "../../core/extensions/index"; +import { KeybindingsManager } from "../../core/keybindings"; import { type CustomMessage, createCompactionSummaryMessage } from "../../core/messages"; import { getRecentSessions, type SessionContext, SessionManager } from "../../core/session-manager"; import { loadSlashCommands } from "../../core/slash-commands"; @@ -66,9 +67,11 @@ import { UserMessageSelectorComponent } from "./components/user-message-selector import { WelcomeComponent } from "./components/welcome"; import { getAvailableThemes, + getAvailableThemesWithPaths, getEditorTheme, getMarkdownTheme, getSymbolTheme, + getThemeByName, onThemeChange, setSymbolPreset, setTheme, @@ -85,6 +88,11 @@ function isExpandable(obj: unknown): obj is Expandable { return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function"; } +type CompactionQueuedMessage = { + text: string; + mode: "steer" | "followUp"; +}; + const VOICE_PROGRESS_DELAY_MS = 15000; const VOICE_PROGRESS_MIN_CHARS = 160; const VOICE_PROGRESS_DELTA_CHARS = 120; @@ -145,6 +153,9 @@ export class InteractiveMode { // Track pending images from clipboard paste (attached to next message) private pendingImages: ImageContent[] = []; + // Slash commands loaded from files (for compaction queue handling) + private fileSlashCommands = new Set(); + // Voice mode state private voiceSupervisor: VoiceSupervisor; private voiceAutoModeEnabled = false; @@ -157,6 +168,9 @@ export class InteractiveMode { private autoCompactionLoader: Loader | undefined = undefined; private autoCompactionEscapeHandler?: () => void; + // Messages queued while compaction is running + private compactionQueuedMessages: CompactionQueuedMessage[] = []; + // Auto-retry state private retryLoader: Loader | undefined = undefined; private retryEscapeHandler?: () => void; @@ -250,6 +264,7 @@ export class InteractiveMode { // Load and convert file commands to SlashCommand format const fileCommands = loadSlashCommands({ cwd: process.cwd() }); + this.fileSlashCommands = new Set(fileCommands.map((cmd) => cmd.name)); const fileSlashCommands: SlashCommand[] = fileCommands.map((cmd) => ({ name: cmd.name, description: cmd.description, @@ -383,20 +398,29 @@ export class InteractiveMode { private async initHooksAndCustomTools(): Promise { // Create and set hook & tool UI context const uiContext: ExtensionUIContext = { - select: (title, options) => this.showHookSelector(title, options), - confirm: (title, message) => this.showHookConfirm(title, message), - input: (title, placeholder) => this.showHookInput(title, placeholder), + select: (title, options, _dialogOptions) => this.showHookSelector(title, options), + confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message), + input: (title, placeholder, _dialogOptions) => this.showHookInput(title, placeholder), notify: (message, type) => this.showHookNotify(message, type), setStatus: (key, text) => this.setHookStatus(key, text), setWidget: (key, content) => this.setHookWidget(key, content), setTitle: (title) => setTerminalTitle(title), - custom: (factory) => this.showHookCustom(factory), + custom: (factory, _options) => this.showHookCustom(factory), setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getText(), editor: (title, prefill) => this.showHookEditor(title, prefill), get theme() { return theme; }, + getAllThemes: () => getAvailableThemesWithPaths().map((t) => ({ name: t.name, path: t.path })), + getTheme: (name) => getThemeByName(name), + setTheme: (themeArg) => { + if (typeof themeArg === "string") { + return setTheme(themeArg, true); + } + // Theme object passed directly - not supported in current implementation + return { success: false, error: "Direct theme object not supported" }; + }, }; this.setToolUIContext(uiContext, true); @@ -405,102 +429,130 @@ export class InteractiveMode { return; // No hooks loaded } - extensionRunner.initialize({ - getModel: () => this.session.model, - sendMessageHandler: (message, options) => { - const wasStreaming = this.session.isStreaming; - this.session - .sendCustomMessage(message, options) - .then(() => { - // For non-streaming cases with display=true, update UI - // (streaming cases update via message_end event) - if (!this.isBackgrounded && !wasStreaming && message.display) { - this.rebuildChatFromMessages(); - } - }) - .catch((err) => { - this.showError(`Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`); + extensionRunner.initialize( + // ExtensionActions - for pi.* API + { + sendMessage: (message, options) => { + const wasStreaming = this.session.isStreaming; + this.session + .sendCustomMessage(message, options) + .then(() => { + // For non-streaming cases with display=true, update UI + // (streaming cases update via message_end event) + if (!this.isBackgrounded && !wasStreaming && message.display) { + this.rebuildChatFromMessages(); + } + }) + .catch((err) => { + this.showError( + `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + }, + sendUserMessage: (content, options) => { + this.session.sendUserMessage(content, options).catch((err) => { + this.showError( + `Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`, + ); }); + }, + appendEntry: (customType, data) => { + this.sessionManager.appendCustomEntry(customType, data); + }, + getActiveTools: () => this.session.getActiveToolNames(), + getAllTools: () => this.session.getAllToolNames(), + setActiveTools: (toolNames) => this.session.setActiveToolsByName(toolNames), + setModel: async (model) => { + const key = await this.session.modelRegistry.getApiKey(model); + if (!key) return false; + await this.session.setModel(model); + return true; + }, + getThinkingLevel: () => this.session.thinkingLevel, + setThinkingLevel: (level) => this.session.setThinkingLevel(level), }, - appendEntryHandler: (customType, data) => { - this.sessionManager.appendCustomEntry(customType, data); + // ExtensionContextActions - for ctx.* in event handlers + { + getModel: () => this.session.model, + isIdle: () => !this.session.isStreaming, + abort: () => this.session.abort(), + hasPendingMessages: () => this.session.queuedMessageCount > 0, + shutdown: () => { + // Signal shutdown request (will be handled by main loop) + }, }, - getActiveToolsHandler: () => this.session.getActiveToolNames(), - getAllToolsHandler: () => this.session.getAllToolNames(), - setActiveToolsHandler: (toolNames: string[]) => this.session.setActiveToolsByName(toolNames), - newSessionHandler: async (options) => { - // Stop any loading animation - if (this.loadingAnimation) { - this.loadingAnimation.stop(); - this.loadingAnimation = undefined; - } - this.statusContainer.clear(); + // ExtensionCommandContextActions - for ctx.* in command handlers + { + waitForIdle: () => this.session.agent.waitForIdle(), + newSession: async (options) => { + // Stop any loading animation + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); - // Create new session - const success = await this.session.newSession({ parentSession: options?.parentSession }); - if (!success) { - return { cancelled: true }; - } + // Create new session + const success = await this.session.newSession({ parentSession: options?.parentSession }); + if (!success) { + return { cancelled: true }; + } - // Call setup callback if provided - if (options?.setup) { - await options.setup(this.sessionManager); - } + // Call setup callback if provided + if (options?.setup) { + await options.setup(this.sessionManager); + } - // Clear UI state - this.chatContainer.clear(); - this.pendingMessagesContainer.clear(); - this.streamingComponent = undefined; - this.streamingMessage = undefined; - this.pendingTools.clear(); + // Clear UI state + this.chatContainer.clear(); + this.pendingMessagesContainer.clear(); + this.compactionQueuedMessages = []; + this.streamingComponent = undefined; + this.streamingMessage = undefined; + this.pendingTools.clear(); - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild( - new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), - ); - this.ui.requestRender(); + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild( + new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), + ); + this.ui.requestRender(); - return { cancelled: false }; + return { cancelled: false }; + }, + branch: async (entryId) => { + const result = await this.session.branch(entryId); + if (result.cancelled) { + return { cancelled: true }; + } + + // Update UI + this.chatContainer.clear(); + this.renderInitialMessages(); + this.editor.setText(result.selectedText); + this.showStatus("Branched to new session"); + + return { cancelled: false }; + }, + navigateTree: async (targetId, options) => { + const result = await this.session.navigateTree(targetId, { summarize: options?.summarize }); + if (result.cancelled) { + return { cancelled: true }; + } + + // Update UI + this.chatContainer.clear(); + this.renderInitialMessages(); + if (result.editorText) { + this.editor.setText(result.editorText); + } + this.showStatus("Navigated to selected point"); + + return { cancelled: false }; + }, }, - branchHandler: async (entryId) => { - const result = await this.session.branch(entryId); - if (result.cancelled) { - return { cancelled: true }; - } - - // Update UI - this.chatContainer.clear(); - this.renderInitialMessages(); - this.editor.setText(result.selectedText); - this.showStatus("Branched to new session"); - - return { cancelled: false }; - }, - navigateTreeHandler: async (targetId, options) => { - const result = await this.session.navigateTree(targetId, { summarize: options?.summarize }); - if (result.cancelled) { - return { cancelled: true }; - } - - // Update UI - this.chatContainer.clear(); - this.renderInitialMessages(); - if (result.editorText) { - this.editor.setText(result.editorText); - } - this.showStatus("Navigated to selected point"); - - return { cancelled: false }; - }, - isIdle: () => !this.session.isStreaming, - waitForIdle: () => this.session.agent.waitForIdle(), - abort: () => { - this.session.abort(); - }, - hasPendingMessages: () => this.session.queuedMessageCount > 0, + // ExtensionUIContext uiContext, - hasUI: true, - }); + ); // Subscribe to extension errors extensionRunner.onError((error) => { @@ -521,146 +573,168 @@ export class InteractiveMode { this.ui.requestRender(); } - private initializeHookRunner(uiContext: ExtensionUIContext, hasUI: boolean): void { + private initializeHookRunner(uiContext: ExtensionUIContext, _hasUI: boolean): void { const extensionRunner = this.session.extensionRunner; if (!extensionRunner) { return; } - extensionRunner.initialize({ - getModel: () => this.session.model, - sendMessageHandler: (message, options) => { - const wasStreaming = this.session.isStreaming; - this.session - .sendCustomMessage(message, options) - .then(() => { - // For non-streaming cases with display=true, update UI - // (streaming cases update via message_end event) - if (!this.isBackgrounded && !wasStreaming && message.display) { - this.rebuildChatFromMessages(); - } - }) - .catch((err: Error) => { - const errorText = `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`; - if (this.isBackgrounded) { - console.error(errorText); - return; - } - this.showError(errorText); + extensionRunner.initialize( + // ExtensionActions - for pi.* API + { + sendMessage: (message, options) => { + const wasStreaming = this.session.isStreaming; + this.session + .sendCustomMessage(message, options) + .then(() => { + // For non-streaming cases with display=true, update UI + // (streaming cases update via message_end event) + if (!this.isBackgrounded && !wasStreaming && message.display) { + this.rebuildChatFromMessages(); + } + }) + .catch((err: Error) => { + const errorText = `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`; + if (this.isBackgrounded) { + console.error(errorText); + return; + } + this.showError(errorText); + }); + }, + sendUserMessage: (content, options) => { + this.session.sendUserMessage(content, options).catch((err) => { + this.showError( + `Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`, + ); }); + }, + appendEntry: (customType, data) => { + this.sessionManager.appendCustomEntry(customType, data); + }, + getActiveTools: () => this.session.getActiveToolNames(), + getAllTools: () => this.session.getAllToolNames(), + setActiveTools: (toolNames: string[]) => this.session.setActiveToolsByName(toolNames), + setModel: async (model) => { + const key = await this.session.modelRegistry.getApiKey(model); + if (!key) return false; + await this.session.setModel(model); + return true; + }, + getThinkingLevel: () => this.session.thinkingLevel, + setThinkingLevel: (level) => this.session.setThinkingLevel(level), }, - appendEntryHandler: (customType, data) => { - this.sessionManager.appendCustomEntry(customType, data); + // ExtensionContextActions - for ctx.* in event handlers + { + getModel: () => this.session.model, + isIdle: () => !this.session.isStreaming, + abort: () => this.session.abort(), + hasPendingMessages: () => this.session.queuedMessageCount > 0, + shutdown: () => { + // Signal shutdown request (will be handled by main loop) + }, }, - getActiveToolsHandler: () => this.session.getActiveToolNames(), - getAllToolsHandler: () => this.session.getAllToolNames(), - setActiveToolsHandler: (toolNames) => this.session.setActiveToolsByName(toolNames), - newSessionHandler: async (options) => { - if (this.isBackgrounded) { - return { cancelled: true }; - } - // Stop any loading animation - if (this.loadingAnimation) { - this.loadingAnimation.stop(); - this.loadingAnimation = undefined; - } - this.statusContainer.clear(); + // ExtensionCommandContextActions - for ctx.* in command handlers + { + waitForIdle: () => this.session.agent.waitForIdle(), + newSession: async (options) => { + if (this.isBackgrounded) { + return { cancelled: true }; + } + // Stop any loading animation + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); - // Create new session - const success = await this.session.newSession({ parentSession: options?.parentSession }); - if (!success) { - return { cancelled: true }; - } + // Create new session + const success = await this.session.newSession({ parentSession: options?.parentSession }); + if (!success) { + return { cancelled: true }; + } - // Call setup callback if provided - if (options?.setup) { - await options.setup(this.sessionManager); - } + // Call setup callback if provided + if (options?.setup) { + await options.setup(this.sessionManager); + } - // Clear UI state - this.chatContainer.clear(); - this.pendingMessagesContainer.clear(); - this.streamingComponent = undefined; - this.streamingMessage = undefined; - this.pendingTools.clear(); + // Clear UI state + this.chatContainer.clear(); + this.pendingMessagesContainer.clear(); + this.compactionQueuedMessages = []; + this.streamingComponent = undefined; + this.streamingMessage = undefined; + this.pendingTools.clear(); - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild( - new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), - ); - this.ui.requestRender(); + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild( + new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1), + ); + this.ui.requestRender(); - return { cancelled: false }; + return { cancelled: false }; + }, + branch: async (entryId) => { + if (this.isBackgrounded) { + return { cancelled: true }; + } + const result = await this.session.branch(entryId); + if (result.cancelled) { + return { cancelled: true }; + } + + // Update UI + this.chatContainer.clear(); + this.renderInitialMessages(); + this.editor.setText(result.selectedText); + this.showStatus("Branched to new session"); + + return { cancelled: false }; + }, + navigateTree: async (targetId, options) => { + if (this.isBackgrounded) { + return { cancelled: true }; + } + const result = await this.session.navigateTree(targetId, { summarize: options?.summarize }); + if (result.cancelled) { + return { cancelled: true }; + } + + // Update UI + this.chatContainer.clear(); + this.renderInitialMessages(); + if (result.editorText) { + this.editor.setText(result.editorText); + } + this.showStatus("Navigated to selected point"); + + return { cancelled: false }; + }, }, - branchHandler: async (entryId) => { - if (this.isBackgrounded) { - return { cancelled: true }; - } - const result = await this.session.branch(entryId); - if (result.cancelled) { - return { cancelled: true }; - } - - // Update UI - this.chatContainer.clear(); - this.renderInitialMessages(); - this.editor.setText(result.selectedText); - this.showStatus("Branched to new session"); - - return { cancelled: false }; - }, - navigateTreeHandler: async (targetId, options) => { - if (this.isBackgrounded) { - return { cancelled: true }; - } - const result = await this.session.navigateTree(targetId, { summarize: options?.summarize }); - if (result.cancelled) { - return { cancelled: true }; - } - - // Update UI - this.chatContainer.clear(); - this.renderInitialMessages(); - if (result.editorText) { - this.editor.setText(result.editorText); - } - this.showStatus("Navigated to selected point"); - - return { cancelled: false }; - }, - isIdle: () => !this.session.isStreaming, - waitForIdle: () => this.session.agent.waitForIdle(), - abort: () => { - this.session.abort(); - }, - hasPendingMessages: () => this.session.queuedMessageCount > 0, uiContext, - hasUI, - }); + ); } private createBackgroundUiContext(): ExtensionUIContext { return { - select: async (_title: string, _options: string[]) => undefined, - confirm: async (_title: string, _message: string) => false, - input: async (_title: string, _placeholder?: string) => undefined, + select: async (_title: string, _options: string[], _dialogOptions) => undefined, + confirm: async (_title: string, _message: string, _dialogOptions) => false, + input: async (_title: string, _placeholder?: string, _dialogOptions?: unknown) => undefined, notify: () => {}, setStatus: () => {}, setWidget: () => {}, setTitle: () => {}, - custom: async ( - _factory: ( - tui: TUI, - theme: Theme, - done: (result: T) => void, - ) => (Component & { dispose?(): void }) | Promise, - ) => undefined as T, + custom: async () => undefined as never, setEditorText: () => {}, getEditorText: () => "", editor: async () => undefined, get theme() { return theme; }, + getAllThemes: () => [], + getTheme: () => undefined, + setTheme: () => ({ success: false, error: "Background mode" }), }; } @@ -692,6 +766,9 @@ export class InteractiveMode { abort: () => { this.session.abort(); }, + shutdown: () => { + // Signal shutdown request + }, }); } catch (err) { this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err)); @@ -861,10 +938,12 @@ export class InteractiveMode { factory: ( tui: TUI, theme: Theme, + keybindings: KeybindingsManager, done: (result: T) => void, ) => (Component & { dispose?(): void }) | Promise, ): Promise { const savedText = this.editor.getText(); + const keybindings = KeybindingsManager.inMemory(); return new Promise((resolve) => { let component: Component & { dispose?(): void }; @@ -879,7 +958,7 @@ export class InteractiveMode { resolve(result); }; - Promise.resolve(factory(this.ui, theme, close)).then((c) => { + Promise.resolve(factory(this.ui, theme, keybindings, close)).then((c) => { component = c; this.editorContainer.clear(); this.editorContainer.addChild(component); @@ -955,6 +1034,7 @@ export class InteractiveMode { this.editor.onCtrlG = () => this.openExternalEditor(); this.editor.onQuestionMark = () => this.handleHotkeysCommand(); this.editor.onCtrlV = () => this.handleImagePaste(); + this.editor.onAltUp = () => this.handleDequeue(); // Wire up extension shortcuts this.registerExtensionShortcuts(); @@ -971,6 +1051,12 @@ export class InteractiveMode { text = text.trim(); if (!text) return; + // Queue follow-up messages while compaction is running + if (this.session.isCompacting) { + this.queueCompactionMessage(text, "followUp"); + return; + } + // Alt+Enter queues a follow-up message (waits until agent finishes) // This handles extension commands (execute immediately), prompt template expansion, and queueing if (this.session.isStreaming) { @@ -1078,12 +1164,7 @@ export class InteractiveMode { if (text === "/compact" || text.startsWith("/compact ")) { const customInstructions = text.startsWith("/compact ") ? text.slice(9).trim() : undefined; this.editor.setText(""); - this.editor.disableSubmit = true; - try { - await this.handleCompactCommand(customInstructions); - } finally { - this.editor.disableSubmit = false; - } + await this.handleCompactCommand(customInstructions); return; } if (text === "/background" || text === "/bg") { @@ -1130,8 +1211,13 @@ export class InteractiveMode { } } - // Block input during compaction + // Queue input during compaction if (this.session.isCompacting) { + if (this.pendingImages.length > 0) { + this.showStatus("Compaction in progress. Retry after it completes to send images."); + return; + } + this.queueCompactionMessage(text, "steer"); return; } @@ -1193,6 +1279,16 @@ export class InteractiveMode { switch (event.type) { case "agent_start": + // Restore escape handler if retry UI is still active + if (this.retryEscapeHandler) { + this.editor.onEscape = this.retryEscapeHandler; + this.retryEscapeHandler = undefined; + } + if (this.retryLoader) { + this.retryLoader.stop(); + this.retryLoader = undefined; + this.statusContainer.clear(); + } if (this.loadingAnimation) { this.loadingAnimation.stop(); } @@ -1281,10 +1377,16 @@ export class InteractiveMode { if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") { // Skip error handling for TTSR aborts if (!this.session.isTtsrAbortPending) { - const errorMessage = - this.streamingMessage.stopReason === "aborted" - ? "Operation aborted" - : this.streamingMessage.errorMessage || "Error"; + let errorMessage: string; + if (this.streamingMessage.stopReason === "aborted") { + const retryAttempt = this.session.retryAttempt; + errorMessage = + retryAttempt > 0 + ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}` + : "Operation aborted"; + } else { + errorMessage = this.streamingMessage.errorMessage || "Error"; + } for (const [, component] of this.pendingTools.entries()) { component.updateResult({ content: [{ type: "text", text: errorMessage }], @@ -1374,8 +1476,7 @@ export class InteractiveMode { break; case "auto_compaction_start": { - // Disable submit to preserve editor text during compaction - this.editor.disableSubmit = true; + // Allow input during compaction; submissions are queued // Set up escape to abort auto-compaction this.autoCompactionEscapeHandler = this.editor.onEscape; this.editor.onEscape = () => { @@ -1397,8 +1498,6 @@ export class InteractiveMode { } case "auto_compaction_end": { - // Re-enable submit - this.editor.disableSubmit = false; // Restore escape handler if (this.autoCompactionEscapeHandler) { this.editor.onEscape = this.autoCompactionEscapeHandler; @@ -1427,6 +1526,7 @@ export class InteractiveMode { this.statusLine.invalidate(); this.updateEditorTopBorder(); } + await this.flushCompactionQueue({ willRetry: event.willRetry }); this.ui.requestRender(); break; } @@ -1648,8 +1748,16 @@ export class InteractiveMode { this.chatContainer.addChild(component); if (message.stopReason === "aborted" || message.stopReason === "error") { - const errorMessage = - message.stopReason === "aborted" ? "Operation aborted" : message.errorMessage || "Error"; + let errorMessage: string; + if (message.stopReason === "aborted") { + const retryAttempt = this.session.retryAttempt; + errorMessage = + retryAttempt > 0 + ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}` + : "Operation aborted"; + } else { + errorMessage = message.errorMessage || "Error"; + } component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true }); } else { this.pendingTools.set(content.id, component); @@ -1756,6 +1864,21 @@ export class InteractiveMode { process.kill(0, "SIGTSTP"); } + /** + * Handle Alt+Up: pop the last queued message and restore it to the editor. + */ + private handleDequeue(): void { + const message = this.session.popLastQueuedMessage(); + if (!message) return; + + // Prepend to existing editor text (if any) + const currentText = this.editor.getText(); + const newText = currentText ? `${message}\n\n${currentText}` : message; + this.editor.setText(newText); + this.updatePendingMessagesDisplay(); + this.ui.requestRender(); + } + private handleBackgroundCommand(): void { if (this.isBackgrounded) { this.showStatus("Background mode already enabled"); @@ -2164,8 +2287,18 @@ export class InteractiveMode { private updatePendingMessagesDisplay(): void { this.pendingMessagesContainer.clear(); const queuedMessages = this.session.getQueuedMessages(); - const steeringMessages = queuedMessages.steering.map((message) => ({ message, label: "Steer" })); - const followUpMessages = queuedMessages.followUp.map((message) => ({ message, label: "Follow-up" })); + const steeringMessages = [ + ...queuedMessages.steering.map((message) => ({ message, label: "Steer" })), + ...this.compactionQueuedMessages + .filter((entry) => entry.mode === "steer") + .map((entry) => ({ message: entry.text, label: "Steer" })), + ]; + const followUpMessages = [ + ...queuedMessages.followUp.map((message) => ({ message, label: "Follow-up" })), + ...this.compactionQueuedMessages + .filter((entry) => entry.mode === "followUp") + .map((entry) => ({ message: entry.text, label: "Follow-up" })), + ]; const allMessages = [...steeringMessages, ...followUpMessages]; if (allMessages.length > 0) { this.pendingMessagesContainer.addChild(new Spacer(1)); @@ -2176,6 +2309,102 @@ export class InteractiveMode { } } + private queueCompactionMessage(text: string, mode: "steer" | "followUp"): void { + this.compactionQueuedMessages.push({ text, mode }); + this.editor.addToHistory(text); + this.editor.setText(""); + this.updatePendingMessagesDisplay(); + this.showStatus("Queued message for after compaction"); + } + + private isKnownSlashCommand(text: string): boolean { + if (!text.startsWith("/")) return false; + const spaceIndex = text.indexOf(" "); + const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex); + if (!commandName) return false; + + if (this.session.extensionRunner?.getCommand(commandName)) { + return true; + } + + if (this.session.customCommands.some((cmd) => cmd.command.name === commandName)) { + return true; + } + + return this.fileSlashCommands.has(commandName); + } + + private async flushCompactionQueue(options?: { willRetry?: boolean }): Promise { + if (this.compactionQueuedMessages.length === 0) { + return; + } + + const queuedMessages = [...this.compactionQueuedMessages]; + this.compactionQueuedMessages = []; + this.updatePendingMessagesDisplay(); + + const restoreQueue = (error: unknown) => { + this.session.clearQueue(); + this.compactionQueuedMessages = queuedMessages; + this.updatePendingMessagesDisplay(); + this.showError( + `Failed to send queued message${queuedMessages.length > 1 ? "s" : ""}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }; + + try { + if (options?.willRetry) { + for (const message of queuedMessages) { + if (this.isKnownSlashCommand(message.text)) { + await this.session.prompt(message.text); + } else if (message.mode === "followUp") { + await this.session.followUp(message.text); + } else { + await this.session.steer(message.text); + } + } + this.updatePendingMessagesDisplay(); + return; + } + + const firstPromptIndex = queuedMessages.findIndex((message) => !this.isKnownSlashCommand(message.text)); + if (firstPromptIndex === -1) { + for (const message of queuedMessages) { + await this.session.prompt(message.text); + } + return; + } + + const preCommands = queuedMessages.slice(0, firstPromptIndex); + const firstPrompt = queuedMessages[firstPromptIndex]; + const rest = queuedMessages.slice(firstPromptIndex + 1); + + for (const message of preCommands) { + await this.session.prompt(message.text); + } + + const promptPromise = this.session.prompt(firstPrompt.text).catch((error) => { + restoreQueue(error); + }); + + for (const message of rest) { + if (this.isKnownSlashCommand(message.text)) { + await this.session.prompt(message.text); + } else if (message.mode === "followUp") { + await this.session.followUp(message.text); + } else { + await this.session.steer(message.text); + } + } + this.updatePendingMessagesDisplay(); + void promptPromise; + } catch (error) { + restoreQueue(error); + } + } + /** Move pending bash components from pending area to chat */ private flushPendingBashComponents(): void { for (const component of this.pendingBashComponents) { @@ -2597,6 +2826,7 @@ export class InteractiveMode { // Clear UI state this.pendingMessagesContainer.clear(); + this.compactionQueuedMessages = []; this.streamingComponent = undefined; this.streamingMessage = undefined; this.pendingTools.clear(); @@ -3018,6 +3248,7 @@ export class InteractiveMode { | \`Ctrl+G\` | Edit message in external editor | | \`/\` | Slash commands | | \`!\` | Run bash command | +| \`!!\` | Run bash command (excluded from context) | `; this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new DynamicBorder()); @@ -3046,6 +3277,7 @@ export class InteractiveMode { // Clear UI state this.chatContainer.clear(); this.pendingMessagesContainer.clear(); + this.compactionQueuedMessages = []; this.streamingComponent = undefined; this.streamingMessage = undefined; this.pendingTools.clear(); @@ -3207,6 +3439,7 @@ export class InteractiveMode { this.statusContainer.clear(); this.editor.onEscape = originalOnEscape; } + await this.flushCompactionQueue({ willRetry: false }); } stop(): void { diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index be91bd8d1..5fec918c4 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -1012,10 +1012,12 @@ function detectColorMode(): ColorMode { return "truecolor"; } const term = process.env.TERM || ""; - if (term.includes("256color")) { + // Only fall back to 256color for truly limited terminals + if (term === "dumb" || term === "" || term === "linux") { return "256color"; } - return "256color"; + // Assume truecolor for everything else - virtually all modern terminals support it + return "truecolor"; } function hexToRgb(hex: string): { r: number; g: number; b: number } { @@ -1599,6 +1601,35 @@ export function getAvailableThemes(): string[] { return Array.from(themes).sort(); } +export interface ThemeInfo { + name: string; + path: string | undefined; +} + +export function getAvailableThemesWithPaths(): ThemeInfo[] { + const result: ThemeInfo[] = []; + + // Built-in themes (embedded, no file path) + for (const name of Object.keys(getBuiltinThemes())) { + result.push({ name, path: undefined }); + } + + // Custom themes + const customThemesDir = getCustomThemesDir(); + if (fs.existsSync(customThemesDir)) { + for (const file of fs.readdirSync(customThemesDir)) { + if (file.endsWith(".json")) { + const name = file.slice(0, -5); + if (!result.some((themeInfo) => themeInfo.name === name)) { + result.push({ name, path: path.join(customThemesDir, file) }); + } + } + } + } + + return result.sort((a, b) => a.name.localeCompare(b.name)); +} + function loadThemeJson(name: string): ThemeJson { const builtinThemes = getBuiltinThemes(); if (name in builtinThemes) { @@ -1679,6 +1710,14 @@ function loadTheme(name: string, mode?: ColorMode, symbolPresetOverride?: Symbol return createTheme(themeJson, mode, symbolPresetOverride); } +export function getThemeByName(name: string): Theme | undefined { + try { + return loadTheme(name); + } catch { + return undefined; + } +} + function detectTerminalBackground(): "dark" | "light" { const colorfgbg = process.env.COLORFGBG || ""; if (colorfgbg) { @@ -1748,6 +1787,15 @@ export function setTheme(name: string, enableWatcher: boolean = false): { succes } } +export function setThemeInstance(themeInstance: Theme): void { + theme = themeInstance; + currentThemeName = ""; + stopThemeWatcher(); + if (onThemeChangeCallback) { + onThemeChangeCallback(); + } +} + /** * Set the symbol preset override, recreating the theme with the new preset. */ diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index 680e2b778..394832cec 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -9,43 +9,90 @@ import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai"; import type { AgentSession } from "../core/agent-session"; +/** + * Options for print mode. + */ +export interface PrintModeOptions { + /** Output mode: "text" for final response only, "json" for all events */ + mode: "text" | "json"; + /** Array of additional prompts to send after initialMessage */ + messages?: string[]; + /** First message to send (may contain @file content) */ + initialMessage?: string; + /** Images to attach to the initial message */ + initialImages?: ImageContent[]; +} + /** * Run in print (single-shot) mode. * Sends prompts to the agent and outputs the result. - * - * @param session The agent session - * @param mode Output mode: "text" for final response only, "json" for all events - * @param messages Array of prompts to send - * @param initialMessage Optional first message (may contain @file content) - * @param initialImages Optional images for the initial message */ -export async function runPrintMode( - session: AgentSession, - mode: "text" | "json", - messages: string[], - initialMessage?: string, - initialImages?: ImageContent[], -): Promise { - // Extension runner already has no-op UI context by default (set in loader) - // Set up extensions for print mode (no UI) +export async function runPrintMode(session: AgentSession, options: PrintModeOptions): Promise { + const { mode, messages = [], initialMessage, initialImages } = options; + // Set up extensions for print mode (no UI, no command context) const extensionRunner = session.extensionRunner; if (extensionRunner) { - extensionRunner.initialize({ - getModel: () => session.model, - sendMessageHandler: (message, options) => { - session.sendCustomMessage(message, options).catch((e) => { - console.error(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}`); - }); + extensionRunner.initialize( + // ExtensionActions + { + sendMessage: (message, options) => { + session.sendCustomMessage(message, options).catch((e) => { + process.stderr.write(`Extension sendMessage failed: ${e instanceof Error ? e.message : String(e)}\n`); + }); + }, + sendUserMessage: (content, options) => { + session.sendUserMessage(content, options).catch((e) => { + process.stderr.write( + `Extension sendUserMessage failed: ${e instanceof Error ? e.message : String(e)}\n`, + ); + }); + }, + appendEntry: (customType, data) => { + session.sessionManager.appendCustomEntry(customType, data); + }, + getActiveTools: () => session.getActiveToolNames(), + getAllTools: () => session.getAllToolNames(), + setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), + setModel: async (model) => { + const key = await session.modelRegistry.getApiKey(model); + if (!key) return false; + await session.setModel(model); + return true; + }, + getThinkingLevel: () => session.thinkingLevel, + setThinkingLevel: (level) => session.setThinkingLevel(level), }, - appendEntryHandler: (customType, data) => { - session.sessionManager.appendCustomEntry(customType, data); + // ExtensionContextActions + { + getModel: () => session.model, + isIdle: () => !session.isStreaming, + abort: () => session.abort(), + hasPendingMessages: () => session.queuedMessageCount > 0, + shutdown: () => {}, }, - getActiveToolsHandler: () => session.getActiveToolNames(), - getAllToolsHandler: () => session.getAllToolNames(), - setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames), - }); + // ExtensionCommandContextActions - commands invokable via prompt("/command") + { + waitForIdle: () => session.agent.waitForIdle(), + newSession: async (options) => { + const success = await session.newSession({ parentSession: options?.parentSession }); + if (success && options?.setup) { + await options.setup(session.sessionManager); + } + return { cancelled: !success }; + }, + branch: async (entryId) => { + const result = await session.branch(entryId); + return { cancelled: result.cancelled }; + }, + navigateTree: async (targetId, options) => { + const result = await session.navigateTree(targetId, { summarize: options?.summarize }); + return { cancelled: result.cancelled }; + }, + }, + // No UI context + ); extensionRunner.onError((err) => { - console.error(`Extension error (${err.extensionPath}): ${err.error}`); + process.stderr.write(`Extension error (${err.extensionPath}): ${err.error}\n`); }); // Emit session_start event await extensionRunner.emit({ @@ -57,7 +104,7 @@ export async function runPrintMode( session.subscribe((event) => { // In JSON mode, output all events if (mode === "json") { - console.log(JSON.stringify(event)); + process.stdout.write(`${JSON.stringify(event)}\n`); } }); @@ -81,14 +128,14 @@ export async function runPrintMode( // Check for error/aborted if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") { - console.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`); + process.stderr.write(`${assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`}\n`); process.exit(1); } // Output text content for (const content of assistantMsg.content) { if (content.type === "text") { - console.log(content.text); + process.stdout.write(`${content.text}\n`); } } } diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index b4652e8d9..a143705f6 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -13,8 +13,8 @@ import { nanoid } from "nanoid"; import type { AgentSession } from "../../core/agent-session"; -import type { ExtensionUIContext } from "../../core/extensions/index"; -import { theme } from "../interactive/theme/theme"; +import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../core/extensions/index"; +import { type Theme, theme } from "../interactive/theme/theme"; import type { RpcCommand, RpcExtensionUIRequest, @@ -38,7 +38,7 @@ export type { */ export async function runRpcMode(session: AgentSession): Promise { const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => { - console.log(JSON.stringify(obj)); + process.stdout.write(`${JSON.stringify(obj)}\n`); }; const success = ( @@ -57,71 +57,101 @@ export async function runRpcMode(session: AgentSession): Promise { }; // Pending extension UI requests waiting for response - const pendingExtensionRequests = new Map< - string, - { resolve: (value: any) => void; reject: (error: Error) => void } - >(); + type PendingExtensionRequest = { + resolve: (response: RpcExtensionUIResponse) => void; + reject: (error: Error) => void; + }; + + const pendingExtensionRequests = new Map(); + + // Shutdown request flag (wrapped in object to allow mutation with const) + const shutdownState = { requested: false }; + + /** Helper for dialog methods with signal/timeout support */ + function createDialogPromise( + opts: ExtensionUIDialogOptions | undefined, + defaultValue: T, + request: Record, + parseResponse: (response: RpcExtensionUIResponse) => T, + ): Promise { + if (opts?.signal?.aborted) return Promise.resolve(defaultValue); + + const id = nanoid(); + return new Promise((resolve, reject) => { + let timeoutId: ReturnType | undefined; + + const cleanup = () => { + if (timeoutId) clearTimeout(timeoutId); + opts?.signal?.removeEventListener("abort", onAbort); + pendingExtensionRequests.delete(id); + }; + + const onAbort = () => { + cleanup(); + resolve(defaultValue); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + + if (opts?.timeout !== undefined) { + timeoutId = setTimeout(() => { + cleanup(); + resolve(defaultValue); + }, opts.timeout); + } + + pendingExtensionRequests.set(id, { + resolve: (response: RpcExtensionUIResponse) => { + cleanup(); + resolve(parseResponse(response)); + }, + reject, + }); + output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest); + }); + } /** * Create an extension UI context that uses the RPC protocol. */ const createExtensionUIContext = (): ExtensionUIContext => ({ - async select(title: string, options: string[]): Promise { - const id = nanoid(); - return new Promise((resolve, reject) => { - pendingExtensionRequests.set(id, { - resolve: (response: RpcExtensionUIResponse) => { - if ("cancelled" in response && response.cancelled) { - resolve(undefined); - } else if ("value" in response) { - resolve(response.value); - } else { - resolve(undefined); - } - }, - reject, - }); - output({ type: "extension_ui_request", id, method: "select", title, options } as RpcExtensionUIRequest); - }); - }, + select: (title, options, dialogOptions) => + createDialogPromise( + dialogOptions, + undefined, + { method: "select", title, options, timeout: dialogOptions?.timeout }, + (response) => + "cancelled" in response && response.cancelled + ? undefined + : "value" in response + ? response.value + : undefined, + ), - async confirm(title: string, message: string): Promise { - const id = nanoid(); - return new Promise((resolve, reject) => { - pendingExtensionRequests.set(id, { - resolve: (response: RpcExtensionUIResponse) => { - if ("cancelled" in response && response.cancelled) { - resolve(false); - } else if ("confirmed" in response) { - resolve(response.confirmed); - } else { - resolve(false); - } - }, - reject, - }); - output({ type: "extension_ui_request", id, method: "confirm", title, message } as RpcExtensionUIRequest); - }); - }, + confirm: (title, message, dialogOptions) => + createDialogPromise( + dialogOptions, + false, + { method: "confirm", title, message, timeout: dialogOptions?.timeout }, + (response) => + "cancelled" in response && response.cancelled + ? false + : "confirmed" in response + ? response.confirmed + : false, + ), - async input(title: string, placeholder?: string): Promise { - const id = nanoid(); - return new Promise((resolve, reject) => { - pendingExtensionRequests.set(id, { - resolve: (response: RpcExtensionUIResponse) => { - if ("cancelled" in response && response.cancelled) { - resolve(undefined); - } else if ("value" in response) { - resolve(response.value); - } else { - resolve(undefined); - } - }, - reject, - }); - output({ type: "extension_ui_request", id, method: "input", title, placeholder } as RpcExtensionUIRequest); - }); - }, + input: (title, placeholder, dialogOptions) => + createDialogPromise( + dialogOptions, + undefined, + { method: "input", title, placeholder, timeout: dialogOptions?.timeout }, + (response) => + "cancelled" in response && response.cancelled + ? undefined + : "value" in response + ? response.value + : undefined, + ), notify(message: string, type?: "info" | "warning" | "error"): void { // Fire and forget - no response needed @@ -195,6 +225,7 @@ export async function runRpcMode(session: AgentSession): Promise { return new Promise((resolve, reject) => { pendingExtensionRequests.set(id, { resolve: (response: RpcExtensionUIResponse) => { + pendingExtensionRequests.delete(id); if ("cancelled" in response && response.cancelled) { resolve(undefined); } else if ("value" in response) { @@ -212,27 +243,84 @@ export async function runRpcMode(session: AgentSession): Promise { get theme() { return theme; }, + + getAllThemes() { + return []; + }, + + getTheme(_name: string) { + return undefined; + }, + + setTheme(_theme: string | Theme) { + // Theme switching not supported in RPC mode + return { success: false, error: "Theme switching not supported in RPC mode" }; + }, }); // Set up extensions with RPC-based UI context const extensionRunner = session.extensionRunner; if (extensionRunner) { - extensionRunner.initialize({ - getModel: () => session.agent.state.model, - sendMessageHandler: (message, options) => { - session.sendCustomMessage(message, options).catch((e) => { - output(error(undefined, "extension_send", e.message)); - }); + extensionRunner.initialize( + // ExtensionActions + { + sendMessage: (message, options) => { + session.sendCustomMessage(message, options).catch((e) => { + output(error(undefined, "extension_send", e.message)); + }); + }, + sendUserMessage: (content, options) => { + session.sendUserMessage(content, options).catch((e) => { + output(error(undefined, "extension_send_user", e.message)); + }); + }, + appendEntry: (customType, data) => { + session.sessionManager.appendCustomEntry(customType, data); + }, + getActiveTools: () => session.getActiveToolNames(), + getAllTools: () => session.getAllToolNames(), + setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames), + setModel: async (model) => { + const key = await session.modelRegistry.getApiKey(model); + if (!key) return false; + await session.setModel(model); + return true; + }, + getThinkingLevel: () => session.thinkingLevel, + setThinkingLevel: (level) => session.setThinkingLevel(level), }, - appendEntryHandler: (customType, data) => { - session.sessionManager.appendCustomEntry(customType, data); + // ExtensionContextActions + { + getModel: () => session.agent.state.model, + isIdle: () => !session.isStreaming, + abort: () => session.abort(), + hasPendingMessages: () => session.queuedMessageCount > 0, + shutdown: () => { + shutdownState.requested = true; + }, }, - getActiveToolsHandler: () => session.getActiveToolNames(), - getAllToolsHandler: () => session.getAllToolNames(), - setActiveToolsHandler: (toolNames: string[]) => session.setActiveToolsByName(toolNames), - uiContext: createExtensionUIContext(), - hasUI: false, - }); + // ExtensionCommandContextActions - commands invokable via prompt("/command") + { + waitForIdle: () => session.agent.waitForIdle(), + newSession: async (options) => { + const success = await session.newSession({ parentSession: options?.parentSession }); + // Note: setup callback runs but no UI feedback in RPC mode + if (success && options?.setup) { + await options.setup(session.sessionManager); + } + return { cancelled: !success }; + }, + branch: async (entryId) => { + const result = await session.branch(entryId); + return { cancelled: result.cancelled }; + }, + navigateTree: async (targetId, options) => { + const result = await session.navigateTree(targetId, { summarize: options?.summarize }); + return { cancelled: result.cancelled }; + }, + }, + createExtensionUIContext(), + ); extensionRunner.onError((err) => { output({ type: "extension_error", extensionPath: err.extensionPath, event: err.event, error: err.error }); }); @@ -466,6 +554,20 @@ export async function runRpcMode(session: AgentSession): Promise { } }; + /** + * Check if shutdown was requested and perform shutdown if so. + * Called after handling each command when waiting for the next command. + */ + async function checkShutdownRequested(): Promise { + if (!shutdownState.requested) return; + + if (extensionRunner?.hasHandlers("session_shutdown")) { + await extensionRunner.emit({ type: "session_shutdown" }); + } + + process.exit(0); + } + // Listen for JSON input using Bun's stdin const decoder = new TextDecoder(); let buffer = ""; @@ -486,7 +588,6 @@ export async function runRpcMode(session: AgentSession): Promise { const response = parsed as RpcExtensionUIResponse; const pending = pendingExtensionRequests.get(response.id); if (pending) { - pendingExtensionRequests.delete(response.id); pending.resolve(response); } continue; @@ -496,6 +597,9 @@ export async function runRpcMode(session: AgentSession): Promise { const command = parsed as RpcCommand; const response = await handleCommand(command); output(response); + + // Check for deferred shutdown request (idle between commands) + await checkShutdownRequested(); } catch (e: any) { output(error(undefined, "parse", `Failed to parse command: ${e.message}`)); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 40f869995..962386b96 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -180,9 +180,16 @@ export type RpcResponse = /** Emitted when an extension needs user input */ export type RpcExtensionUIRequest = - | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[] } - | { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string } - | { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string } + | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number } + | { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number } + | { + type: "extension_ui_request"; + id: string; + method: "input"; + title: string; + placeholder?: string; + timeout?: number; + } | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string } | { type: "extension_ui_request"; diff --git a/packages/coding-agent/src/prompts/system-prompt.md b/packages/coding-agent/src/prompts/system-prompt.md index 6fc877f93..cf9e2078e 100644 --- a/packages/coding-agent/src/prompts/system-prompt.md +++ b/packages/coding-agent/src/prompts/system-prompt.md @@ -1,35 +1,43 @@ -You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files. +You are a senior software engineer with deep expertise in debugging, refactoring, and system design. You read files, execute commands, edit code, and write new files to complete coding tasks. -Available tools: -{{toolsList}} -{{antiBashSection}}Guidelines: -{{guidelines}} - -Core behavior: -- Keep going until the task is fully resolved; do not stop early. -- Verify with tools; ask for clarification when required. -- Before tool calls, send a brief preamble describing the next action. -- Provide short progress updates for long tasks; give a brief heads-up before writing large changes. -- Follow AGENTS.md instructions by scope: nearest file applies, deeper files override higher-level ones. -- If update_plan is available, use it for non-trivial multi-step work and keep it updated; skip planning for simple tasks. -- If a command fails due to sandboxing or needs elevated access, request approval and rerun. -- Follow project validation/testing guidance; if checks are not run, suggest them in next steps. -- Resolve blockers before yielding; do not guess. -- Use tools to ground answers when external or deterministic info is needed; avoid speculation when a tool can verify. -- Ask for missing or ambiguous tool parameters instead of guessing; confirm before actions. -- Minimize tool calls and context usage by narrowing queries and summarizing only what is needed. -- After each tool result, check relevance; iterate or clarify if results conflict or are insufficient. -- Use concise, scannable responses; include file paths in backticks; use short bullets for multi-item lists; avoid dumping large files. + +Keep working until the user's task is fully resolved. Use tools to verify—never guess. + {{environmentInfo}} -Documentation: -- Main documentation: {{readmePath}} -- Additional docs: {{docsPath}} -- Examples: {{examplesPath}} (hooks, custom tools, SDK) -- When asked to create: custom models/providers (README.md), hooks (docs/hooks.md, examples/hooks/), custom tools (docs/custom-tools.md, docs/tui.md, examples/custom-tools/), themes (docs/theme.md), skills (docs/skills.md) -- Always read the doc, examples, AND follow .md cross-references before implementing + +{{toolsList}} + +{{antiBashSection}} + +{{guidelines}} + -Final reminder: Complete the full user request before ending your turn. + +## Execution +- Before each tool call, state the action in one sentence. +- After each result, verify relevance; iterate if results conflict or are insufficient. +- Plan multi-step work with update_plan when available; skip for simple tasks. +- On sandbox/permission failures, request approval and retry. + +## Verification +- Ground answers with tools when deterministic info is needed. +- Ask for missing parameters instead of assuming. +- Follow project testing guidance; suggest validation if not run. + +## Communication +- Concise, scannable responses; file paths in backticks. +- Brief progress updates on long tasks; heads-up before large changes. +- Short bullets for lists; avoid dumping large files. + +## Project Integration +- Follow AGENTS.md by scope: nearest file applies, deeper overrides higher. +- Resolve blockers before yielding. + + + +Complete the full user request before ending your turn. This matters. + diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 056e721e5..25628b6ad 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -1,6 +1,35 @@ +import { unlink } from "node:fs/promises"; import { platform } from "node:os"; import { nanoid } from "nanoid"; +const PREFERRED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const; + +function isWaylandSession(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland"; +} + +function baseMimeType(mimeType: string): string { + const base = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase(); + return base === "image/jpg" ? "image/jpeg" : base; +} + +function selectPreferredImageMimeType(mimeTypes: string[]): string | null { + const normalized = mimeTypes + .map((t) => t.trim()) + .filter(Boolean) + .map((t) => ({ raw: t, base: baseMimeType(t) })); + + for (const preferred of PREFERRED_IMAGE_MIME_TYPES) { + const match = normalized.find((t) => t.base === preferred); + if (match) { + return match.raw; + } + } + + const anyImage = normalized.find((t) => t.base.startsWith("image/")); + return anyImage?.raw ?? null; +} + async function spawnWithTimeout(cmd: string[], input: string, timeoutMs: number): Promise { const proc = Bun.spawn(cmd, { stdin: "pipe" }); @@ -22,15 +51,18 @@ async function spawnWithTimeout(cmd: string[], input: string, timeoutMs: number) } async function spawnAndRead(cmd: string[], timeoutMs: number): Promise { - const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Clipboard operation timed out")), timeoutMs); - }); + let proc: ReturnType | null = null; try { + proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Clipboard operation timed out")), timeoutMs); + }); + + const stdoutStream = proc.stdout as ReadableStream; const [exitCode, stdout] = await Promise.race([ - Promise.all([proc.exited, new Response(proc.stdout).arrayBuffer()]), + Promise.all([proc.exited, new Response(stdoutStream).arrayBuffer()]), timeoutPromise, ]); @@ -42,7 +74,7 @@ async function spawnAndRead(cmd: string[], timeoutMs: number): Promise { } else if (p === "win32") { await spawnWithTimeout(["clip"], text, timeout); } else { + const wayland = isWaylandSession(); + if (wayland) { + const wlCopyPath = Bun.which("wl-copy"); + if (wlCopyPath) { + try { + await spawnWithTimeout([wlCopyPath], text, timeout); + return; + } catch { + // Fall back to xclip/xsel (works on XWayland) + } + } + } + // Linux - try xclip first, fall back to xsel try { await spawnWithTimeout(["xclip", "-selection", "clipboard"], text, timeout); @@ -66,7 +111,8 @@ export async function copyToClipboard(text: string): Promise { } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (p === "linux") { - throw new Error(`Failed to copy to clipboard. Install xclip or xsel: ${msg}`); + const tools = isWaylandSession() ? "wl-copy, xclip, or xsel" : "xclip or xsel"; + throw new Error(`Failed to copy to clipboard. Install ${tools}: ${msg}`); } throw new Error(`Failed to copy to clipboard: ${msg}`); } @@ -82,7 +128,7 @@ export interface ClipboardImage { * Returns null if no image is in clipboard or clipboard access fails. * * Supported platforms: - * - Linux: requires xclip + * - Linux: requires wl-paste (Wayland) or xclip (X11) * - macOS: uses osascript + pbpaste * - Windows: uses PowerShell */ @@ -106,64 +152,59 @@ export async function readImageFromClipboard(): Promise { } async function readImageLinux(timeout: number): Promise { - // Try Wayland first (wl-paste), then X11 (xclip) - const wayland = await readImageWayland(timeout); - if (wayland) return wayland; + const wayland = isWaylandSession(); + if (wayland) { + const image = await readImageWayland(timeout); + if (image) return image; + } return await readImageX11(timeout); } async function readImageWayland(timeout: number): Promise { - // wl-paste --list-types shows available MIME types const types = await spawnAndRead(["wl-paste", "--list-types"], timeout); if (!types) return null; - const typeList = types.toString("utf-8"); + const typeList = types + .toString("utf-8") + .split(/\r?\n/) + .map((t) => t.trim()) + .filter(Boolean); - // Try PNG first, then JPEG - const imageTypes = [ - { type: "image/png", mimeType: "image/png" }, - { type: "image/jpeg", mimeType: "image/jpeg" }, - ]; + const selectedType = selectPreferredImageMimeType(typeList); + if (!selectedType) return null; - for (const { type, mimeType } of imageTypes) { - if (typeList.includes(type)) { - const imageData = await spawnAndRead(["wl-paste", "--type", type], timeout); - if (imageData && imageData.length > 0) { - return { - data: imageData.toString("base64"), - mimeType, - }; - } - } - } + const imageData = await spawnAndRead(["wl-paste", "--type", selectedType, "--no-newline"], timeout); + if (!imageData || imageData.length === 0) return null; - return null; + return { + data: imageData.toString("base64"), + mimeType: baseMimeType(selectedType), + }; } async function readImageX11(timeout: number): Promise { - // Check available targets in clipboard const targets = await spawnAndRead(["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"], timeout); - if (!targets) return null; - const targetList = targets.toString("utf-8"); + let candidateTypes: string[] = []; + if (targets) { + candidateTypes = targets + .toString("utf-8") + .split(/\r?\n/) + .map((t) => t.trim()) + .filter(Boolean); + } - // Try PNG first (preferred), then JPEG - const imageTypes = [ - { target: "image/png", mimeType: "image/png" }, - { target: "image/jpeg", mimeType: "image/jpeg" }, - { target: "image/jpg", mimeType: "image/jpeg" }, - ]; + const preferred = candidateTypes.length > 0 ? selectPreferredImageMimeType(candidateTypes) : null; + const tryTypes = preferred ? [preferred, ...PREFERRED_IMAGE_MIME_TYPES] : [...PREFERRED_IMAGE_MIME_TYPES]; - for (const { target, mimeType } of imageTypes) { - if (targetList.includes(target)) { - const imageData = await spawnAndRead(["xclip", "-selection", "clipboard", "-t", target, "-o"], timeout); - if (imageData && imageData.length > 0) { - return { - data: imageData.toString("base64"), - mimeType, - }; - } + for (const mimeType of tryTypes) { + const imageData = await spawnAndRead(["xclip", "-selection", "clipboard", "-t", mimeType, "-o"], timeout); + if (imageData && imageData.length > 0) { + return { + data: imageData.toString("base64"), + mimeType: baseMimeType(mimeType), + }; } } @@ -222,7 +263,6 @@ async function readImageMacOS(timeout: number): Promise { if (await file.exists()) { const buffer = await file.arrayBuffer(); await Bun.write(tempFile, ""); // Clear file - const { unlink } = await import("fs/promises"); await unlink(tempFile).catch(() => {}); if (buffer.byteLength > 0) { diff --git a/packages/coding-agent/src/utils/image-convert.ts b/packages/coding-agent/src/utils/image-convert.ts index e26b450a2..b773e89ef 100644 --- a/packages/coding-agent/src/utils/image-convert.ts +++ b/packages/coding-agent/src/utils/image-convert.ts @@ -1,4 +1,4 @@ -import { convertToPngWithImageMagick } from "./image-magick.js"; +import { convertToPngWithImageMagick } from "./image-magick"; /** * Convert image to PNG format for terminal display. diff --git a/packages/coding-agent/src/utils/image-resize.ts b/packages/coding-agent/src/utils/image-resize.ts index 8260e17d5..c727e208e 100644 --- a/packages/coding-agent/src/utils/image-resize.ts +++ b/packages/coding-agent/src/utils/image-resize.ts @@ -1,5 +1,5 @@ import type { ImageContent } from "@oh-my-pi/pi-ai"; -import { getImageDimensionsWithImageMagick, resizeWithImageMagick } from "./image-magick.js"; +import { getImageDimensionsWithImageMagick, resizeWithImageMagick } from "./image-magick"; export interface ImageResizeOptions { maxWidth?: number; // Default: 2000 diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index 533365be3..37d965f73 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -2,9 +2,9 @@ import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, rmSync import { arch, platform } from "node:os"; import { join } from "node:path"; import chalk from "chalk"; -import { APP_NAME, getToolsDir } from "../config"; +import { APP_NAME, getBinDir } from "../config"; -const TOOLS_DIR = getToolsDir(); +const TOOLS_DIR = getBinDir(); interface ToolConfig { name: string; diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 62e437771..7287e5066 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -145,6 +145,39 @@ describe("parseArgs", () => { }); }); + describe("--no-extensions flag", () => { + test("parses --no-extensions flag", () => { + const result = parseArgs(["--no-extensions"]); + expect(result.noExtensions).toBe(true); + }); + + test("parses --no-extensions with explicit -e flags", () => { + const result = parseArgs(["--no-extensions", "-e", "foo.ts", "-e", "bar.ts"]); + expect(result.noExtensions).toBe(true); + expect(result.extensions).toEqual(["foo.ts", "bar.ts"]); + }); + }); + + describe("--no-skills flag", () => { + test("parses --no-skills flag", () => { + const result = parseArgs(["--no-skills"]); + expect(result.noSkills).toBe(true); + }); + }); + + describe("--no-tools flag", () => { + test("parses --no-tools flag", () => { + const result = parseArgs(["--no-tools"]); + expect(result.noTools).toBe(true); + }); + + test("parses --no-tools with explicit --tools flags", () => { + const result = parseArgs(["--no-tools", "--tools", "read,bash"]); + expect(result.noTools).toBe(true); + expect(result.tools).toEqual(["read", "bash"]); + }); + }); + describe("messages and file args", () => { test("parses plain text messages", () => { const result = parseArgs(["hello", "world"]); diff --git a/packages/coding-agent/test/block-images.test.ts b/packages/coding-agent/test/block-images.test.ts new file mode 100644 index 000000000..a1ddeb282 --- /dev/null +++ b/packages/coding-agent/test/block-images.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { processFileArguments } from "../src/cli/file-processor"; +import { SettingsManager } from "../src/core/settings-manager"; +import type { ToolSession } from "../src/core/tools/index"; +import { createReadTool } from "../src/core/tools/read"; + +// 1x1 red PNG image as base64 (smallest valid PNG) +const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + +function createTestToolSession(cwd: string): ToolSession { + return { + cwd, + hasUI: false, + rulebookRules: [], + getSessionFile: () => null, + getSessionSpawns: () => "*", + }; +} + +describe("blockImages setting", () => { + describe("SettingsManager", () => { + it("should default blockImages to false", () => { + const manager = SettingsManager.inMemory({}); + expect(manager.getBlockImages()).toBe(false); + }); + + it("should return true when blockImages is set to true", () => { + const manager = SettingsManager.inMemory({ images: { blockImages: true } }); + expect(manager.getBlockImages()).toBe(true); + }); + + it("should persist blockImages setting via setBlockImages", () => { + const manager = SettingsManager.inMemory({}); + expect(manager.getBlockImages()).toBe(false); + + manager.setBlockImages(true); + expect(manager.getBlockImages()).toBe(true); + + manager.setBlockImages(false); + expect(manager.getBlockImages()).toBe(false); + }); + + it("should handle blockImages alongside autoResize", () => { + const manager = SettingsManager.inMemory({ + images: { autoResize: true, blockImages: true }, + }); + expect(manager.getImageAutoResize()).toBe(true); + expect(manager.getBlockImages()).toBe(true); + }); + }); + + describe("Read tool", () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `block-images-test-${Date.now()}-${Math.random()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("should always read images (filtering happens at convertToLlm layer)", async () => { + // Create test image + const imagePath = join(testDir, "test.png"); + writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); + + const tool = createReadTool(createTestToolSession(testDir)); + const result = await tool.execute("test-1", { path: imagePath }); + + // Should have text note + image content + expect(result.content.length).toBeGreaterThanOrEqual(1); + const hasImage = result.content.some((c) => c.type === "image"); + expect(hasImage).toBe(true); + }); + + it("should read text files normally", async () => { + // Create test text file + const textPath = join(testDir, "test.txt"); + writeFileSync(textPath, "Hello, world!"); + + const tool = createReadTool(createTestToolSession(testDir)); + const result = await tool.execute("test-2", { path: textPath }); + + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + const textContent = result.content[0] as { type: "text"; text: string }; + expect(textContent.text).toContain("Hello, world!"); + }); + }); + + describe("processFileArguments", () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `block-images-process-test-${Date.now()}-${Math.random()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("should always process images (filtering happens at convertToLlm layer)", async () => { + // Create test image + const imagePath = join(testDir, "test.png"); + writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); + + const result = await processFileArguments([imagePath]); + + expect(result.images).toHaveLength(1); + expect(result.images[0].type).toBe("image"); + }); + + it("should process text files normally", async () => { + // Create test text file + const textPath = join(testDir, "test.txt"); + writeFileSync(textPath, "Hello, world!"); + + const result = await processFileArguments([textPath]); + + expect(result.images).toHaveLength(0); + expect(result.text).toContain("Hello, world!"); + }); + }); +}); diff --git a/packages/coding-agent/test/extensions-discovery.test.ts b/packages/coding-agent/test/extensions-discovery.test.ts index 66773fc7b..5d646cc0e 100644 --- a/packages/coding-agent/test/extensions-discovery.test.ts +++ b/packages/coding-agent/test/extensions-discovery.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import { tmpdir } from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import { discoverAndLoadExtensions } from "../src/core/extensions/loader"; +import { discoverAndLoadExtensions, loadExtensions } from "../src/core/extensions/loader"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -366,7 +366,7 @@ describe("extensions discovery", () => { const result = await discoverAndLoadExtensions([], tempDir); expect(result.errors).toHaveLength(1); - expect(result.errors[0].error).toContain("must export a default function"); + expect(result.errors[0].error).toContain("does not export a valid factory function"); expect(result.extensions).toHaveLength(0); }); @@ -443,4 +443,32 @@ describe("extensions discovery", () => { expect(result.extensions).toHaveLength(1); expect(result.extensions[0].flags.has("--my-flag")).toBe(true); }); + + it("loadExtensions only loads explicit paths without discovery", async () => { + // Create discoverable extensions (would be found by discoverAndLoadExtensions) + fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCodeWithTool("discovered")); + + // Create explicit extension outside discovery path + const explicitPath = path.join(tempDir, "explicit.ts"); + fs.writeFileSync(explicitPath, extensionCodeWithTool("explicit")); + + // Use loadExtensions directly to skip discovery + const result = await loadExtensions([explicitPath], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(1); + expect(result.extensions[0].tools.has("explicit")).toBe(true); + expect(result.extensions[0].tools.has("discovered")).toBe(false); + }); + + it("loadExtensions with no paths loads nothing", async () => { + // Create discoverable extensions (would be found by discoverAndLoadExtensions) + fs.writeFileSync(path.join(extensionsDir, "discovered.ts"), extensionCode); + + // Use loadExtensions directly with empty paths + const result = await loadExtensions([], tempDir); + + expect(result.errors).toHaveLength(0); + expect(result.extensions).toHaveLength(0); + }); }); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 58bfa3ea4..1e92c9a07 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -9,6 +9,7 @@ import { join } from "node:path"; import { AuthStorage } from "../src/core/auth-storage"; import { discoverAndLoadExtensions } from "../src/core/extensions/loader"; import { ExtensionRunner } from "../src/core/extensions/runner"; +import { logger } from "../src/core/logger"; import { ModelRegistry } from "../src/core/model-registry"; import { SessionManager } from "../src/core/session-manager"; @@ -43,13 +44,13 @@ describe("ExtensionRunner", () => { `; writeFileSync(join(extensionsDir, "conflict.ts"), extCode); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const shortcuts = runner.getShortcuts(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in")); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("conflicts with built-in"), expect.any(Object)); expect(shortcuts.has("ctrl+c")).toBe(false); warnSpy.mockRestore(); @@ -76,13 +77,13 @@ describe("ExtensionRunner", () => { writeFileSync(join(extensionsDir, "ext1.ts"), extCode1); writeFileSync(join(extensionsDir, "ext2.ts"), extCode2); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const shortcuts = runner.getShortcuts(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("shortcut conflict")); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("shortcut conflict"), expect.any(Object)); // Last one wins expect(shortcuts.has("ctrl+shift+x")).toBe(true); @@ -108,7 +109,7 @@ describe("ExtensionRunner", () => { writeFileSync(join(extensionsDir, "tool-b.ts"), toolCode("tool_b")); const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const tools = runner.getAllRegisteredTools(); expect(tools.length).toBe(2); @@ -130,7 +131,7 @@ describe("ExtensionRunner", () => { writeFileSync(join(extensionsDir, "cmd-b.ts"), cmdCode("cmd-b")); const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const commands = runner.getRegisteredCommands(); expect(commands.length).toBe(2); @@ -149,7 +150,7 @@ describe("ExtensionRunner", () => { writeFileSync(join(extensionsDir, "cmd.ts"), cmdCode); const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const cmd = runner.getCommand("my-cmd"); expect(cmd).toBeDefined(); @@ -173,7 +174,7 @@ describe("ExtensionRunner", () => { writeFileSync(join(extensionsDir, "throws.ts"), extCode); const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const errors: Array<{ extensionPath: string; event: string; error: string }> = []; runner.onError((err) => { @@ -199,7 +200,7 @@ describe("ExtensionRunner", () => { writeFileSync(join(extensionsDir, "renderer.ts"), extCode); const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const renderer = runner.getMessageRenderer("my-type"); expect(renderer).toBeDefined(); @@ -222,7 +223,7 @@ describe("ExtensionRunner", () => { writeFileSync(join(extensionsDir, "with-flag.ts"), extCode); const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); const flags = runner.getFlags(); expect(flags.has("--my-flag")).toBe(true); @@ -240,14 +241,13 @@ describe("ExtensionRunner", () => { writeFileSync(join(extensionsDir, "flag.ts"), extCode); const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); // Setting a flag value should not throw runner.setFlagValue("--test-flag", true); - // The flag values are stored in the extension's flagValues map - const ext = result.extensions[0]; - expect(ext.flagValues.get("--test-flag")).toBe(true); + // The flag values are stored in the shared runtime + expect(result.runtime.flagValues.get("--test-flag")).toBe(true); }); }); @@ -261,7 +261,7 @@ describe("ExtensionRunner", () => { writeFileSync(join(extensionsDir, "handler.ts"), extCode); const result = await discoverAndLoadExtensions([], tempDir); - const runner = new ExtensionRunner(result.extensions, tempDir, sessionManager, modelRegistry); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); expect(runner.hasHandlers("tool_call")).toBe(true); expect(runner.hasHandlers("agent_end")).toBe(false); diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 33bcc8e81..ce3ff1956 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -62,24 +62,27 @@ const allModels = [...mockModels, ...mockOpenRouterModels]; describe("parseModelPattern", () => { describe("simple patterns without colons", () => { - test("exact match returns model with off thinking level", () => { + test("exact match returns model with off thinking level and explicitThinkingLevel false", () => { const result = parseModelPattern("claude-sonnet-4-5", allModels); expect(result.model?.id).toBe("claude-sonnet-4-5"); expect(result.thinkingLevel).toBe("off"); + expect(result.explicitThinkingLevel).toBe(false); expect(result.warning).toBeUndefined(); }); - test("partial match returns best model", () => { + test("partial match returns best model with off thinking level and explicitThinkingLevel false", () => { const result = parseModelPattern("sonnet", allModels); expect(result.model?.id).toBe("claude-sonnet-4-5"); expect(result.thinkingLevel).toBe("off"); + expect(result.explicitThinkingLevel).toBe(false); expect(result.warning).toBeUndefined(); }); - test("no match returns null model", () => { + test("no match returns undefined model with off thinking level", () => { const result = parseModelPattern("nonexistent", allModels); expect(result.model).toBeUndefined(); expect(result.thinkingLevel).toBe("off"); + expect(result.explicitThinkingLevel).toBe(false); expect(result.warning).toBeUndefined(); }); }); @@ -111,27 +114,30 @@ describe("parseModelPattern", () => { }); describe("patterns with invalid thinking levels", () => { - test("sonnet:random returns sonnet with off and warning", () => { + test("sonnet:random returns sonnet with off thinking level and warning", () => { const result = parseModelPattern("sonnet:random", allModels); expect(result.model?.id).toBe("claude-sonnet-4-5"); expect(result.thinkingLevel).toBe("off"); + expect(result.explicitThinkingLevel).toBe(false); expect(result.warning).toContain("Invalid thinking level"); expect(result.warning).toContain("random"); }); - test("gpt-4o:invalid returns gpt-4o with off and warning", () => { + test("gpt-4o:invalid returns gpt-4o with off thinking level and warning", () => { const result = parseModelPattern("gpt-4o:invalid", allModels); expect(result.model?.id).toBe("gpt-4o"); expect(result.thinkingLevel).toBe("off"); + expect(result.explicitThinkingLevel).toBe(false); expect(result.warning).toContain("Invalid thinking level"); }); }); describe("OpenRouter models with colons in IDs", () => { - test("qwen3-coder:exacto matches the model with off", () => { + test("qwen3-coder:exacto matches the model with off thinking level", () => { const result = parseModelPattern("qwen/qwen3-coder:exacto", allModels); expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); expect(result.thinkingLevel).toBe("off"); + expect(result.explicitThinkingLevel).toBe(false); expect(result.warning).toBeUndefined(); }); @@ -140,6 +146,7 @@ describe("parseModelPattern", () => { expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); expect(result.model?.provider).toBe("openrouter"); expect(result.thinkingLevel).toBe("off"); + expect(result.explicitThinkingLevel).toBe(false); expect(result.warning).toBeUndefined(); }); @@ -147,6 +154,7 @@ describe("parseModelPattern", () => { const result = parseModelPattern("qwen/qwen3-coder:exacto:high", allModels); expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); expect(result.thinkingLevel).toBe("high"); + expect(result.explicitThinkingLevel).toBe(true); expect(result.warning).toBeUndefined(); }); @@ -155,30 +163,34 @@ describe("parseModelPattern", () => { expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); expect(result.model?.provider).toBe("openrouter"); expect(result.thinkingLevel).toBe("high"); + expect(result.explicitThinkingLevel).toBe(true); expect(result.warning).toBeUndefined(); }); - test("gpt-4o:extended matches the extended model", () => { + test("gpt-4o:extended matches the extended model with off thinking level", () => { const result = parseModelPattern("openai/gpt-4o:extended", allModels); expect(result.model?.id).toBe("openai/gpt-4o:extended"); expect(result.thinkingLevel).toBe("off"); + expect(result.explicitThinkingLevel).toBe(false); expect(result.warning).toBeUndefined(); }); }); describe("invalid thinking levels with OpenRouter models", () => { - test("qwen3-coder:exacto:random returns model with off and warning", () => { + test("qwen3-coder:exacto:random returns model with off thinking level and warning", () => { const result = parseModelPattern("qwen/qwen3-coder:exacto:random", allModels); expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); expect(result.thinkingLevel).toBe("off"); + expect(result.explicitThinkingLevel).toBe(false); expect(result.warning).toContain("Invalid thinking level"); expect(result.warning).toContain("random"); }); - test("qwen3-coder:exacto:high:random returns model with off and warning", () => { + test("qwen3-coder:exacto:high:random returns model with off thinking level and warning", () => { const result = parseModelPattern("qwen/qwen3-coder:exacto:high:random", allModels); expect(result.model?.id).toBe("qwen/qwen3-coder:exacto"); expect(result.thinkingLevel).toBe("off"); + expect(result.explicitThinkingLevel).toBe(false); expect(result.warning).toContain("Invalid thinking level"); expect(result.warning).toContain("random"); }); @@ -190,6 +202,7 @@ describe("parseModelPattern", () => { const result = parseModelPattern("", allModels); expect(result.model).not.toBeNull(); expect(result.thinkingLevel).toBe("off"); + expect(result.explicitThinkingLevel).toBe(false); }); test("pattern ending with colon treats empty suffix as invalid", () => { diff --git a/packages/coding-agent/test/sdk-skills.test.ts b/packages/coding-agent/test/sdk-skills.test.ts new file mode 100644 index 000000000..4983de5f9 --- /dev/null +++ b/packages/coding-agent/test/sdk-skills.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Skill } from "../src/core/sdk"; +import { createAgentSession } from "../src/core/sdk"; +import { SessionManager } from "../src/core/session-manager"; + +describe("createAgentSession skills option", () => { + let tempDir: string; + let skillsDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-sdk-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + // Create skill in .omp/skills/ for native project-level discovery + skillsDir = join(tempDir, ".omp", "skills", "test-skill"); + mkdirSync(skillsDir, { recursive: true }); + + // Create a test skill in the pi skills directory + writeFileSync( + join(skillsDir, "SKILL.md"), + `--- +name: test-skill +description: A test skill for SDK tests. +--- + +# Test Skill + +This is a test skill. +`, + ); + }); + + afterEach(() => { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("should discover skills by default and expose them on session.skills", async () => { + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir: tempDir, + sessionManager: SessionManager.inMemory(), + }); + + // Skills should be discovered and exposed on the session + expect(session.skills.length).toBeGreaterThan(0); + expect(session.skills.some((s: Skill) => s.name === "test-skill")).toBe(true); + }); + + it("should have empty skills when options.skills is empty array (--no-skills)", async () => { + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir: tempDir, + sessionManager: SessionManager.inMemory(), + skills: [], // Explicitly empty - like --no-skills + }); + + // session.skills should be empty + expect(session.skills).toEqual([]); + // No warnings since we didn't discover + expect(session.skillWarnings).toEqual([]); + }); + + it("should use provided skills when options.skills is explicitly set", async () => { + const customSkill: Skill = { + name: "custom-skill", + description: "A custom skill", + filePath: "/fake/path/SKILL.md", + baseDir: "/fake/path", + source: "custom" as const, + }; + + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir: tempDir, + sessionManager: SessionManager.inMemory(), + skills: [customSkill], + }); + + // session.skills should contain only the provided skill + expect(session.skills).toEqual([customSkill]); + // No warnings since we didn't discover + expect(session.skillWarnings).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts new file mode 100644 index 000000000..779483c18 --- /dev/null +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { SettingsManager } from "../src/core/settings-manager"; + +describe("SettingsManager", () => { + const testDir = join(process.cwd(), "test-settings-tmp"); + const agentDir = join(testDir, "agent"); + const projectDir = join(testDir, "project"); + + beforeEach(() => { + // Clean up and create fresh directories + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true }); + } + mkdirSync(agentDir, { recursive: true }); + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true }); + } + }); + + describe("preserves externally added settings", () => { + it("should preserve enabledModels when changing thinking level", () => { + // Create initial settings file + const settingsPath = join(agentDir, "settings.json"); + writeFileSync( + settingsPath, + JSON.stringify({ + theme: "dark", + defaultModel: "claude-sonnet", + }), + ); + + // Create SettingsManager (simulates pi starting up) + const manager = SettingsManager.create(projectDir, agentDir); + + // Simulate user editing settings.json externally to add enabledModels + const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + currentSettings.enabledModels = ["claude-opus-4-5", "gpt-5.2-codex"]; + writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2)); + + // User changes thinking level via Shift+Tab + manager.setDefaultThinkingLevel("high"); + + // Verify enabledModels is preserved + const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(savedSettings.enabledModels).toEqual(["claude-opus-4-5", "gpt-5.2-codex"]); + expect(savedSettings.defaultThinkingLevel).toBe("high"); + expect(savedSettings.theme).toBe("dark"); + expect(savedSettings.defaultModel).toBe("claude-sonnet"); + }); + + it("should preserve custom settings when changing theme", () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync( + settingsPath, + JSON.stringify({ + defaultModel: "claude-sonnet", + }), + ); + + const manager = SettingsManager.create(projectDir, agentDir); + + // User adds custom settings externally + const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + currentSettings.shellPath = "/bin/zsh"; + currentSettings.extensions = ["/path/to/extension.ts"]; + writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2)); + + // User changes theme + manager.setTheme("light"); + + // Verify all settings preserved + const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(savedSettings.shellPath).toBe("/bin/zsh"); + expect(savedSettings.extensions).toEqual(["/path/to/extension.ts"]); + expect(savedSettings.theme).toBe("light"); + }); + + it("should let in-memory changes override file changes for same key", () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync( + settingsPath, + JSON.stringify({ + theme: "dark", + }), + ); + + const manager = SettingsManager.create(projectDir, agentDir); + + // User externally sets thinking level to "low" + const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + currentSettings.defaultThinkingLevel = "low"; + writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2)); + + // But then changes it via UI to "high" + manager.setDefaultThinkingLevel("high"); + + // In-memory change should win + const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(savedSettings.defaultThinkingLevel).toBe("high"); + }); + }); +}); diff --git a/packages/coding-agent/test/system-prompt.test.ts b/packages/coding-agent/test/system-prompt.test.ts new file mode 100644 index 000000000..2556bcabb --- /dev/null +++ b/packages/coding-agent/test/system-prompt.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { buildSystemPrompt } from "../src/core/system-prompt"; + +describe("buildSystemPrompt", () => { + describe("empty tools", () => { + test("shows (none) for empty tools list", () => { + const prompt = buildSystemPrompt({ + toolNames: [], + contextFiles: [], + skills: [], + }); + + // System prompt uses XML tag format + expect(prompt).toContain("\n(none)\n"); + }); + + test("shows file paths guideline even with no tools", () => { + const prompt = buildSystemPrompt({ + toolNames: [], + contextFiles: [], + skills: [], + }); + + expect(prompt).toContain("Show file paths clearly"); + }); + }); + + describe("default tools", () => { + test("includes all default tools", () => { + const prompt = buildSystemPrompt({ + contextFiles: [], + skills: [], + }); + + expect(prompt).toContain("- read:"); + expect(prompt).toContain("- bash:"); + expect(prompt).toContain("- edit:"); + expect(prompt).toContain("- write:"); + }); + }); +}); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index cfd7c1b1d..4b22d61f2 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -11,6 +11,7 @@ import type { ToolSession } from "../src/core/tools/index"; import { createLsTool } from "../src/core/tools/ls"; import { createReadTool } from "../src/core/tools/read"; import { createWriteTool } from "../src/core/tools/write"; +import * as shellModule from "../src/utils/shell"; // Helper to extract text from content blocks function getTextOutput(result: any): string { @@ -398,6 +399,31 @@ function b() { /timed out/i, ); }); + + it("should throw error when cwd does not exist", async () => { + const nonexistentCwd = "/this/directory/definitely/does/not/exist/12345"; + + const bashToolWithBadCwd = createBashTool(createTestToolSession(nonexistentCwd)); + + await expect(bashToolWithBadCwd.execute("test-call-11", { command: "echo test" })).rejects.toThrow( + /Working directory does not exist/, + ); + }); + + it("should handle process spawn errors", async () => { + const getShellConfigSpy = vi.spyOn(shellModule, "getShellConfig").mockReturnValueOnce({ + shell: "/nonexistent-shell-path-xyz123", + args: ["-c"], + env: {}, + prefix: undefined, + }); + + const bashWithBadShell = createBashTool(createTestToolSession(testDir)); + + await expect(bashWithBadShell.execute("test-call-12", { command: "echo test" })).rejects.toThrow(/ENOENT/); + + getShellConfigSpy.mockRestore(); + }); }); describe("grep tool", () => { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d54cfaecf..ac46f2985 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,28 @@ # Changelog ## [Unreleased] +### Added + +- `EditorComponent` interface for custom editor implementations +- `StdinBuffer` class to split batched stdin into individual sequences +- Overlay compositing via `TUI.showOverlay()` and `TUI.hideOverlay()` for `ctx.ui.custom()` with `{ overlay: true }` +- Kitty keyboard protocol flag 2 support for key release events (`isKeyRelease()`, `isKeyRepeat()`, `KeyEventType`) +- `setKittyProtocolActive()`, `isKittyProtocolActive()` for Kitty protocol state management +- `kittyProtocolActive` property on Terminal interface to query Kitty protocol state +- `Component.wantsKeyRelease` property to opt-in to key release events (default false) +- Input component `onEscape` callback for handling escape key presses + +### Changed + +- Terminal startup now queries Kitty protocol support before enabling event reporting +- Default editor `newLine` binding now uses `shift+enter` only + +### Fixed + +- Key presses no longer dropped when batched with other events over SSH +- TUI now filters out key release events by default, preventing double-processing of keys +- `matchesKey()` now correctly matches Kitty protocol sequences for unmodified letter keys +- Crash when pasting text with trailing whitespace exceeding terminal width through Markdown rendering ## [3.37.1] - 2026-01-10 diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 800ed16b5..879ef9bd7 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,3 +1,4 @@ +import { getEditorKeybindings } from "../keybindings"; import { isAltBackspace, isAltLeft, @@ -13,7 +14,6 @@ import { isCtrlU, isCtrlW, isDelete, - isEnter, } from "../keys"; import type { Component } from "../tui"; import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils"; @@ -27,6 +27,7 @@ export class Input implements Component { private value: string = ""; private cursor: number = 0; // Cursor position in the value public onSubmit?: (value: string) => void; + public onEscape?: () => void; // Bracketed paste mode buffering private pasteBuffer: string = ""; @@ -78,8 +79,14 @@ export class Input implements Component { } return; } + const kb = getEditorKeybindings(); + if (kb.matches(data, "selectCancel")) { + this.onEscape?.(); + return; + } + // Handle special keys - if (isEnter(data) || data === "\n") { + if (kb.matches(data, "submit") || data === "\n") { // Enter - submit if (this.onSubmit) { this.onSubmit(this.value); diff --git a/packages/tui/src/editor-component.ts b/packages/tui/src/editor-component.ts new file mode 100644 index 000000000..b59e681d5 --- /dev/null +++ b/packages/tui/src/editor-component.ts @@ -0,0 +1,65 @@ +import type { AutocompleteProvider } from "./autocomplete"; +import type { Component } from "./tui"; + +/** + * Interface for custom editor components. + * + * This allows extensions to provide their own editor implementation + * (e.g., vim mode, emacs mode, custom keybindings) while maintaining + * compatibility with the core application. + */ +export interface EditorComponent extends Component { + // ========================================================================= + // Core text access (required) + // ========================================================================= + + /** Get the current text content */ + getText(): string; + + /** Set the text content */ + setText(text: string): void; + + // ========================================================================= + // Callbacks (required) + // ========================================================================= + + /** Called when user submits (e.g., Enter key) */ + onSubmit?: (text: string) => void; + + /** Called when text changes */ + onChange?: (text: string) => void; + + // ========================================================================= + // History support (optional) + // ========================================================================= + + /** Add text to history for up/down navigation */ + addToHistory?(text: string): void; + + // ========================================================================= + // Advanced text manipulation (optional) + // ========================================================================= + + /** Insert text at current cursor position */ + insertTextAtCursor?(text: string): void; + + /** + * Get text with any markers expanded (e.g., paste markers). + * Falls back to getText() if not implemented. + */ + getExpandedText?(): string; + + // ========================================================================= + // Autocomplete support (optional) + // ========================================================================= + + /** Set the autocomplete provider */ + setAutocompleteProvider?(provider: AutocompleteProvider): void; + + // ========================================================================= + // Appearance (optional) + // ========================================================================= + + /** Border color function */ + borderColor?: (str: string) => string; +} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 743719dc3..d76db8add 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -21,6 +21,8 @@ export { Spacer } from "./components/spacer"; export { type Tab, TabBar, type TabBarTheme } from "./components/tab-bar"; export { Text } from "./components/text"; export { TruncatedText } from "./components/truncated-text"; +// Editor component interface (for custom editors) +export type { EditorComponent } from "./editor-component"; // Keybindings export { DEFAULT_EDITOR_KEYBINDINGS, @@ -64,6 +66,9 @@ export { isEnter, isEscape, isHome, + isKeyRelease, + isKeyRepeat, + isKittyProtocolActive, isShiftBackspace, isShiftCtrlD, isShiftCtrlO, @@ -74,10 +79,14 @@ export { isShiftTab, isTab, Key, + type KeyEventType, type KeyId, matchesKey, parseKey, + setKittyProtocolActive, } from "./keys"; +// Input buffering for batch splitting +export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer"; export type { BoxSymbols, SymbolTheme } from "./symbols"; // Terminal interface and implementations export { emergencyTerminalRestore, ProcessTerminal, type Terminal } from "./terminal"; diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index d63879436..8cae78a46 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -13,8 +13,31 @@ * - matchesKey(data, keyId) - Check if input matches a key identifier * - parseKey(data) - Parse input and return the key identifier * - Key - Helper object for creating typed key identifiers + * - setKittyProtocolActive(active) - Set global Kitty protocol state + * - isKittyProtocolActive() - Query global Kitty protocol state */ +// ============================================================================= +// Global Kitty Protocol State +// ============================================================================= + +let kittyProtocolActive = false; + +/** + * Set the global Kitty keyboard protocol state. + * Called by ProcessTerminal after detecting protocol support. + */ +export function setKittyProtocolActive(active: boolean): void { + kittyProtocolActive = active; +} + +/** + * Query whether Kitty keyboard protocol is currently active. + */ +export function isKittyProtocolActive(): boolean { + return kittyProtocolActive; +} + // ============================================================================= // Type-Safe Key Identifiers // ============================================================================= @@ -271,33 +294,85 @@ const FUNCTIONAL_CODEPOINTS = { // Kitty Protocol Parsing // ============================================================================= +/** + * Event types from Kitty keyboard protocol (flag 2) + * 1 = key press, 2 = key repeat, 3 = key release + */ +export type KeyEventType = "press" | "repeat" | "release"; + interface ParsedKittySequence { codepoint: number; modifier: number; + eventType: KeyEventType; +} + +/** + * Check if the last parsed key event was a key release. + * Only meaningful when Kitty keyboard protocol with flag 2 is active. + */ +export function isKeyRelease(data: string): boolean { + return ( + data.includes(":3u") || + data.includes(":3~") || + data.includes(":3A") || + data.includes(":3B") || + data.includes(":3C") || + data.includes(":3D") || + data.includes(":3H") || + data.includes(":3F") + ); +} + +/** + * Check if the last parsed key event was a key repeat. + * Only meaningful when Kitty keyboard protocol with flag 2 is active. + */ +export function isKeyRepeat(data: string): boolean { + return ( + data.includes(":2u") || + data.includes(":2~") || + data.includes(":2A") || + data.includes(":2B") || + data.includes(":2C") || + data.includes(":2D") || + data.includes(":2H") || + data.includes(":2F") + ); +} + +function parseEventType(eventTypeStr: string | undefined): KeyEventType { + if (!eventTypeStr) return "press"; + const eventType = parseInt(eventTypeStr, 10); + if (eventType === 2) return "repeat"; + if (eventType === 3) return "release"; + return "press"; } function parseKittySequence(data: string): ParsedKittySequence | null { - // CSI u format: \x1b[u or \x1b[;u - const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?u$/); + // CSI u format: \x1b[u or \x1b[;u or \x1b[;:u + const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?u$/); if (csiUMatch) { const codepoint = parseInt(csiUMatch[1]!, 10); const modValue = csiUMatch[2] ? parseInt(csiUMatch[2], 10) : 1; - return { codepoint, modifier: modValue - 1 }; + const eventType = parseEventType(csiUMatch[3]); + return { codepoint, modifier: modValue - 1, eventType }; } - // Arrow keys with modifier: \x1b[1;A/B/C/D - const arrowMatch = data.match(/^\x1b\[1;(\d+)([ABCD])$/); + // Arrow keys with modifier: \x1b[1;A/B/C/D or \x1b[1;:A/B/C/D + const arrowMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/); if (arrowMatch) { const modValue = parseInt(arrowMatch[1]!, 10); + const eventType = parseEventType(arrowMatch[2]); const arrowCodes: Record = { A: -1, B: -2, C: -3, D: -4 }; - return { codepoint: arrowCodes[arrowMatch[2]!]!, modifier: modValue - 1 }; + return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType }; } - // Functional keys: \x1b[~ or \x1b[;~ - const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?~$/); + // Functional keys: \x1b[~ or \x1b[;~ or \x1b[;:~ + const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/); if (funcMatch) { const keyNum = parseInt(funcMatch[1]!, 10); const modValue = funcMatch[2] ? parseInt(funcMatch[2], 10) : 1; + const eventType = parseEventType(funcMatch[3]); const funcCodes: Record = { 2: FUNCTIONAL_CODEPOINTS.insert, 3: FUNCTIONAL_CODEPOINTS.delete, @@ -308,16 +383,17 @@ function parseKittySequence(data: string): ParsedKittySequence | null { }; const codepoint = funcCodes[keyNum]; if (codepoint !== undefined) { - return { codepoint, modifier: modValue - 1 }; + return { codepoint, modifier: modValue - 1, eventType }; } } - // Home/End with modifier: \x1b[1;H/F - const homeEndMatch = data.match(/^\x1b\[1;(\d+)([HF])$/); + // Home/End with modifier: \x1b[1;H/F or \x1b[1;:H/F + const homeEndMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([HF])$/); if (homeEndMatch) { const modValue = parseInt(homeEndMatch[1]!, 10); - const codepoint = homeEndMatch[2] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end; - return { codepoint, modifier: modValue - 1 }; + const eventType = parseEventType(homeEndMatch[2]); + const codepoint = homeEndMatch[3] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end; + return { codepoint, modifier: modValue - 1, eventType }; } return null; @@ -402,17 +478,28 @@ export function matchesKey(data: string, keyId: KeyId): boolean { case "enter": case "return": if (shift && !ctrl && !alt) { - return ( - data === "\x1b\r" || // Legacy: some terminals send ESC+CR for shift+enter + if ( matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) || matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift) - ); + ) { + return true; + } + if (kittyProtocolActive) { + return data === "\x1b\r" || data === "\n"; + } + return false; } if (alt && !ctrl && !shift) { - return ( + if ( matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) || matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.alt) - ); + ) { + return true; + } + if (!kittyProtocolActive) { + return data === "\x1b\r"; + } + return false; } if (modifier === 0) { return ( @@ -534,7 +621,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { return matchesKittySequence(data, codepoint, modifier); } - return data === key; + return data === key || matchesKittySequence(data, codepoint, 0); } return false; @@ -577,14 +664,22 @@ export function parseKey(data: string): string | undefined { } } - // Legacy sequences + // Mode-aware legacy sequences + // When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings: + // - \x1b\r = shift+enter (Kitty mapping), not alt+enter + // - \n = shift+enter (Ghostty mapping) + if (kittyProtocolActive) { + if (data === "\x1b\r" || data === "\n") return "shift+enter"; + } + + // Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences) if (data === "\x1b") return "escape"; if (data === "\t") return "tab"; if (data === "\r" || data === "\x1bOM") return "enter"; if (data === " ") return "space"; if (data === "\x7f" || data === "\x08") return "backspace"; if (data === "\x1b[Z") return "shift+tab"; - if (data === "\x1b\r") return "shift+enter"; // Legacy: ESC+CR for shift+enter + if (!kittyProtocolActive && data === "\x1b\r") return "alt+enter"; if (data === "\x1b\x7f") return "alt+backspace"; if (data === "\x1b[A") return "up"; if (data === "\x1b[B") return "down"; diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts new file mode 100644 index 000000000..9a0c81357 --- /dev/null +++ b/packages/tui/src/stdin-buffer.ts @@ -0,0 +1,386 @@ +/** + * StdinBuffer buffers input and emits complete sequences. + * + * This is necessary because stdin data events can arrive in partial chunks, + * especially for escape sequences like mouse events. Without buffering, + * partial sequences can be misinterpreted as regular keypresses. + * + * For example, the mouse SGR sequence `\x1b[<35;20;5m` might arrive as: + * - Event 1: `\x1b` + * - Event 2: `[<35` + * - Event 3: `;20;5m` + * + * The buffer accumulates these until a complete sequence is detected. + * Call the `process()` method to feed input data. + * + * Based on code from OpenTUI (https://github.com/anomalyco/opentui) + * MIT License - Copyright (c) 2025 opentui + */ + +import { EventEmitter } from "node:events"; + +const ESC = "\x1b"; +const BRACKETED_PASTE_START = "\x1b[200~"; +const BRACKETED_PASTE_END = "\x1b[201~"; + +/** + * Check if a string is a complete escape sequence or needs more data + */ +function isCompleteSequence(data: string): "complete" | "incomplete" | "not-escape" { + if (!data.startsWith(ESC)) { + return "not-escape"; + } + + if (data.length === 1) { + return "incomplete"; + } + + const afterEsc = data.slice(1); + + // CSI sequences: ESC [ + if (afterEsc.startsWith("[")) { + // Check for old-style mouse sequence: ESC[M + 3 bytes + if (afterEsc.startsWith("[M")) { + // Old-style mouse needs ESC[M + 3 bytes = 6 total + return data.length >= 6 ? "complete" : "incomplete"; + } + return isCompleteCsiSequence(data); + } + + // OSC sequences: ESC ] + if (afterEsc.startsWith("]")) { + return isCompleteOscSequence(data); + } + + // DCS sequences: ESC P ... ESC \ (includes XTVersion responses) + if (afterEsc.startsWith("P")) { + return isCompleteDcsSequence(data); + } + + // APC sequences: ESC _ ... ESC \ (includes Kitty graphics responses) + if (afterEsc.startsWith("_")) { + return isCompleteApcSequence(data); + } + + // SS3 sequences: ESC O + if (afterEsc.startsWith("O")) { + // ESC O followed by a single character + return afterEsc.length >= 2 ? "complete" : "incomplete"; + } + + // Meta key sequences: ESC followed by a single character + if (afterEsc.length === 1) { + return "complete"; + } + + // Unknown escape sequence - treat as complete + return "complete"; +} + +/** + * Check if CSI sequence is complete + * CSI sequences: ESC [ ... followed by a final byte (0x40-0x7E) + */ +function isCompleteCsiSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}[`)) { + return "complete"; + } + + // Need at least ESC [ and one more character + if (data.length < 3) { + return "incomplete"; + } + + const payload = data.slice(2); + + // CSI sequences end with a byte in the range 0x40-0x7E (@-~) + // This includes all letters and several special characters + const lastChar = payload[payload.length - 1]; + const lastCharCode = lastChar.charCodeAt(0); + + if (lastCharCode >= 0x40 && lastCharCode <= 0x7e) { + // Special handling for SGR mouse sequences + // Format: ESC[ /^\d+$/.test(p))) { + return "complete"; + } + } + + return "incomplete"; + } + + return "complete"; + } + + return "incomplete"; +} + +/** + * Check if OSC sequence is complete + * OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL) + */ +function isCompleteOscSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}]`)) { + return "complete"; + } + + // OSC sequences end with ST (ESC \) or BEL (\x07) + if (data.endsWith(`${ESC}\\`) || data.endsWith("\x07")) { + return "complete"; + } + + return "incomplete"; +} + +/** + * Check if DCS (Device Control String) sequence is complete + * DCS sequences: ESC P ... ST (where ST is ESC \) + * Used for XTVersion responses like ESC P >| ... ESC \ + */ +function isCompleteDcsSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}P`)) { + return "complete"; + } + + // DCS sequences end with ST (ESC \) + if (data.endsWith(`${ESC}\\`)) { + return "complete"; + } + + return "incomplete"; +} + +/** + * Check if APC (Application Program Command) sequence is complete + * APC sequences: ESC _ ... ST (where ST is ESC \) + * Used for Kitty graphics responses like ESC _ G ... ESC \ + */ +function isCompleteApcSequence(data: string): "complete" | "incomplete" { + if (!data.startsWith(`${ESC}_`)) { + return "complete"; + } + + // APC sequences end with ST (ESC \) + if (data.endsWith(`${ESC}\\`)) { + return "complete"; + } + + return "incomplete"; +} + +/** + * Split accumulated buffer into complete sequences + */ +function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } { + const sequences: string[] = []; + let pos = 0; + + while (pos < buffer.length) { + const remaining = buffer.slice(pos); + + // Try to extract a sequence starting at this position + if (remaining.startsWith(ESC)) { + // Find the end of this escape sequence + let seqEnd = 1; + while (seqEnd <= remaining.length) { + const candidate = remaining.slice(0, seqEnd); + const status = isCompleteSequence(candidate); + + if (status === "complete") { + sequences.push(candidate); + pos += seqEnd; + break; + } else if (status === "incomplete") { + seqEnd++; + } else { + // Should not happen when starting with ESC + sequences.push(candidate); + pos += seqEnd; + break; + } + } + + if (seqEnd > remaining.length) { + return { sequences, remainder: remaining }; + } + } else { + // Not an escape sequence - take a single character + sequences.push(remaining[0]!); + pos++; + } + } + + return { sequences, remainder: "" }; +} + +export type StdinBufferOptions = { + /** + * Maximum time to wait for sequence completion (default: 10ms) + * After this time, the buffer is flushed even if incomplete + */ + timeout?: number; +}; + +export type StdinBufferEventMap = { + data: [string]; + paste: [string]; +}; + +/** + * Buffers stdin input and emits complete sequences via the 'data' event. + * Handles partial escape sequences that arrive across multiple chunks. + */ +export class StdinBuffer extends EventEmitter { + private buffer: string = ""; + private timeout: ReturnType | null = null; + private readonly timeoutMs: number; + private pasteMode: boolean = false; + private pasteBuffer: string = ""; + + constructor(options: StdinBufferOptions = {}) { + super(); + this.timeoutMs = options.timeout ?? 10; + } + + public process(data: string | Buffer): void { + // Clear any pending timeout + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + + // Handle high-byte conversion (for compatibility with parseKeypress) + // If buffer has single byte > 127, convert to ESC + (byte - 128) + let str: string; + if (Buffer.isBuffer(data)) { + if (data.length === 1 && data[0]! > 127) { + const byte = data[0]! - 128; + str = `\x1b${String.fromCharCode(byte)}`; + } else { + str = data.toString(); + } + } else { + str = data; + } + + if (str.length === 0 && this.buffer.length === 0) { + this.emit("data", ""); + return; + } + + this.buffer += str; + + if (this.pasteMode) { + this.pasteBuffer += this.buffer; + this.buffer = ""; + + const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END); + if (endIndex !== -1) { + const pastedContent = this.pasteBuffer.slice(0, endIndex); + const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); + + this.pasteMode = false; + this.pasteBuffer = ""; + + this.emit("paste", pastedContent); + + if (remaining.length > 0) { + this.process(remaining); + } + } + return; + } + + const startIndex = this.buffer.indexOf(BRACKETED_PASTE_START); + if (startIndex !== -1) { + if (startIndex > 0) { + const beforePaste = this.buffer.slice(0, startIndex); + const result = extractCompleteSequences(beforePaste); + for (const sequence of result.sequences) { + this.emit("data", sequence); + } + } + + this.buffer = this.buffer.slice(startIndex + BRACKETED_PASTE_START.length); + this.pasteMode = true; + this.pasteBuffer = this.buffer; + this.buffer = ""; + + const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END); + if (endIndex !== -1) { + const pastedContent = this.pasteBuffer.slice(0, endIndex); + const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length); + + this.pasteMode = false; + this.pasteBuffer = ""; + + this.emit("paste", pastedContent); + + if (remaining.length > 0) { + this.process(remaining); + } + } + return; + } + + const result = extractCompleteSequences(this.buffer); + this.buffer = result.remainder; + + for (const sequence of result.sequences) { + this.emit("data", sequence); + } + + if (this.buffer.length > 0) { + this.timeout = setTimeout(() => { + const flushed = this.flush(); + + for (const sequence of flushed) { + this.emit("data", sequence); + } + }, this.timeoutMs); + } + } + + flush(): string[] { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + + if (this.buffer.length === 0) { + return []; + } + + const sequences = [this.buffer]; + this.buffer = ""; + return sequences; + } + + clear(): void { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = null; + } + this.buffer = ""; + this.pasteMode = false; + this.pasteBuffer = ""; + } + + getBuffer(): string { + return this.buffer; + } + + destroy(): void { + this.clear(); + } +} diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index dbb248beb..3fa93c953 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -1,3 +1,6 @@ +import { setKittyProtocolActive } from "./keys"; +import { StdinBuffer } from "./stdin-buffer"; + /** * Minimal terminal interface for TUI */ @@ -40,6 +43,9 @@ export interface Terminal { get columns(): number; get rows(): number; + // Whether Kitty keyboard protocol is active + get kittyProtocolActive(): boolean; + // Cursor positioning (relative to current position) moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines @@ -63,6 +69,13 @@ export class ProcessTerminal implements Terminal { private wasRaw = false; private inputHandler?: (data: string) => void; private resizeHandler?: () => void; + private _kittyProtocolActive = false; + private stdinBuffer?: StdinBuffer; + private stdinDataHandler?: (data: string) => void; + + get kittyProtocolActive(): boolean { + return this._kittyProtocolActive; + } start(onInput: (data: string) => void, onResize: () => void): void { this.inputHandler = onInput; @@ -82,15 +95,125 @@ export class ProcessTerminal implements Terminal { // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~ process.stdout.write("\x1b[?2004h"); - // Enable Kitty keyboard protocol (disambiguate escape codes) - // This makes terminals like Ghostty, Kitty, WezTerm send enhanced key sequences - // e.g., Shift+Enter becomes \x1b[13;2u instead of just \r - // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ - process.stdout.write("\x1b[>1u"); - - // Set up event handlers - process.stdin.on("data", this.inputHandler); + // Set up resize handler immediately process.stdout.on("resize", this.resizeHandler); + + // Query and enable Kitty keyboard protocol + // The query handler intercepts input temporarily, then installs the user's handler + // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + this.queryAndEnableKittyProtocol(); + } + + /** + * Set up StdinBuffer to split batched input into individual sequences. + * This ensures components receive single events, making matchesKey/isKeyRelease work correctly. + * Note: Does NOT register the stdin handler - that's done after the Kitty protocol query. + */ + private setupStdinBuffer(): void { + this.stdinBuffer = new StdinBuffer({ timeout: 10 }); + + // Forward individual sequences to the input handler + this.stdinBuffer.on("data", (sequence: string) => { + if (this.inputHandler) { + this.inputHandler(sequence); + } + }); + + // Re-wrap paste content with bracketed paste markers for existing editor handling + this.stdinBuffer.on("paste", (content: string) => { + if (this.inputHandler) { + this.inputHandler(`\x1b[200~${content}\x1b[201~`); + } + }); + + // Handler that pipes stdin data through the buffer + // Registration happens after Kitty protocol query completes + this.stdinDataHandler = (data: string) => { + this.stdinBuffer!.process(data); + }; + } + + /** + * Query terminal for Kitty keyboard protocol support and enable if available. + * + * Sends CSI ? u to query current flags. If terminal responds with CSI ? u, + * it supports the protocol and we enable it with CSI > 1 u. + * + * Non-supporting terminals won't respond, so we use a timeout. + */ + private queryAndEnableKittyProtocol(): void { + const QUERY_TIMEOUT_MS = 100; + let resolved = false; + let buffer = ""; + + // Kitty protocol response pattern: \x1b[?u + const kittyResponsePattern = /\x1b\[\?(\d+)u/; + + const queryHandler = (data: string) => { + if (resolved) { + // Query phase done, forward to StdinBuffer + if (this.stdinBuffer) { + this.stdinBuffer.process(data); + } + return; + } + + buffer += data; + + // Check if we have a Kitty protocol response + const match = buffer.match(kittyResponsePattern); + if (match) { + resolved = true; + this._kittyProtocolActive = true; + setKittyProtocolActive(true); + + // Enable Kitty keyboard protocol (push flags) + // Flag 1 = disambiguate escape codes + // Flag 2 = report event types (press/repeat/release) + process.stdout.write("\x1b[>3u"); + + // Remove the response from buffer, forward any remaining input through StdinBuffer + const remaining = buffer.replace(kittyResponsePattern, ""); + if (remaining && this.stdinBuffer) { + this.stdinBuffer.process(remaining); + } + + // Replace query handler with StdinBuffer handler + process.stdin.removeListener("data", queryHandler); + if (this.stdinDataHandler) { + process.stdin.on("data", this.stdinDataHandler); + } + } + }; + + // Set up StdinBuffer before query (it will receive input after query completes) + this.setupStdinBuffer(); + + // Temporarily intercept input for the query (before StdinBuffer) + process.stdin.on("data", queryHandler); + + // Send query + process.stdout.write("\x1b[?u"); + + // Timeout: if no response, terminal doesn't support Kitty protocol + setTimeout(() => { + if (!resolved) { + resolved = true; + this._kittyProtocolActive = false; + setKittyProtocolActive(false); + + // Forward any buffered input that wasn't a Kitty response through StdinBuffer + if (buffer && this.stdinBuffer) { + this.stdinBuffer.process(buffer); + } + + // Replace query handler with StdinBuffer handler + process.stdin.removeListener("data", queryHandler); + if (this.stdinDataHandler) { + process.stdin.on("data", this.stdinDataHandler); + } + } + }, QUERY_TIMEOUT_MS); } stop(): void { @@ -102,14 +225,25 @@ export class ProcessTerminal implements Terminal { // Disable bracketed paste mode process.stdout.write("\x1b[?2004l"); - // Disable Kitty keyboard protocol (pop the flags we pushed) - process.stdout.write("\x1b[ 0; + } + + override invalidate(): void { + super.invalidate(); + for (const overlay of this.overlayStack) overlay.component.invalidate?.(); + } + start(): void { this.terminal.start( (data) => this.handleInput(data), @@ -230,6 +267,9 @@ export class TUI extends Container { // Pass input to focused component (including Ctrl+C) // The focused component can decide how to handle Ctrl+C if (this.focusedComponent?.handleInput) { + if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) { + return; + } this.focusedComponent.handleInput(data); this.requestRender(); } @@ -282,6 +322,77 @@ export class TUI extends Container { return line.includes("\x1b_G") || line.includes("\x1b]1337;File="); } + /** Composite all overlays into content lines (in stack order, later = on top). */ + private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { + if (this.overlayStack.length === 0) return lines; + const result = [...lines]; + const viewportStart = Math.max(0, result.length - termHeight); + + for (const { component, options } of this.overlayStack) { + const w = + options?.width !== undefined + ? Math.max(1, Math.min(options.width, termWidth - 4)) + : Math.max(1, Math.min(80, termWidth - 4)); + const overlayLines = component.render(w); + const h = overlayLines.length; + + const row = Math.max(0, Math.min(options?.row ?? Math.floor((termHeight - h) / 2), termHeight - h)); + const col = Math.max(0, Math.min(options?.col ?? Math.floor((termWidth - w) / 2), termWidth - w)); + + for (let i = 0; i < h; i++) { + const idx = viewportStart + row + i; + if (idx >= 0 && idx < result.length) { + result[idx] = this.compositeLineAt(result[idx], overlayLines[i], col, w, termWidth); + } + } + } + return result; + } + + private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; + + /** Splice overlay content into a base line at a specific column. Single-pass optimized. */ + private compositeLineAt( + baseLine: string, + overlayLine: string, + startCol: number, + overlayWidth: number, + totalWidth: number, + ): string { + if (this.containsImage(baseLine)) return baseLine; + + // Single pass through baseLine extracts both before and after segments + const afterStart = startCol + overlayWidth; + const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true); + + // Extract overlay with width tracking + const overlay = sliceWithWidth(overlayLine, 0, overlayWidth); + + // Pad segments to target widths + const beforePad = Math.max(0, startCol - base.beforeWidth); + const overlayPad = Math.max(0, overlayWidth - overlay.width); + const actualBeforeWidth = Math.max(startCol, base.beforeWidth); + const actualOverlayWidth = Math.max(overlayWidth, overlay.width); + const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth); + const afterPad = Math.max(0, afterTarget - base.afterWidth); + + // Compose result - widths are tracked so no final visibleWidth check needed + const r = TUI.SEGMENT_RESET; + const result = + base.before + + " ".repeat(beforePad) + + r + + overlay.text + + " ".repeat(overlayPad) + + r + + base.after + + " ".repeat(afterPad); + + // Only truncate if wide char at after boundary caused overflow (rare) + const resultWidth = actualBeforeWidth + actualOverlayWidth + Math.max(afterTarget, base.afterWidth); + return resultWidth <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true); + } + private doRender(): void { // Capture terminal dimensions at start to ensure consistency throughout render const width = this.terminal.columns; @@ -290,7 +401,13 @@ export class TUI extends Container { const currentCursorRow = this.cursorRow; // Render all components to get new lines - const newLines = this.render(width); + let newLines = this.render(width); + + // Composite overlays into the rendered lines (before differential compare) + if (this.overlayStack.length > 0) { + newLines = this.compositeOverlays(newLines, width, height); + } + const cursorInfo = this.getCursorPosition(width); // Width changed - need full re-render @@ -417,7 +534,19 @@ export class TUI extends Container { } catch { // Ignore - crash log is best-effort } - throw new Error(`Rendered line ${i} exceeds terminal width. Debug log written to ${crashLogPath}`); + + // Clean up terminal state before throwing + this.stop(); + + const errorMsg = [ + `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`, + "", + "This is likely caused by a custom TUI component not truncating its output.", + "Use visibleWidth() to measure and truncateToWidth() to truncate lines.", + "", + `Debug log written to: ${crashLogPath}`, + ].join("\n"); + throw new Error(errorMsg); } buffer += line; } diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 7f321caa4..54d970307 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -135,21 +135,31 @@ export function visibleWidth(str: string): number { /** * Extract ANSI escape sequences from a string at the given position. */ -function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null { - if (pos >= str.length || str[pos] !== "\x1b" || str[pos + 1] !== "[") { +export function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null { + if (pos >= str.length || str[pos] !== "\x1b") return null; + + const next = str[pos + 1]; + + // CSI sequence: ESC [ ... m/G/K/H/J + if (next === "[") { + let j = pos + 2; + while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++; + if (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos }; return null; } - let j = pos + 2; - while (j < str.length && str[j] && !/[mGKHJ]/.test(str[j]!)) { - j++; - } - - if (j < str.length) { - return { - code: str.substring(pos, j + 1), - length: j + 1 - pos, - }; + // OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \) + // Used for hyperlinks (OSC 8), window titles, etc. + if (next === "]") { + let j = pos + 2; + while (j < str.length) { + if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos }; + if (str[j] === "\x1b" && str[j + 1] === "\\") { + return { code: str.substring(pos, j + 2), length: j + 2 - pos }; + } + j++; + } + return null; } return null; @@ -308,6 +318,11 @@ class AnsiCodeTracker { this.bgColor = null; } + /** Clear all state for reuse. */ + clear(): void { + this.reset(); + } + getActiveCodes(): string { const codes: string[] = []; if (this.bold) codes.push("1"); @@ -510,7 +525,7 @@ function wrapSingleLine(line: string, width: number): string[] { const totalNeeded = currentVisibleLength + tokenVisibleLength; if (totalNeeded > width && currentVisibleLength > 0) { - // Add specific reset for underline only (preserves background) + // Trim trailing whitespace, then add underline reset (not full reset, to preserve background) let lineToWrap = currentLine.trimEnd(); const lineEndReset = tracker.getLineEndReset(); if (lineEndReset) { @@ -539,7 +554,8 @@ function wrapSingleLine(line: string, width: number): string[] { wrapped.push(currentLine); } - return wrapped.length > 0 ? wrapped : [""]; + // Trailing whitespace can cause lines to exceed the requested width + return wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [""]; } const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/; @@ -722,3 +738,140 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string // Add reset code before ellipsis to prevent styling leaking into it return `${result}\x1b[0m${ellipsis}`; } + +/** + * Extract a range of visible columns from a line. Handles ANSI codes and wide chars. + * @param strict - If true, exclude wide chars at boundary that would extend past the range + */ +export function sliceByColumn(line: string, startCol: number, length: number, strict = false): string { + return sliceWithWidth(line, startCol, length, strict).text; +} + +/** Like sliceByColumn but also returns the actual visible width of the result. */ +export function sliceWithWidth( + line: string, + startCol: number, + length: number, + strict = false, +): { text: string; width: number } { + if (length <= 0) return { text: "", width: 0 }; + const endCol = startCol + length; + let result = "", + resultWidth = 0, + currentCol = 0, + i = 0, + pendingAnsi = ""; + + while (i < line.length) { + const ansi = extractAnsiCode(line, i); + if (ansi) { + if (currentCol >= startCol && currentCol < endCol) result += ansi.code; + else if (currentCol < startCol) pendingAnsi += ansi.code; + i += ansi.length; + continue; + } + + let textEnd = i; + while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; + + for (const { segment } of segmenter.segment(line.slice(i, textEnd))) { + const w = graphemeWidth(segment); + const inRange = currentCol >= startCol && currentCol < endCol; + const fits = !strict || currentCol + w <= endCol; + if (inRange && fits) { + if (pendingAnsi) { + result += pendingAnsi; + pendingAnsi = ""; + } + result += segment; + resultWidth += w; + } + currentCol += w; + if (currentCol >= endCol) break; + } + i = textEnd; + if (currentCol >= endCol) break; + } + return { text: result, width: resultWidth }; +} + +// Pooled tracker instance for extractSegments (avoids allocation per call) +const pooledStyleTracker = new AnsiCodeTracker(); + +/** + * Extract "before" and "after" segments from a line in a single pass. + * Used for overlay compositing where we need content before and after the overlay region. + * Preserves styling from before the overlay that should affect content after it. + */ +export function extractSegments( + line: string, + beforeEnd: number, + afterStart: number, + afterLen: number, + strictAfter = false, +): { before: string; beforeWidth: number; after: string; afterWidth: number } { + let before = "", + beforeWidth = 0, + after = "", + afterWidth = 0; + let currentCol = 0, + i = 0; + let pendingAnsiBefore = ""; + let afterStarted = false; + const afterEnd = afterStart + afterLen; + + // Track styling state so "after" inherits styling from before the overlay + pooledStyleTracker.clear(); + + while (i < line.length) { + const ansi = extractAnsiCode(line, i); + if (ansi) { + // Track all SGR codes to know styling state at afterStart + pooledStyleTracker.process(ansi.code); + // Include ANSI codes in their respective segments + if (currentCol < beforeEnd) { + pendingAnsiBefore += ansi.code; + } else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) { + // Only include after we've started "after" (styling already prepended) + after += ansi.code; + } + i += ansi.length; + continue; + } + + let textEnd = i; + while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; + + for (const { segment } of segmenter.segment(line.slice(i, textEnd))) { + const w = graphemeWidth(segment); + + if (currentCol < beforeEnd) { + if (pendingAnsiBefore) { + before += pendingAnsiBefore; + pendingAnsiBefore = ""; + } + before += segment; + beforeWidth += w; + } else if (currentCol >= afterStart && currentCol < afterEnd) { + const fits = !strictAfter || currentCol + w <= afterEnd; + if (fits) { + // On first "after" grapheme, prepend inherited styling from before overlay + if (!afterStarted) { + after += pooledStyleTracker.getActiveCodes(); + afterStarted = true; + } + after += segment; + afterWidth += w; + } + } + + currentCol += w; + // Early exit: done with "before" only, or done with both segments + if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break; + } + i = textEnd; + if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break; + } + + return { before, beforeWidth, after, afterWidth }; +} diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts new file mode 100644 index 000000000..2718bacf5 --- /dev/null +++ b/packages/tui/test/stdin-buffer.test.ts @@ -0,0 +1,421 @@ +/** + * Tests for StdinBuffer + * + * Based on code from OpenTUI (https://github.com/anomalyco/opentui) + * MIT License - Copyright (c) 2025 opentui + */ + +import { beforeEach, describe, expect, it } from "bun:test"; +import { StdinBuffer } from "../src/stdin-buffer"; + +describe("StdinBuffer", () => { + let buffer: StdinBuffer; + let emittedSequences: string[]; + + beforeEach(() => { + buffer = new StdinBuffer({ timeout: 10 }); + + // Collect emitted sequences + emittedSequences = []; + buffer.on("data", (sequence: string) => { + emittedSequences.push(sequence); + }); + }); + + // Helper to process data through the buffer + function processInput(data: string | Buffer): void { + buffer.process(data); + } + + // Helper to wait for async operations + async function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + describe("Regular Characters", () => { + it("should pass through regular characters immediately", () => { + processInput("a"); + expect(emittedSequences).toEqual(["a"]); + }); + + it("should pass through multiple regular characters", () => { + processInput("abc"); + expect(emittedSequences).toEqual(["a", "b", "c"]); + }); + + it("should handle unicode characters", () => { + processInput("hello \u4e16\u754c"); + expect(emittedSequences).toEqual(["h", "e", "l", "l", "o", " ", "\u4e16", "\u754c"]); + }); + }); + + describe("Complete Escape Sequences", () => { + it("should pass through complete mouse SGR sequences", () => { + const mouseSeq = "\x1b[<35;20;5m"; + processInput(mouseSeq); + expect(emittedSequences).toEqual([mouseSeq]); + }); + + it("should pass through complete arrow key sequences", () => { + const upArrow = "\x1b[A"; + processInput(upArrow); + expect(emittedSequences).toEqual([upArrow]); + }); + + it("should pass through complete function key sequences", () => { + const f1 = "\x1b[11~"; + processInput(f1); + expect(emittedSequences).toEqual([f1]); + }); + + it("should pass through meta key sequences", () => { + const metaA = "\x1ba"; + processInput(metaA); + expect(emittedSequences).toEqual([metaA]); + }); + + it("should pass through SS3 sequences", () => { + const ss3 = "\x1bOA"; + processInput(ss3); + expect(emittedSequences).toEqual([ss3]); + }); + }); + + describe("Partial Escape Sequences", () => { + it("should buffer incomplete mouse SGR sequence", () => { + processInput("\x1b"); + expect(emittedSequences).toEqual([]); + expect(buffer.getBuffer()).toBe("\x1b"); + + processInput("[<35"); + expect(emittedSequences).toEqual([]); + expect(buffer.getBuffer()).toBe("\x1b[<35"); + + processInput(";20;5m"); + expect(emittedSequences).toEqual(["\x1b[<35;20;5m"]); + expect(buffer.getBuffer()).toBe(""); + }); + + it("should buffer incomplete CSI sequence", () => { + processInput("\x1b["); + expect(emittedSequences).toEqual([]); + + processInput("1;"); + expect(emittedSequences).toEqual([]); + + processInput("5H"); + expect(emittedSequences).toEqual(["\x1b[1;5H"]); + }); + + it("should buffer split across many chunks", () => { + processInput("\x1b"); + processInput("["); + processInput("<"); + processInput("3"); + processInput("5"); + processInput(";"); + processInput("2"); + processInput("0"); + processInput(";"); + processInput("5"); + processInput("m"); + + expect(emittedSequences).toEqual(["\x1b[<35;20;5m"]); + }); + + it("should flush incomplete sequence after timeout", async () => { + processInput("\x1b[<35"); + expect(emittedSequences).toEqual([]); + + // Wait for timeout + await wait(15); + + expect(emittedSequences).toEqual(["\x1b[<35"]); + }); + }); + + describe("Mixed Content", () => { + it("should handle characters followed by escape sequence", () => { + processInput("abc\x1b[A"); + expect(emittedSequences).toEqual(["a", "b", "c", "\x1b[A"]); + }); + + it("should handle escape sequence followed by characters", () => { + processInput("\x1b[Aabc"); + expect(emittedSequences).toEqual(["\x1b[A", "a", "b", "c"]); + }); + + it("should handle multiple complete sequences", () => { + processInput("\x1b[A\x1b[B\x1b[C"); + expect(emittedSequences).toEqual(["\x1b[A", "\x1b[B", "\x1b[C"]); + }); + + it("should handle partial sequence with preceding characters", () => { + processInput("abc\x1b[<35"); + expect(emittedSequences).toEqual(["a", "b", "c"]); + expect(buffer.getBuffer()).toBe("\x1b[<35"); + + processInput(";20;5m"); + expect(emittedSequences).toEqual(["a", "b", "c", "\x1b[<35;20;5m"]); + }); + }); + + describe("Kitty Keyboard Protocol", () => { + it("should handle Kitty CSI u press events", () => { + // Press 'a' in Kitty protocol + processInput("\x1b[97u"); + expect(emittedSequences).toEqual(["\x1b[97u"]); + }); + + it("should handle Kitty CSI u release events", () => { + // Release 'a' in Kitty protocol + processInput("\x1b[97;1:3u"); + expect(emittedSequences).toEqual(["\x1b[97;1:3u"]); + }); + + it("should handle batched Kitty press and release", () => { + // Press 'a', release 'a' batched together (common over SSH) + processInput("\x1b[97u\x1b[97;1:3u"); + expect(emittedSequences).toEqual(["\x1b[97u", "\x1b[97;1:3u"]); + }); + + it("should handle multiple batched Kitty events", () => { + // Press 'a', release 'a', press 'b', release 'b' + processInput("\x1b[97u\x1b[97;1:3u\x1b[98u\x1b[98;1:3u"); + expect(emittedSequences).toEqual(["\x1b[97u", "\x1b[97;1:3u", "\x1b[98u", "\x1b[98;1:3u"]); + }); + + it("should handle Kitty arrow keys with event type", () => { + // Up arrow press with event type + processInput("\x1b[1;1:1A"); + expect(emittedSequences).toEqual(["\x1b[1;1:1A"]); + }); + + it("should handle Kitty functional keys with event type", () => { + // Delete key release + processInput("\x1b[3;1:3~"); + expect(emittedSequences).toEqual(["\x1b[3;1:3~"]); + }); + + it("should handle plain characters mixed with Kitty sequences", () => { + // Plain 'a' followed by Kitty release + processInput("a\x1b[97;1:3u"); + expect(emittedSequences).toEqual(["a", "\x1b[97;1:3u"]); + }); + + it("should handle Kitty sequence followed by plain characters", () => { + processInput("\x1b[97ua"); + expect(emittedSequences).toEqual(["\x1b[97u", "a"]); + }); + + it("should handle rapid typing simulation with Kitty protocol", () => { + // Simulates typing "hi" quickly with releases interleaved + processInput("\x1b[104u\x1b[104;1:3u\x1b[105u\x1b[105;1:3u"); + expect(emittedSequences).toEqual(["\x1b[104u", "\x1b[104;1:3u", "\x1b[105u", "\x1b[105;1:3u"]); + }); + }); + + describe("Mouse Events", () => { + it("should handle mouse press event", () => { + processInput("\x1b[<0;10;5M"); + expect(emittedSequences).toEqual(["\x1b[<0;10;5M"]); + }); + + it("should handle mouse release event", () => { + processInput("\x1b[<0;10;5m"); + expect(emittedSequences).toEqual(["\x1b[<0;10;5m"]); + }); + + it("should handle mouse move event", () => { + processInput("\x1b[<35;20;5m"); + expect(emittedSequences).toEqual(["\x1b[<35;20;5m"]); + }); + + it("should handle split mouse events", () => { + processInput("\x1b[<3"); + processInput("5;1"); + processInput("5;"); + processInput("10m"); + expect(emittedSequences).toEqual(["\x1b[<35;15;10m"]); + }); + + it("should handle multiple mouse events", () => { + processInput("\x1b[<35;1;1m\x1b[<35;2;2m\x1b[<35;3;3m"); + expect(emittedSequences).toEqual(["\x1b[<35;1;1m", "\x1b[<35;2;2m", "\x1b[<35;3;3m"]); + }); + + it("should handle old-style mouse sequence (ESC[M + 3 bytes)", () => { + processInput("\x1b[M abc"); + expect(emittedSequences).toEqual(["\x1b[M ab", "c"]); + }); + + it("should buffer incomplete old-style mouse sequence", () => { + processInput("\x1b[M"); + expect(buffer.getBuffer()).toBe("\x1b[M"); + + processInput(" a"); + expect(buffer.getBuffer()).toBe("\x1b[M a"); + + processInput("b"); + expect(emittedSequences).toEqual(["\x1b[M ab"]); + }); + }); + + describe("Edge Cases", () => { + it("should handle empty input", () => { + processInput(""); + // Empty string emits an empty data event + expect(emittedSequences).toEqual([""]); + }); + + it("should handle lone escape character with timeout", async () => { + processInput("\x1b"); + expect(emittedSequences).toEqual([]); + + // After timeout, should emit + await wait(15); + expect(emittedSequences).toEqual(["\x1b"]); + }); + + it("should handle lone escape character with explicit flush", () => { + processInput("\x1b"); + expect(emittedSequences).toEqual([]); + + const flushed = buffer.flush(); + expect(flushed).toEqual(["\x1b"]); + }); + + it("should handle buffer input", () => { + processInput(Buffer.from("\x1b[A")); + expect(emittedSequences).toEqual(["\x1b[A"]); + }); + + it("should handle very long sequences", () => { + const longSeq = `\x1b[${"1;".repeat(50)}H`; + processInput(longSeq); + expect(emittedSequences).toEqual([longSeq]); + }); + }); + + describe("Flush", () => { + it("should flush incomplete sequences", () => { + processInput("\x1b[<35"); + const flushed = buffer.flush(); + expect(flushed).toEqual(["\x1b[<35"]); + expect(buffer.getBuffer()).toBe(""); + }); + + it("should return empty array if nothing to flush", () => { + const flushed = buffer.flush(); + expect(flushed).toEqual([]); + }); + + it("should emit flushed data via timeout", async () => { + processInput("\x1b[<35"); + expect(emittedSequences).toEqual([]); + + // Wait for timeout to flush + await wait(15); + + expect(emittedSequences).toEqual(["\x1b[<35"]); + }); + }); + + describe("Clear", () => { + it("should clear buffered content without emitting", () => { + processInput("\x1b[<35"); + expect(buffer.getBuffer()).toBe("\x1b[<35"); + + buffer.clear(); + expect(buffer.getBuffer()).toBe(""); + expect(emittedSequences).toEqual([]); + }); + }); + + describe("Bracketed Paste", () => { + let emittedPaste: string[] = []; + + beforeEach(() => { + buffer = new StdinBuffer({ timeout: 10 }); + + // Collect emitted sequences + emittedSequences = []; + buffer.on("data", (sequence: string) => { + emittedSequences.push(sequence); + }); + + // Collect paste events + emittedPaste = []; + buffer.on("paste", (data: string) => { + emittedPaste.push(data); + }); + }); + + it("should emit paste event for complete bracketed paste", () => { + const pasteStart = "\x1b[200~"; + const pasteEnd = "\x1b[201~"; + const content = "hello world"; + + processInput(pasteStart + content + pasteEnd); + + expect(emittedPaste).toEqual(["hello world"]); + expect(emittedSequences).toEqual([]); // No data events during paste + }); + + it("should handle paste arriving in chunks", () => { + processInput("\x1b[200~"); + expect(emittedPaste).toEqual([]); + + processInput("hello "); + expect(emittedPaste).toEqual([]); + + processInput("world\x1b[201~"); + expect(emittedPaste).toEqual(["hello world"]); + expect(emittedSequences).toEqual([]); + }); + + it("should handle paste with input before and after", () => { + processInput("a"); + processInput("\x1b[200~pasted\x1b[201~"); + processInput("b"); + + expect(emittedSequences).toEqual(["a", "b"]); + expect(emittedPaste).toEqual(["pasted"]); + }); + + it("should handle paste with newlines", () => { + processInput("\x1b[200~line1\nline2\nline3\x1b[201~"); + + expect(emittedPaste).toEqual(["line1\nline2\nline3"]); + expect(emittedSequences).toEqual([]); + }); + + it("should handle paste with unicode", () => { + processInput("\x1b[200~Hello \u4e16\u754c \u{1f389}\x1b[201~"); + + expect(emittedPaste).toEqual(["Hello \u4e16\u754c \u{1f389}"]); + expect(emittedSequences).toEqual([]); + }); + }); + + describe("Destroy", () => { + it("should clear buffer on destroy", () => { + processInput("\x1b[<35"); + expect(buffer.getBuffer()).toBe("\x1b[<35"); + + buffer.destroy(); + expect(buffer.getBuffer()).toBe(""); + }); + + it("should clear pending timeouts on destroy", async () => { + processInput("\x1b[<35"); + buffer.destroy(); + + // Wait longer than timeout + await wait(15); + + // Should not have emitted anything + expect(emittedSequences).toEqual([]); + }); + }); +}); diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index e4291d5cf..ed29e93c8 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -55,6 +55,11 @@ export class VirtualTerminal implements Terminal { return this._rows; } + get kittyProtocolActive(): boolean { + // Virtual terminal always reports Kitty protocol as active for testing + return true; + } + moveBy(lines: number): void { if (lines > 0) { // Move down diff --git a/packages/tui/test/wrap-ansi.test.ts b/packages/tui/test/wrap-ansi.test.ts index 6b26e7d7b..5ee639656 100644 --- a/packages/tui/test/wrap-ansi.test.ts +++ b/packages/tui/test/wrap-ansi.test.ts @@ -11,14 +11,24 @@ describe("wrapTextWithAnsi", () => { const wrapped = wrapTextWithAnsi(text, 40); - // First line should NOT contain underline code - it's just "read this thread " - expect(wrapped[0]).toBe("read this thread "); + // First line should NOT contain underline code - it's just "read this thread" + expect(wrapped[0]).toBe("read this thread"); // Second line should start with underline, have URL content expect(wrapped[1].startsWith(underlineOn)).toBe(true); expect(wrapped[1].includes("https://")).toBe(true); }); + it("should not have whitespace before underline reset code", () => { + const underlineOn = "\x1b[4m"; + const underlineOff = "\x1b[24m"; + const textWithUnderlinedTrailingSpace = `${underlineOn}underlined text here ${underlineOff}more`; + + const wrapped = wrapTextWithAnsi(textWithUnderlinedTrailingSpace, 18); + + expect(wrapped[0].includes(` ${underlineOff}`)).toBe(false); + }); + it("should not bleed underline to padding - each line should end with reset for underline only", () => { const underlineOn = "\x1b[4m"; const underlineOff = "\x1b[24m"; @@ -100,6 +110,11 @@ describe("wrapTextWithAnsi", () => { } }); + it("should truncate trailing whitespace that exceeds width", () => { + const twoSpacesWrappedToWidth1 = wrapTextWithAnsi(" ", 1); + expect(visibleWidth(twoSpacesWrappedToWidth1[0]) <= 1).toBeTruthy(); + }); + it("should preserve color codes across wraps", () => { const red = "\x1b[31m"; const reset = "\x1b[0m";