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:
@@ -39,3 +39,5 @@ out.html
|
||||
.claude/*
|
||||
!.claude/commands/
|
||||
packages/ai/test/.temp-images/
|
||||
|
||||
changes/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,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";
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user