chore: merged in upstream changes

- Added ExtensionRuntime shared state with async extension factory support
- Introduced pluggable tool operations (BashOperations, FileOperations, etc.) for remote execution
- Added new CLI flags: --no-tools, --no-extensions, --no-skills
- Added login dialog, countdown timer, and component barrel export
- Added blockImages and thinkingBudgets settings
- Refactored stdin handling with StdinBuffer for improved key parsing
- Updated DEVELOPMENT.md with new architecture documentation
This commit is contained in:
can1357
2026-01-10 06:23:28 +01:00
parent 6847eb2f11
commit 100061accb
118 changed files with 5667 additions and 1311 deletions
+2
View File
@@ -39,3 +39,5 @@ out.html
.claude/*
!.claude/commands/
packages/ai/test/.temp-images/
changes/
+98
View File
@@ -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
+10 -1
View File
@@ -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).
+47 -1
View File
@@ -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> | 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> | string | undefined;
private getToolContext?: () => AgentToolContext | undefined;
private runningPrompt?: Promise<void>;
@@ -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,
+79 -1
View File
@@ -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<AssistantMessageEvent, AssistantMessage> {
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 });
});
});
+31
View File
@@ -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
+38 -2
View File
@@ -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
+1 -1
View File
@@ -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";
+17 -5
View File
@@ -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<string, string> = {
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
@@ -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<OpenAICompat> {
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<OpenAICompat> {
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:
@@ -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,
}));
}
+8 -3
View File
@@ -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;
}
+2
View File
@@ -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. */
+155 -44
View File
@@ -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<void> {
}
/**
* 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<string, string>,
onProgress?: (message: string) => void,
): Promise<LongRunningOperationResponse> {
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<string> {
// 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<string, unknown> = {
tierId,
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
};
if (tierId !== TIER_FREE && envProjectId) {
onboardBody.cloudaicompanionProject = envProjectId;
(onboardBody.metadata as Record<string, unknown>).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",
);
}
+4 -4
View File
@@ -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([
+7 -7
View File
@@ -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([
+4 -4
View File
@@ -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([
@@ -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", () => {
+3 -3
View File
@@ -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({
+3 -3
View File
@@ -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);
+4 -4
View File
@@ -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([
+3 -3
View File
@@ -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");
@@ -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 () => {
+2 -2
View File
@@ -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;
+3 -3
View File
@@ -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";
+8 -8
View File
@@ -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<TApi extends Api>(model: Model<TApi>, 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);
}
}
}
+4 -4
View File
@@ -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([
@@ -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", () => {
@@ -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([
+4 -4
View File
@@ -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([
+4 -4
View File
@@ -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({});
+3 -3
View File
@@ -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 {
+70
View File
@@ -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/<model-id>` 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 <search>` 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
+89 -1
View File
@@ -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 <id>`: 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:
+8
View File
@@ -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<string, { type: "
result.sessionDir = args[++i];
} else if (arg === "--models" && i + 1 < args.length) {
result.models = args[++i].split(",").map((s) => 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<string, { type: "
} else if ((arg === "--extension" || arg === "-e") && i + 1 < args.length) {
result.extensions = result.extensions ?? [];
result.extensions.push(args[++i]);
} else if (arg === "--no-extensions") {
result.noExtensions = true;
} else if (arg === "--no-skills") {
result.noSkills = true;
} else if (arg === "--skills" && i + 1 < args.length) {
@@ -189,11 +195,13 @@ ${chalk.bold("Options:")}
--no-session Don't save session (ephemeral)
--models <patterns> Comma-separated model patterns for Ctrl+P cycling
Supports globs (anthropic/*, *sonnet*) and fuzzy matching
--no-tools Disable all built-in tools
--tools <tools> Comma-separated list of tools to enable (default: read,bash,edit,write)
Available: read, bash, edit, write, grep, find, ls
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh
--hook <path> Load a hook/extension file (can be used multiple times)
--extension, -e <path> 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 <patterns> Comma-separated glob patterns to filter skills (e.g., git-*,docker)
--export <file> Export session file to HTML and exit
+5 -15
View File
@@ -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");
+190 -47
View File
@@ -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<SkillsSettings>;
@@ -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<void> {
@@ -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<void> {
// 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<SkillsSettings> | 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<BashResult> {
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();
+16 -3
View File
@@ -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<string>;
onProgress?: (message: string) => void;
/** For providers with local callback servers (e.g., openai-codex), races with browser callback */
onManualCodeInput?: () => Promise<string>;
/** For cancellation support (e.g., github-copilot polling) */
signal?: AbortSignal;
},
): Promise<void> {
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}`);
+79 -14
View File
@@ -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<Uint8Array, string> {
});
}
async function pumpStream(readable: ReadableStream<Uint8Array>, writer: WritableStreamDefaultWriter<string>) {
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<Uint8Array>) {
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<Uint8Array>),
pumpStream(child.stderr as ReadableStream<Uint8Array>),
pumpStream(child.stdout as ReadableStream<Uint8Array>, writer),
pumpStream(child.stderr as ReadableStream<Uint8Array>, 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<BashResult> {
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<Uint8Array>;
const dataStream = new ReadableStream<Uint8Array>({
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;
}
}
@@ -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");
}
/**
@@ -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");
}
/**
@@ -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<CodexInjectionInfo | undefined> {
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<SessionManager["getEntries"]>;
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);
@@ -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);
@@ -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=<leafId>&targetId=<entryId>
*/
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 `<button class="copy-link-btn" data-entry-id="${entryId}" title="Copy link to this message">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</button>`;
}
function renderEntry(entry) {
const ts = formatTimestamp(entry.timestamp);
const tsHtml = ts ? `<div class="message-timestamp">${ts}</div>` : '';
const entryId = `entry-${entry.id}`;
const copyBtnHtml = renderCopyLinkButton(entry.id);
if (entry.type === 'message') {
const msg = entry.message;
if (msg.role === 'user') {
let html = `<div class="user-message" id="${entryId}">${tsHtml}`;
let html = `<div class="user-message" id="${entryId}">${copyBtnHtml}${tsHtml}`;
const content = msg.content;
if (Array.isArray(content)) {
@@ -810,7 +906,7 @@
}
if (msg.role === 'assistant') {
let html = `<div class="assistant-message" id="${entryId}">${tsHtml}`;
let html = `<div class="assistant-message" id="${entryId}">${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 `<div class="model-change" id="${entryId}">${tsHtml}Switched to model: <span class="model-name">${escapeHtml(entry.provider)}/${escapeHtml(entry.modelId)}</span></div>`;
let html = `<div class="model-change" id="${entryId}">${tsHtml}Switched to model: <span class="model-name">${escapeHtml(entry.provider)}/${escapeHtml(entry.modelId)}</span>`;
if (entry.provider === 'openai-codex' && codexInjectionInfo) {
const fullContent = `# Codex Instructions\n${codexInjectionInfo.instructions}\n\n# Codex-Pi Bridge\n${codexInjectionInfo.bridge}`;
html += ` <span class="codex-bridge-toggle" onclick="event.stopPropagation(); this.parentElement.classList.toggle('show-bridge')">[bridge prompt]</span>`;
html += `<div class="codex-bridge-content"><pre>${escapeHtml(fullContent)}</pre></div>`;
}
html += '</div>';
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');
@@ -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
@@ -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<unknown>;
/**
* 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<unknown>;
/**
* Create the ExtensionAPI for an extension.
* Registration methods write to the extension object.
* Action methods delegate to the shared runtime.
*/
function createExtensionAPI(
handlers: Map<string, HandlerFn[]>,
tools: Map<string, RegisteredTool>,
extension: Extension,
runtime: ExtensionRuntime,
cwd: string,
extensionPath: string,
eventBus: EventBus,
_sharedUI: { ui: ExtensionUIContext; hasUI: boolean },
): {
api: ExtensionAPI;
messageRenderers: Map<string, MessageRenderer>;
commands: Map<string, RegisteredCommand>;
flags: Map<string, ExtensionFlag>;
flagValues: Map<string, boolean | string>;
shortcuts: Map<KeyId, ExtensionShortcut>;
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<string, MessageRenderer>();
const commands = new Map<string, RegisteredCommand>();
const flags = new Map<string, ExtensionFlag>();
const flagValues = new Map<string, boolean | string>();
const shortcuts = new Map<KeyId, ExtensionShortcut>();
): 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;
},
): 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<T>(customType: string, renderer: MessageRenderer<T>): void {
extension.messageRenderers.set(customType, renderer as MessageRenderer);
},
registerMessageRenderer<T>(customType: string, renderer: MessageRenderer<T>): 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<string, HandlerFn[]>();
const tools = new Map<string, RegisteredTool>();
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 = "<inline>",
): LoadedExtension {
const handlers = new Map<string, HandlerFn[]>();
const tools = new Map<string, RegisteredTool>();
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<Extension> {
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<LoadExtensionsResult> {
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);
@@ -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<BeforeAgentStartEventResult["message"]>[];
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<boolean> {
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<any> | undefined;
sendMessageHandler: SendMessageHandler;
appendEntryHandler: AppendEntryHandler;
getActiveToolsHandler: GetActiveToolsHandler;
getAllToolsHandler: GetAllToolsHandler;
setActiveToolsHandler: SetActiveToolsHandler;
newSessionHandler?: NewSessionHandler;
branchHandler?: BranchHandler;
navigateTreeHandler?: NavigateTreeHandler;
isIdle?: () => boolean;
waitForIdle?: () => Promise<void>;
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<UserBashEventResult | undefined> {
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<AgentMessage[]> {
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<BeforeAgentStartCombinedResult | undefined> {
const ctx = this.createContext();
const messages: NonNullable<BeforeAgentStartEventResult["message"]>[] = [];
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,
};
}
@@ -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<string | undefined>;
select(title: string, options: string[], dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
/** Show a confirmation dialog. */
confirm(title: string, message: string): Promise<boolean>;
confirm(title: string, message: string, dialogOptions?: ExtensionUIDialogOptions): Promise<boolean>;
/** Show a text input dialog. */
input(title: string, placeholder?: string): Promise<string | undefined>;
input(title: string, placeholder?: string, dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
/** 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<Component & { dispose?(): void }>,
options?: { overlay?: boolean },
): Promise<T>;
/** 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<CustomMessage, "customType" | "content" | "display" | "details">;
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<TurnEndEvent>): void;
on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): 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<T = unknown>(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<any>): Promise<boolean>;
/** 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<void>;
// ============================================================================
// Loaded Extension Types
@@ -666,6 +734,11 @@ export type SendMessageHandler = <T = unknown>(
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
) => void;
export type SendUserMessageHandler = (
content: string | (TextContent | ImageContent)[],
options?: { deliverAs?: "steer" | "followUp" },
) => void;
export type AppendEntryHandler = <T = unknown>(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<any>) => Promise<boolean>;
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<string, boolean | string>;
}
/** 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<any> | undefined;
isIdle: () => boolean;
abort: () => void;
hasPendingMessages: () => boolean;
shutdown: () => void;
}
/** Actions for ExtensionCommandContext (ctx.* in command handlers). */
export interface ExtensionCommandContextActions {
waitForIdle: () => Promise<void>;
newSession: (options?: {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}) => 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<string, HandlerFn[]>;
@@ -683,21 +803,14 @@ export interface LoadedExtension {
messageRenderers: Map<string, MessageRenderer>;
commands: Map<string, RegisteredCommand>;
flags: Map<string, ExtensionFlag>;
flagValues: Map<string, boolean | string>;
shortcuts: Map<KeyId, ExtensionShortcut>;
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;
}
// ============================================================================
@@ -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));
}
/**
@@ -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");
}
/**
+2 -1
View File
@@ -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";
@@ -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<AppAction, KeyId | KeyId[]> = {
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 {
@@ -384,7 +384,7 @@ export class ModelRegistry {
* Find a model by provider and ID.
*/
find(provider: string, modelId: string): Model<Api> | undefined {
return this.models.find((m) => m.provider === provider && m.id === modelId) ?? undefined;
return this.models.find((m) => m.provider === provider && m.id === modelId);
}
/**
@@ -25,12 +25,13 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
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<Api>;
thinkingLevel: ThinkingLevel;
explicitThinkingLevel?: boolean;
}
/** Priority chain for auto-discovering smol/fast models */
@@ -124,6 +125,7 @@ export interface ParsedModelResult {
model: Model<Api> | undefined;
thinkingLevel: ThinkingLevel;
warning: string | undefined;
explicitThinkingLevel: boolean;
}
/**
@@ -143,14 +145,14 @@ export function parseModelPattern(pattern: string, availableModels: Model<Api>[]
// 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<Api>[]
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<Api>[]
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,
};
}
+96 -76
View File
@@ -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<LoadExtensionsRe
/**
* Discover skills from cwd and agentDir.
*/
export function discoverSkills(cwd?: string, _agentDir?: string, settings?: SkillsSettings): Skill[] {
const { skills } = loadSkillsInternal({
export function discoverSkills(
cwd?: string,
_agentDir?: string,
settings?: SkillsSettings,
): { skills: Skill[]; warnings: SkillWarning[] } {
return loadSkillsInternal({
...settings,
cwd: cwd ?? process.cwd(),
});
return skills;
}
/**
@@ -380,6 +388,7 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
extensions: manager.getExtensionPaths(),
skills: manager.getSkillsSettings(),
terminal: { showImages: manager.getShowImages() },
images: { autoResize: manager.getImageAutoResize(), blockImages: manager.getBlockImages() },
};
}
@@ -614,7 +623,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
thinkingLevel = "off";
}
const skills = options.skills ?? discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
let skills: Skill[];
let skillWarnings: SkillWarning[];
if (options.skills !== undefined) {
skills = options.skills;
skillWarnings = [];
} else {
const discovered = discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings());
skills = discovered.skills;
skillWarnings = discovered.warnings;
}
time("discoverSkills");
// Discover rules
@@ -723,12 +741,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Load extensions (discovers from standard locations + configured paths)
let extensionsResult: LoadExtensionsResult;
if (options.preloadedExtensions !== undefined && options.preloadedExtensions.length > 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, `<inline-${i}>`);
const loaded = await loadExtensionFromFactory(
factory,
cwd,
eventBus,
extensionsResult.runtime,
`<inline-${i}>`,
);
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: "<sdk>" };
}) ?? []),
];
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<string, AgentTool>();
@@ -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,
@@ -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<string, string>;
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;
}
@@ -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);
+43 -5
View File
@@ -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<typeof bashSchema> {
/**
* 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<typeof bashSchema> {
return {
name: "bash",
label: "Bash",
@@ -53,11 +75,22 @@ export function createBashTool(session: ToolSession): AgentTool<typeof bashSchem
}
}
const commandCwd = workdir ? resolveToCwd(workdir, session.cwd) : session.cwd;
let cwdStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
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<typeof bashSchem
});
}
},
});
};
// Use custom operations if provided, otherwise use default local executor
const result = options?.operations
? await executeBashWithOperations(command, commandCwd, options.operations, executorOptions)
: await executeBash(command, executorOptions);
// Handle errors
if (result.cancelled) {
+23 -6
View File
@@ -17,11 +17,20 @@ import {
restoreLineEndings,
stripBom,
} from "./edit-diff";
import type { ToolSession } from "./index";
import type { FileOperations, ToolSession } from "./index";
import { createLspWritethrough, type FileDiagnosticsResult } from "./lsp/index";
import { resolveToCwd } from "./path-utils";
import { createToolUIKit, getDiffStats, shortenPath, truncateDiffByHunk } from "./render-utils";
/** Default file operations using Bun APIs */
const defaultFileOperations: FileOperations = {
readFile: async (path) => 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<typeof editSchem
const enableDiagnostics = session.settings?.getLspDiagnosticsOnEdit() ?? false;
const enableFormat = session.settings?.getLspFormatOnWrite() ?? true;
const writethrough = createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics });
const ops = session.fileOperations ?? defaultFileOperations;
return {
name: "edit",
label: "Edit",
@@ -62,12 +73,11 @@ export function createEditTool(session: ToolSession): AgentTool<typeof editSchem
const absolutePath = resolveToCwd(path, session.cwd);
const file = Bun.file(absolutePath);
if (!(await file.exists())) {
if (!(await ops.exists(absolutePath))) {
throw new Error(`File not found: ${path}`);
}
const rawContent = await file.text();
const rawContent = await ops.readFile(absolutePath);
// Strip BOM before matching (LLM won't include invisible BOM in oldText)
const { bom, text: content } = stripBom(rawContent);
@@ -159,7 +169,14 @@ export function createEditTool(session: ToolSession): AgentTool<typeof editSchem
}
const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding);
const diagnostics = await writethrough(absolutePath, finalContent, signal, file);
// Use writethrough for local operations (LSP formatting/diagnostics), direct write for custom ops
let diagnostics: FileDiagnosticsResult | undefined;
if (session.fileOperations) {
await ops.writeFile(absolutePath, finalContent);
} else {
diagnostics = await writethrough(absolutePath, finalContent, signal);
}
const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
@@ -170,7 +187,7 @@ export function createEditTool(session: ToolSession): AgentTool<typeof editSchem
: `Successfully replaced text in ${path}.`;
const messages = diagnostics?.messages;
if (messages && messages.length > 0) {
if (diagnostics && messages && messages.length > 0) {
resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`;
resultText += messages.map((d) => ` ${d}`).join("\n");
}
+86 -7
View File
@@ -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> | boolean;
/** Find files matching glob pattern. Returns relative paths. */
glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise<string[]> | 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<typeof findSchema> {
export function createFindTool(session: ToolSession, options?: FindToolOptions): AgentTool<typeof findSchema> {
const customOps = options?.operations;
return {
name: "find",
label: "Find",
@@ -117,12 +135,6 @@ export function createFindTool(session: ToolSession): AgentTool<typeof findSchem
signal?: AbortSignal,
) => {
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<typeof findSchem
const includeHidden = hidden ?? false;
const shouldSortByMtime = sortByMtime ?? false;
// If custom operations provided with glob, use that instead of fd
if (customOps?.glob) {
if (!(await customOps.exists(searchPath))) {
throw new Error(`Path not found: ${searchPath}`);
}
const results = await customOps.glob(pattern, searchPath, {
ignore: ["**/node_modules/**", "**/.git/**"],
limit: effectiveLimit,
});
if (results.length === 0) {
return {
content: [{ type: "text", text: "No files found matching pattern" }],
details: { scopePath, fileCount: 0, files: [], truncated: false },
};
}
// Relativize paths
const relativized = results.map((p) => {
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.
+27 -6
View File
@@ -70,7 +70,29 @@ export interface GrepToolDetails {
error?: string;
}
export function createGrepTool(session: ToolSession): AgentTool<typeof grepSchema> {
/**
* 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> | boolean;
/** Read file contents for context lines */
readFile: (absolutePath: string) => Promise<string> | 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<typeof grepSchema> {
const ops = options?.operations ?? defaultGrepOperations;
return {
name: "grep",
label: "Grep",
@@ -120,14 +142,13 @@ export function createGrepTool(session: ToolSession): AgentTool<typeof grepSchem
const relative = nodePath.relative(session.cwd, searchPath).replace(/\\/g, "/");
return relative.length === 0 ? "." : relative;
})();
let searchStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
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<typeof grepSchem
if (!linesPromise) {
linesPromise = (async () => {
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 [];
+31 -7
View File
@@ -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<any, any, any>;
/**
* 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<string>;
/** Write content to a file */
writeFile: (absolutePath: string, content: string) => Promise<void>;
/** Check if file exists */
exists: (absolutePath: string) => Promise<boolean>;
}
/** 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<Tool | null>;
@@ -151,7 +175,7 @@ export type ToolName = keyof typeof BUILTIN_TOOLS;
*/
export async function createTools(session: ToolSession, toolNames?: string[]): Promise<Tool[]> {
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<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
if (includeComplete && requestedTools && !requestedTools.includes("complete")) {
requestedTools.push("complete");
+49 -18
View File
@@ -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<string[]>;
}
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<typeof lsSchema> {
/** 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<typeof lsSchema> {
const ops = options?.operations ?? defaultLsOperations;
return {
name: "ls",
label: "Ls",
@@ -53,10 +86,8 @@ export function createLsTool(session: ToolSession): AgentTool<typeof lsSchema> {
const effectiveLimit = limit ?? DEFAULT_LIMIT;
// Check if path exists and is a directory
let dirStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
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<typeof lsSchema> {
// 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<typeof lsSchema> {
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);
@@ -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)}`;
}
@@ -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}`);
});
+62 -7
View File
@@ -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";
+57 -13
View File
@@ -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<SessionM
return SessionManager.inMemory();
}
if (parsed.session) {
return await SessionManager.open(parsed.session, parsed.sessionDir);
const resolvedPath = resolveSessionPath(parsed.session, cwd, parsed.sessionDir);
return await SessionManager.open(resolvedPath, parsed.sessionDir);
}
if (parsed.continue) {
return await SessionManager.continueRecent(cwd, parsed.sessionDir);
@@ -299,13 +322,22 @@ async function buildSessionOptions(
// Thinking level
if (parsed.thinking) {
options.thinkingLevel = parsed.thinking;
} else if (scopedModels.length > 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<void>((resolve) => process.stdout.once("drain", resolve));
+47 -2
View File
@@ -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[] = [];
+1 -1
View File
@@ -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";
@@ -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));
@@ -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
@@ -21,6 +21,11 @@ export class BranchSummaryMessageComponent extends Box {
this.updateDisplay();
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();
}
private updateDisplay(): void {
this.clear();
@@ -21,6 +21,11 @@ export class CompactionSummaryMessageComponent extends Box {
this.updateDisplay();
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();
}
private updateDisplay(): void {
this.clear();
@@ -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<typeof setInterval> | 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;
}
}
}
@@ -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<boolean>;
/** Called when Alt+Up is pressed (dequeue keybinding). */
public onAltUp?: () => void;
/** Custom key handlers from extensions */
private customKeyHandlers = new Map<KeyId, () => 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();
@@ -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) {
@@ -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)}`;
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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";
@@ -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<string> {
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<string> {
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);
}
}
@@ -93,7 +93,7 @@ export class ModelSelectorComponent extends Container {
scopedModels: ReadonlyArray<ScopedModelItem>,
onSelect: (model: Model<any>, 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();
});
@@ -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));
}
@@ -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",
@@ -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)}`;
@@ -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
@@ -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, unknown>): 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;
};
@@ -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<string>();
// 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<void> {
// 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 <T>(
_factory: (
tui: TUI,
theme: Theme,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
) => 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<Component & { dispose?(): void }>,
): Promise<T> {
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<void> {
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 {
@@ -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 = "<in-memory>";
stopThemeWatcher();
if (onThemeChangeCallback) {
onThemeChangeCallback();
}
}
/**
* Set the symbol preset override, recreating the theme with the new preset.
*/
+78 -31
View File
@@ -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<void> {
// 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<void> {
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`);
}
}
}
+180 -76
View File
@@ -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<never> {
const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => {
console.log(JSON.stringify(obj));
process.stdout.write(`${JSON.stringify(obj)}\n`);
};
const success = <T extends RpcCommand["type"]>(
@@ -57,71 +57,101 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
};
// 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<string, PendingExtensionRequest>();
// 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<T>(
opts: ExtensionUIDialogOptions | undefined,
defaultValue: T,
request: Record<string, unknown>,
parseResponse: (response: RpcExtensionUIResponse) => T,
): Promise<T> {
if (opts?.signal?.aborted) return Promise.resolve(defaultValue);
const id = nanoid();
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | 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<string | undefined> {
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<boolean> {
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<string | undefined> {
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<never> {
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<never> {
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<never> {
}
};
/**
* 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<void> {
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<never> {
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<never> {
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}`));
}
@@ -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";
@@ -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.
<critical>
Keep working until the user's task is fully resolved. Use tools to verify—never guess.
</critical>
<environment>
{{environmentInfo}}
</environment>
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
<tools>
{{toolsList}}
</tools>
{{antiBashSection}}
<guidelines>
{{guidelines}}
</guidelines>
Final reminder: Complete the full user request before ending your turn.
<instructions>
## 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.
</instructions>
<critical>
Complete the full user request before ending your turn. This matters.
</critical>
+90 -50
View File
@@ -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<void> {
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<Buffer | null> {
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Clipboard operation timed out")), timeoutMs);
});
let proc: ReturnType<typeof Bun.spawn> | null = null;
try {
proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Clipboard operation timed out")), timeoutMs);
});
const stdoutStream = proc.stdout as ReadableStream<Uint8Array>;
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<Buffer |
} catch {
return null;
} finally {
proc.kill();
proc?.kill();
}
}
@@ -56,6 +88,19 @@ export async function copyToClipboard(text: string): Promise<void> {
} 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<void> {
} 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<ClipboardImage | null> {
}
async function readImageLinux(timeout: number): Promise<ClipboardImage | null> {
// 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<ClipboardImage | null> {
// 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<ClipboardImage | null> {
// 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<ClipboardImage | null> {
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) {
@@ -1,4 +1,4 @@
import { convertToPngWithImageMagick } from "./image-magick.js";
import { convertToPngWithImageMagick } from "./image-magick";
/**
* Convert image to PNG format for terminal display.
@@ -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
@@ -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;
+33
View File
@@ -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"]);
@@ -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!");
});
});
});
@@ -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);
});
});

Some files were not shown because too many files have changed in this diff Show More