Merge PR #382: word wrapping in Editor component

This commit is contained in:
Mario Zechner 2026-01-03 00:48:28 +01:00
commit 2b054cdb7c
150 changed files with 10122 additions and 4285 deletions

View file

@ -4,6 +4,26 @@
### Breaking Changes
- **Queue API replaced with steer/followUp**: The `queueMessage()` method has been split into two methods with different delivery semantics ([#403](https://github.com/badlogic/pi-mono/issues/403)):
- `steer(msg)`: Interrupts the agent mid-run. Delivered after current tool execution, skips remaining tools.
- `followUp(msg)`: Waits until the agent finishes. Delivered only when there are no more tool calls or steering messages.
- **Queue mode renamed**: `queueMode` option renamed to `steeringMode`. Added new `followUpMode` option. Both control whether messages are delivered one-at-a-time or all at once.
- **AgentLoopConfig callbacks renamed**: `getQueuedMessages` split into `getSteeringMessages` and `getFollowUpMessages`.
- **Agent methods renamed**:
- `queueMessage()``steer()` and `followUp()`
- `clearMessageQueue()``clearSteeringQueue()`, `clearFollowUpQueue()`, `clearAllQueues()`
- `setQueueMode()`/`getQueueMode()``setSteeringMode()`/`getSteeringMode()` and `setFollowUpMode()`/`getFollowUpMode()`
### Fixed
- `prompt()` and `continue()` now throw if called while the agent is already streaming, preventing race conditions and corrupted state. Use `steer()` or `followUp()` to queue messages during streaming, or `await` the previous call.
## [0.31.1] - 2026-01-02
## [0.31.0] - 2026-01-02
### Breaking Changes
- **Transport abstraction removed**: `ProviderTransport`, `AppTransport`, and `AgentTransport` interface have been removed. Use the `streamFn` option directly for custom streaming implementations.
- **Agent options renamed**:

View file

@ -298,6 +298,22 @@ const readFileTool: AgentTool = {
agent.setTools([readFileTool]);
```
### Error Handling
**Throw an error** when a tool fails. Do not return error messages as content.
```typescript
execute: async (toolCallId, params, signal, onUpdate) => {
if (!fs.existsSync(params.path)) {
throw new Error(`File not found: ${params.path}`);
}
// Return content only on success
return { content: [{ type: "text", text: "..." }] };
}
```
Thrown errors are caught by the agent and reported to the LLM as tool errors with `isError: true`.
## Proxy Usage
For browser apps that proxy through a backend:

View file

@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-agent-core",
"version": "0.30.2",
"version": "0.31.1",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
@ -17,8 +17,8 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@mariozechner/pi-ai": "^0.30.2",
"@mariozechner/pi-tui": "^0.30.2"
"@mariozechner/pi-ai": "^0.31.1",
"@mariozechner/pi-tui": "^0.31.1"
},
"keywords": [
"ai",

View file

@ -109,71 +109,88 @@ async function runLoop(
stream: EventStream<AgentEvent, AgentMessage[]>,
streamFn?: StreamFn,
): Promise<void> {
let hasMoreToolCalls = true;
let firstTurn = true;
let queuedMessages: AgentMessage[] = (await config.getQueuedMessages?.()) || [];
let queuedAfterTools: AgentMessage[] | null = null;
// Check for steering messages at start (user may have typed while waiting)
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
while (hasMoreToolCalls || queuedMessages.length > 0) {
if (!firstTurn) {
stream.push({ type: "turn_start" });
} else {
firstTurn = false;
}
// Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) {
let hasMoreToolCalls = true;
let steeringAfterTools: AgentMessage[] | null = null;
// Process queued messages (inject before next assistant response)
if (queuedMessages.length > 0) {
for (const message of queuedMessages) {
stream.push({ type: "message_start", message });
stream.push({ type: "message_end", message });
currentContext.messages.push(message);
newMessages.push(message);
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
if (!firstTurn) {
stream.push({ type: "turn_start" });
} else {
firstTurn = false;
}
queuedMessages = [];
}
// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
newMessages.push(message);
// Process pending messages (inject before next assistant response)
if (pendingMessages.length > 0) {
for (const message of pendingMessages) {
stream.push({ type: "message_start", message });
stream.push({ type: "message_end", message });
currentContext.messages.push(message);
newMessages.push(message);
}
pendingMessages = [];
}
if (message.stopReason === "error" || message.stopReason === "aborted") {
stream.push({ type: "turn_end", message, toolResults: [] });
stream.push({ type: "agent_end", messages: newMessages });
stream.end(newMessages);
return;
}
// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
newMessages.push(message);
// Check for tool calls
const toolCalls = message.content.filter((c) => c.type === "toolCall");
hasMoreToolCalls = toolCalls.length > 0;
if (message.stopReason === "error" || message.stopReason === "aborted") {
stream.push({ type: "turn_end", message, toolResults: [] });
stream.push({ type: "agent_end", messages: newMessages });
stream.end(newMessages);
return;
}
const toolResults: ToolResultMessage[] = [];
if (hasMoreToolCalls) {
const toolExecution = await executeToolCalls(
currentContext.tools,
message,
signal,
stream,
config.getQueuedMessages,
);
toolResults.push(...toolExecution.toolResults);
queuedAfterTools = toolExecution.queuedMessages ?? null;
// Check for tool calls
const toolCalls = message.content.filter((c) => c.type === "toolCall");
hasMoreToolCalls = toolCalls.length > 0;
for (const result of toolResults) {
currentContext.messages.push(result);
newMessages.push(result);
const toolResults: ToolResultMessage[] = [];
if (hasMoreToolCalls) {
const toolExecution = await executeToolCalls(
currentContext.tools,
message,
signal,
stream,
config.getSteeringMessages,
);
toolResults.push(...toolExecution.toolResults);
steeringAfterTools = toolExecution.steeringMessages ?? null;
for (const result of toolResults) {
currentContext.messages.push(result);
newMessages.push(result);
}
}
stream.push({ type: "turn_end", message, toolResults });
// Get steering messages after turn completes
if (steeringAfterTools && steeringAfterTools.length > 0) {
pendingMessages = steeringAfterTools;
steeringAfterTools = null;
} else {
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
}
stream.push({ type: "turn_end", message, toolResults });
// Get queued messages after turn completes
if (queuedAfterTools && queuedAfterTools.length > 0) {
queuedMessages = queuedAfterTools;
queuedAfterTools = null;
} else {
queuedMessages = (await config.getQueuedMessages?.()) || [];
// Agent would stop here. Check for follow-up messages.
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
// Set as pending so inner loop processes them
pendingMessages = followUpMessages;
continue;
}
// No more messages, exit
break;
}
stream.push({ type: "agent_end", messages: newMessages });
@ -279,11 +296,11 @@ async function executeToolCalls(
assistantMessage: AssistantMessage,
signal: AbortSignal | undefined,
stream: EventStream<AgentEvent, AgentMessage[]>,
getQueuedMessages?: AgentLoopConfig["getQueuedMessages"],
): Promise<{ toolResults: ToolResultMessage[]; queuedMessages?: AgentMessage[] }> {
getSteeringMessages?: AgentLoopConfig["getSteeringMessages"],
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
const results: ToolResultMessage[] = [];
let queuedMessages: AgentMessage[] | undefined;
let steeringMessages: AgentMessage[] | undefined;
for (let index = 0; index < toolCalls.length; index++) {
const toolCall = toolCalls[index];
@ -343,11 +360,11 @@ async function executeToolCalls(
stream.push({ type: "message_start", message: toolResultMessage });
stream.push({ type: "message_end", message: toolResultMessage });
// Check for queued messages - skip remaining tools if user interrupted
if (getQueuedMessages) {
const queued = await getQueuedMessages();
if (queued.length > 0) {
queuedMessages = queued;
// Check for steering messages - skip remaining tools if user interrupted
if (getSteeringMessages) {
const steering = await getSteeringMessages();
if (steering.length > 0) {
steeringMessages = steering;
const remainingCalls = toolCalls.slice(index + 1);
for (const skipped of remainingCalls) {
results.push(skipToolCall(skipped, stream));
@ -357,7 +374,7 @@ async function executeToolCalls(
}
}
return { toolResults: results, queuedMessages };
return { toolResults: results, steeringMessages };
}
function skipToolCall(

View file

@ -47,9 +47,14 @@ export interface AgentOptions {
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
/**
* Queue mode: "all" = send all queued messages at once, "one-at-a-time" = one per turn
* Steering mode: "all" = send all steering messages at once, "one-at-a-time" = one per turn
*/
queueMode?: "all" | "one-at-a-time";
steeringMode?: "all" | "one-at-a-time";
/**
* Follow-up mode: "all" = send all follow-up messages at once, "one-at-a-time" = one per turn
*/
followUpMode?: "all" | "one-at-a-time";
/**
* Custom stream function (for proxy backends, etc.). Default uses streamSimple.
@ -80,8 +85,10 @@ export class Agent {
private abortController?: AbortController;
private convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
private transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
private messageQueue: AgentMessage[] = [];
private queueMode: "all" | "one-at-a-time";
private steeringQueue: AgentMessage[] = [];
private followUpQueue: AgentMessage[] = [];
private steeringMode: "all" | "one-at-a-time";
private followUpMode: "all" | "one-at-a-time";
public streamFn: StreamFn;
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
private runningPrompt?: Promise<void>;
@ -91,7 +98,8 @@ export class Agent {
this._state = { ...this._state, ...opts.initialState };
this.convertToLlm = opts.convertToLlm || defaultConvertToLlm;
this.transformContext = opts.transformContext;
this.queueMode = opts.queueMode || "one-at-a-time";
this.steeringMode = opts.steeringMode || "one-at-a-time";
this.followUpMode = opts.followUpMode || "one-at-a-time";
this.streamFn = opts.streamFn || streamSimple;
this.getApiKey = opts.getApiKey;
}
@ -118,12 +126,20 @@ export class Agent {
this._state.thinkingLevel = l;
}
setQueueMode(mode: "all" | "one-at-a-time") {
this.queueMode = mode;
setSteeringMode(mode: "all" | "one-at-a-time") {
this.steeringMode = mode;
}
getQueueMode(): "all" | "one-at-a-time" {
return this.queueMode;
getSteeringMode(): "all" | "one-at-a-time" {
return this.steeringMode;
}
setFollowUpMode(mode: "all" | "one-at-a-time") {
this.followUpMode = mode;
}
getFollowUpMode(): "all" | "one-at-a-time" {
return this.followUpMode;
}
setTools(t: AgentTool<any>[]) {
@ -138,12 +154,33 @@ export class Agent {
this._state.messages = [...this._state.messages, m];
}
queueMessage(m: AgentMessage) {
this.messageQueue.push(m);
/**
* Queue a steering message to interrupt the agent mid-run.
* Delivered after current tool execution, skips remaining tools.
*/
steer(m: AgentMessage) {
this.steeringQueue.push(m);
}
clearMessageQueue() {
this.messageQueue = [];
/**
* Queue a follow-up message to be processed after the agent finishes.
* Delivered only when agent has no more tool calls or steering messages.
*/
followUp(m: AgentMessage) {
this.followUpQueue.push(m);
}
clearSteeringQueue() {
this.steeringQueue = [];
}
clearFollowUpQueue() {
this.followUpQueue = [];
}
clearAllQueues() {
this.steeringQueue = [];
this.followUpQueue = [];
}
clearMessages() {
@ -164,13 +201,20 @@ export class Agent {
this._state.streamMessage = null;
this._state.pendingToolCalls = new Set<string>();
this._state.error = undefined;
this.messageQueue = [];
this.steeringQueue = [];
this.followUpQueue = [];
}
/** Send a prompt with an AgentMessage */
async prompt(message: AgentMessage | AgentMessage[]): Promise<void>;
async prompt(input: string, images?: ImageContent[]): Promise<void>;
async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]) {
if (this._state.isStreaming) {
throw new Error(
"Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.",
);
}
const model = this._state.model;
if (!model) throw new Error("No model configured");
@ -199,6 +243,10 @@ export class Agent {
/** Continue from current context (for retry after overflow) */
async continue() {
if (this._state.isStreaming) {
throw new Error("Agent is already processing. Wait for completion before continuing.");
}
const messages = this._state.messages;
if (messages.length === 0) {
throw new Error("No messages to continue from");
@ -247,18 +295,32 @@ export class Agent {
convertToLlm: this.convertToLlm,
transformContext: this.transformContext,
getApiKey: this.getApiKey,
getQueuedMessages: async () => {
if (this.queueMode === "one-at-a-time") {
if (this.messageQueue.length > 0) {
const first = this.messageQueue[0];
this.messageQueue = this.messageQueue.slice(1);
getSteeringMessages: async () => {
if (this.steeringMode === "one-at-a-time") {
if (this.steeringQueue.length > 0) {
const first = this.steeringQueue[0];
this.steeringQueue = this.steeringQueue.slice(1);
return [first];
}
return [];
} else {
const queued = this.messageQueue.slice();
this.messageQueue = [];
return queued;
const steering = this.steeringQueue.slice();
this.steeringQueue = [];
return steering;
}
},
getFollowUpMessages: async () => {
if (this.followUpMode === "one-at-a-time") {
if (this.followUpQueue.length > 0) {
const first = this.followUpQueue[0];
this.followUpQueue = this.followUpQueue.slice(1);
return [first];
}
return [];
} else {
const followUp = this.followUpQueue.slice();
this.followUpQueue = [];
return followUp;
}
},
};

View file

@ -75,12 +75,26 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
/**
* Returns queued messages to inject into the conversation.
* Returns steering messages to inject into the conversation mid-run.
*
* Called after each turn to check for user interruptions or injected messages.
* If messages are returned, they're added to the context before the next LLM call.
* Called after each tool execution to check for user interruptions.
* If messages are returned, remaining tool calls are skipped and
* these messages are added to the context before the next LLM call.
*
* Use this for "steering" the agent while it's working.
*/
getQueuedMessages?: () => Promise<AgentMessage[]>;
getSteeringMessages?: () => Promise<AgentMessage[]>;
/**
* Returns follow-up messages to process after the agent would otherwise stop.
*
* Called when the agent has no more tool calls and no steering messages.
* If messages are returned, they're added to the context and the agent
* continues with another turn.
*
* Use this for follow-up messages that should wait until the agent finishes.
*/
getFollowUpMessages?: () => Promise<AgentMessage[]>;
}
/**

View file

@ -340,8 +340,8 @@ describe("agentLoop with AgentMessage", () => {
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
getQueuedMessages: async () => {
// Return queued message after first tool executes
getSteeringMessages: async () => {
// Return steering message after first tool executes
if (executed.length === 1 && !queuedDelivered) {
queuedDelivered = true;
return [queuedUserMessage];

View file

@ -1,7 +1,41 @@
import { getModel } from "@mariozechner/pi-ai";
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { Agent } from "../src/index.js";
// Mock stream that mimics AssistantMessageEventStream
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 createAssistantMessage(text: string): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "openai-responses",
provider: "openai",
model: "mock",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
}
describe("Agent", () => {
it("should create an agent instance with default state", () => {
const agent = new Agent();
@ -93,11 +127,21 @@ describe("Agent", () => {
expect(agent.state.messages).toEqual([]);
});
it("should support message queueing", async () => {
it("should support steering message queue", async () => {
const agent = new Agent();
const message = { role: "user" as const, content: "Queued message", timestamp: Date.now() };
agent.queueMessage(message);
const message = { role: "user" as const, content: "Steering message", timestamp: Date.now() };
agent.steer(message);
// The message is queued but not yet in state.messages
expect(agent.state.messages).not.toContainEqual(message);
});
it("should support follow-up message queue", async () => {
const agent = new Agent();
const message = { role: "user" as const, content: "Follow-up message", timestamp: Date.now() };
agent.followUp(message);
// The message is queued but not yet in state.messages
expect(agent.state.messages).not.toContainEqual(message);
@ -109,4 +153,80 @@ describe("Agent", () => {
// Should not throw even if nothing is running
expect(() => agent.abort()).not.toThrow();
});
it("should throw when prompt() called while streaming", async () => {
let abortSignal: AbortSignal | undefined;
const agent = new Agent({
// Use a stream function that responds to abort
streamFn: (_model, _context, options) => {
abortSignal = options?.signal;
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({ type: "start", partial: createAssistantMessage("") });
// Check abort signal periodically
const checkAbort = () => {
if (abortSignal?.aborted) {
stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") });
} else {
setTimeout(checkAbort, 5);
}
};
checkAbort();
});
return stream;
},
});
// Start first prompt (don't await, it will block until abort)
const firstPrompt = agent.prompt("First message");
// Wait a tick for isStreaming to be set
await new Promise((resolve) => setTimeout(resolve, 10));
expect(agent.state.isStreaming).toBe(true);
// Second prompt should reject
await expect(agent.prompt("Second message")).rejects.toThrow(
"Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.",
);
// Cleanup - abort to stop the stream
agent.abort();
await firstPrompt.catch(() => {}); // Ignore abort error
});
it("should throw when continue() called while streaming", async () => {
let abortSignal: AbortSignal | undefined;
const agent = new Agent({
streamFn: (_model, _context, options) => {
abortSignal = options?.signal;
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({ type: "start", partial: createAssistantMessage("") });
const checkAbort = () => {
if (abortSignal?.aborted) {
stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") });
} else {
setTimeout(checkAbort, 5);
}
};
checkAbort();
});
return stream;
},
});
// Start first prompt
const firstPrompt = agent.prompt("First message");
await new Promise((resolve) => setTimeout(resolve, 10));
expect(agent.state.isStreaming).toBe(true);
// continue() should reject
await expect(agent.continue()).rejects.toThrow(
"Agent is already processing. Wait for completion before continuing.",
);
// Cleanup
agent.abort();
await firstPrompt.catch(() => {});
});
});

View file

@ -2,6 +2,14 @@
## [Unreleased]
### Fixed
- **Gemini CLI rate limit handling**: Added automatic retry with server-provided delay for 429 errors. Parses delay from error messages like "Your quota will reset after 39s" and waits accordingly. Falls back to exponential backoff for other transient errors. ([#370](https://github.com/badlogic/pi-mono/issues/370))
## [0.31.1] - 2026-01-02
## [0.31.0] - 2026-01-02
### Breaking Changes
- **Agent API moved**: All agent functionality (`agentLoop`, `agentLoopContinue`, `AgentContext`, `AgentEvent`, `AgentTool`, `AgentToolResult`, etc.) has moved to `@mariozechner/pi-agent-core`. Import from that package instead of `@mariozechner/pi-ai`.

View file

@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-ai",
"version": "0.30.2",
"version": "0.31.1",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
"main": "./dist/index.js",

View file

@ -3036,8 +3036,8 @@ export const MODELS = {
reasoning: false,
input: ["text"],
cost: {
input: 0.056,
output: 0.224,
input: 0.07,
output: 0.28,
cacheRead: 0,
cacheWrite: 0,
},
@ -3053,8 +3053,8 @@ export const MODELS = {
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.112,
output: 0.448,
input: 0.14,
output: 0.56,
cacheRead: 0,
cacheWrite: 0,
},
@ -3263,7 +3263,7 @@ export const MODELS = {
cacheWrite: 0,
},
contextWindow: 163840,
maxTokens: 163840,
maxTokens: 65536,
} satisfies Model<"openai-completions">,
"deepseek/deepseek-r1-distill-llama-70b": {
id: "deepseek/deepseek-r1-distill-llama-70b",
@ -3563,13 +3563,13 @@ export const MODELS = {
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.04,
output: 0.15,
input: 0.036,
output: 0.064,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 96000,
maxTokens: 96000,
contextWindow: 131072,
maxTokens: 4096,
} satisfies Model<"openai-completions">,
"google/gemma-3-27b-it:free": {
id: "google/gemma-3-27b-it:free",
@ -5297,8 +5297,8 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.039,
output: 0.19,
input: 0.02,
output: 0.09999999999999999,
cacheRead: 0,
cacheWrite: 0,
},
@ -5348,8 +5348,8 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.03,
output: 0.14,
input: 0.016,
output: 0.06,
cacheRead: 0,
cacheWrite: 0,
},
@ -5858,8 +5858,8 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.028,
output: 0.1104,
input: 0.035,
output: 0.13799999999999998,
cacheRead: 0,
cacheWrite: 0,
},
@ -5994,8 +5994,8 @@ export const MODELS = {
reasoning: false,
input: ["text"],
cost: {
input: 0.09,
output: 1.1,
input: 0.06,
output: 0.6,
cacheRead: 0,
cacheWrite: 0,
},
@ -6011,13 +6011,13 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.12,
input: 0.15,
output: 1.2,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 131072,
maxTokens: 32768,
contextWindow: 262144,
maxTokens: 262144,
} satisfies Model<"openai-completions">,
"qwen/qwen3-vl-235b-a22b-instruct": {
id: "qwen/qwen3-vl-235b-a22b-instruct",
@ -6079,8 +6079,8 @@ export const MODELS = {
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.16,
output: 0.7999999999999999,
input: 0.19999999999999998,
output: 1,
cacheRead: 0,
cacheWrite: 0,
},
@ -6096,8 +6096,8 @@ export const MODELS = {
reasoning: false,
input: ["text", "image"],
cost: {
input: 0.064,
output: 0.39999999999999997,
input: 0.08,
output: 0.5,
cacheRead: 0,
cacheWrite: 0,
},
@ -6487,8 +6487,8 @@ export const MODELS = {
reasoning: true,
input: ["text"],
cost: {
input: 0.10400000000000001,
output: 0.6799999999999999,
input: 0.13,
output: 0.85,
cacheRead: 0,
cacheWrite: 0,
},
@ -6521,9 +6521,9 @@ export const MODELS = {
reasoning: true,
input: ["text", "image"],
cost: {
input: 0.48,
output: 1.44,
cacheRead: 0.088,
input: 0.6,
output: 1.7999999999999998,
cacheRead: 0.11,
cacheWrite: 0,
},
contextWindow: 65536,

View file

@ -72,6 +72,83 @@ const ANTIGRAVITY_HEADERS = {
// Counter for generating unique tool call IDs
let toolCallCounter = 0;
// Retry configuration
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000;
/**
* Extract retry delay from Gemini error response (in milliseconds).
* Parses patterns like:
* - "Your quota will reset after 39s"
* - "Your quota will reset after 18h31m10s"
* - "Please retry in Xs" or "Please retry in Xms"
* - "retryDelay": "34.074824224s" (JSON field)
*/
function extractRetryDelay(errorText: string): number | undefined {
// Pattern 1: "Your quota will reset after ..." (formats: "18h31m10s", "10m15s", "6s", "39s")
const durationMatch = errorText.match(/reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i);
if (durationMatch) {
const hours = durationMatch[1] ? parseInt(durationMatch[1], 10) : 0;
const minutes = durationMatch[2] ? parseInt(durationMatch[2], 10) : 0;
const seconds = parseFloat(durationMatch[3]);
if (!Number.isNaN(seconds)) {
const totalMs = ((hours * 60 + minutes) * 60 + seconds) * 1000;
if (totalMs > 0) {
return Math.ceil(totalMs + 1000); // Add 1s buffer
}
}
}
// Pattern 2: "Please retry in X[ms|s]"
const retryInMatch = errorText.match(/Please retry in ([0-9.]+)(ms|s)/i);
if (retryInMatch?.[1]) {
const value = parseFloat(retryInMatch[1]);
if (!Number.isNaN(value) && value > 0) {
const ms = retryInMatch[2].toLowerCase() === "ms" ? value : value * 1000;
return Math.ceil(ms + 1000);
}
}
// Pattern 3: "retryDelay": "34.074824224s" (JSON field in error details)
const retryDelayMatch = errorText.match(/"retryDelay":\s*"([0-9.]+)(ms|s)"/i);
if (retryDelayMatch?.[1]) {
const value = parseFloat(retryDelayMatch[1]);
if (!Number.isNaN(value) && value > 0) {
const ms = retryDelayMatch[2].toLowerCase() === "ms" ? value : value * 1000;
return Math.ceil(ms + 1000);
}
}
return undefined;
}
/**
* Check if an error is retryable (rate limit, server error, etc.)
*/
function isRetryableError(status: number, errorText: string): boolean {
if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) {
return true;
}
return /resource.?exhausted|rate.?limit|overloaded|service.?unavailable/i.test(errorText);
}
/**
* Sleep for a given number of milliseconds, respecting abort signal.
*/
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Request was aborted"));
return;
}
const timeout = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(timeout);
reject(new Error("Request was aborted"));
});
});
}
interface CloudCodeAssistRequest {
project: string;
model: string;
@ -181,21 +258,62 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
const isAntigravity = endpoint.includes("sandbox.googleapis.com");
const headers = isAntigravity ? ANTIGRAVITY_HEADERS : GEMINI_CLI_HEADERS;
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
...headers,
},
body: JSON.stringify(requestBody),
signal: options?.signal,
});
// Fetch with retry logic for rate limits and transient errors
let response: Response | undefined;
let lastError: Error | undefined;
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (options?.signal?.aborted) {
throw new Error("Request was aborted");
}
try {
response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
...headers,
},
body: JSON.stringify(requestBody),
signal: options?.signal,
});
if (response.ok) {
break; // Success, exit retry loop
}
const errorText = await response.text();
// Check if retryable
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff
const serverDelay = extractRetryDelay(errorText);
const delayMs = serverDelay ?? BASE_DELAY_MS * 2 ** attempt;
await sleep(delayMs, options?.signal);
continue;
}
// Not retryable or max retries exceeded
throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`);
} catch (error) {
if (error instanceof Error && error.message === "Request was aborted") {
throw error;
}
lastError = error instanceof Error ? error : new Error(String(error));
// Network errors are retryable
if (attempt < MAX_RETRIES) {
const delayMs = BASE_DELAY_MS * 2 ** attempt;
await sleep(delayMs, options?.signal);
continue;
}
throw lastError;
}
}
if (!response || !response.ok) {
throw lastError ?? new Error("Failed to get response after retries");
}
if (!response.body) {

View file

@ -4,99 +4,289 @@
### Breaking Changes
- **Session tree structure (v2)**: Sessions now store entries as a tree with `id`/`parentId` fields, enabling in-place branching without creating new files. Existing v1 sessions are auto-migrated on load.
- **SessionManager API**:
- `saveXXX()` renamed to `appendXXX()` (e.g., `appendMessage`, `appendCompaction`)
- `branchInPlace()` renamed to `branch()`
- `reset()` renamed to `newSession()`
- `createBranchedSessionFromEntries(entries, index)` replaced with `createBranchedSession(leafId)`
- `saveCompaction(entry)` replaced with `appendCompaction(summary, firstKeptEntryId, tokensBefore)`
- `getEntries()` now excludes the session header (use `getHeader()` separately)
- New methods: `getTree()`, `getPath()`, `getLeafUuid()`, `getLeafEntry()`, `getEntry()`, `branchWithSummary()`
- New `appendCustomEntry(customType, data)` for hooks to store custom data (not in LLM context)
- New `appendCustomMessageEntry(customType, content, display, details?)` for hooks to inject messages into LLM context
- **Compaction API**:
- `CompactionEntry<T>` and `CompactionResult<T>` are now generic with optional `details?: T` for hook-specific data
- `compact()` now returns `CompactionResult` (`{ summary, firstKeptEntryId, tokensBefore, details? }`) instead of `CompactionEntry`
- `appendCompaction()` now accepts optional `details` parameter
- `CompactionEntry.firstKeptEntryIndex` replaced with `firstKeptEntryId`
- `prepareCompaction(pathEntries, settings)` now takes path entries (from `getPath()`) and settings only
- `CompactionPreparation` restructured: removed `cutPoint`, `messagesToKeep`, `boundaryStart`; added `turnPrefixMessages`, `isSplitTurn`, `previousSummary`, `fileOps`, `settings`
- `compact(preparation, model, apiKey, customInstructions?, signal?)` now takes preparation and execution context separately
- **Hook types**:
- `HookEventContext` renamed to `HookContext`
- `HookContext` now has `sessionManager`, `modelRegistry`, and `model` (current model, may be undefined)
- `HookCommandContext` removed - `RegisteredCommand.handler` now takes `(args: string, ctx: HookContext)`
- `before_compact` event: removed `previousCompactions` and `model`, added `branchEntries: SessionEntry[]` (hooks extract what they need)
- `before_tree` event: removed `model` (use `ctx.model` instead)
- `before_switch` event now has `targetSessionFile`, `switch` event has `previousSessionFile`
- Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`)
- Hooks can return `compaction.details` to store custom data (e.g., ArtifactIndex for structured compaction)
- **Hook API**:
- `pi.send(text, attachments?)` replaced with `pi.sendMessage(message, triggerTurn?)` which creates `CustomMessageEntry` instead of user messages
- New `pi.appendEntry(customType, data?)` to persist hook state (does NOT participate in LLM context)
- New `pi.registerCommand(name, options)` to register custom slash commands
- New `pi.registerMessageRenderer(customType, renderer)` to register custom renderers for hook messages
- New `pi.exec(command, args, options?)` to execute shell commands (moved from `HookEventContext`/`HookCommandContext`)
- `HookMessageRenderer` type: `(message: HookMessage, options, theme) => Component | null`
- Renderers return inner content; the TUI wraps it in a styled Box
- New types: `HookMessage<T>`, `RegisteredCommand`, `HookContext`
- Handler types renamed: `SendHandler``SendMessageHandler`, new `AppendEntryHandler`
- **SessionManager**:
- `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions)
- **Themes**: Custom themes must add `selectedBg`, `customMessageBg`, `customMessageText`, `customMessageLabel` color tokens (50 total)
- **Custom tools**: `dispose()` method removed from `CustomAgentTool`. Use `onSession` with `reason: "shutdown"` instead for cleanup. `SessionEvent.reason` now includes `"shutdown"`.
- **Renamed exports**:
- `messageTransformer``convertToLlm`
- `SessionContext` alias `LoadedSession` removed (use `SessionContext` directly)
- **Removed exports**:
- `createSummaryMessage()` - replaced by internal compaction logic
- `SUMMARY_PREFIX`, `SUMMARY_SUFFIX` - no longer used
- `Attachment` type - use `ImageContent` from `@mariozechner/pi-ai` instead
- **New exports**:
- `BranchSummaryEntry`, `CustomEntry`, `CustomMessageEntry`, `LabelEntry` - new session entry types
- `SessionEntryBase`, `FileEntry` - base types for session entries
- `CURRENT_SESSION_VERSION`, `migrateSessionEntries` - session migration utilities
- `BranchPreparation`, `BranchSummaryResult`, `CollectEntriesResult`, `GenerateBranchSummaryOptions` - branch summarization types
- `FileOperations`, `collectEntriesForBranchSummary`, `prepareBranchEntries`, `generateBranchSummary` - branch summarization utilities
- `CompactionPreparation`, `CompactionDetails` - compaction preparation types
- `ReadonlySessionManager` - read-only session manager interface for hooks
- `HookMessage`, `HookContext`, `HookMessageRenderOptions` - hook types
- `isHookMessage`, `createHookMessage` - hook message utilities
- **Queue API replaced with steer/followUp**: The `queueMessage()` method has been split into two methods with different delivery semantics ([#403](https://github.com/badlogic/pi-mono/issues/403)):
- `steer(text)`: Interrupts the agent mid-run (Enter while streaming). Delivered after current tool execution.
- `followUp(text)`: Waits until the agent finishes (Alt+Enter while streaming). Delivered only when agent stops.
- **Settings renamed**: `queueMode` setting renamed to `steeringMode`. Added new `followUpMode` setting. Old settings.json files are migrated automatically.
- **AgentSession methods renamed**:
- `queueMessage()``steer()` and `followUp()`
- `queueMode` getter → `steeringMode` and `followUpMode` getters
- `setQueueMode()``setSteeringMode()` and `setFollowUpMode()`
- `queuedMessageCount``pendingMessageCount`
- `getQueuedMessages()``getSteeringMessages()` and `getFollowUpMessages()`
- `clearQueue()` now returns `{ steering: string[], followUp: string[] }`
- `hasQueuedMessages()``hasPendingMessages()`
- **Hook API signature changed**: `pi.sendMessage()` second parameter changed from `triggerTurn?: boolean` to `options?: { triggerTurn?, deliverAs? }`. Use `deliverAs: "followUp"` for follow-up delivery. Affects both hooks and internal `sendHookMessage()` method.
- **RPC API changes**:
- `queue_message` command → `steer` and `follow_up` commands
- `set_queue_mode` command → `set_steering_mode` and `set_follow_up_mode` commands
- `RpcSessionState.queueMode``steeringMode` and `followUpMode`
- **Settings UI**: "Queue mode" setting split into "Steering mode" and "Follow-up mode"
### Added
- **`/tree` command**: Navigate the session tree in-place. Shows full tree structure with labels, supports search (type to filter), page navigation (←/→), and filter modes (Ctrl+O cycles: default → no-tools → user-only → labeled-only → all, Shift+Ctrl+O cycles backwards). Selecting a branch generates a summary and switches context. Press `l` to label entries.
- **`context` hook event**: Fires before each LLM call, allowing hooks to non-destructively modify messages. Returns `{ messages }` to override. Useful for dynamic context pruning without modifying session history.
- **`before_agent_start` hook event**: Fires once when user submits a prompt, before `agent_start`. Hooks can return `{ message }` to inject a `CustomMessageEntry` that gets persisted and sent to the LLM.
- **`ui.custom()` for hooks**: Show arbitrary TUI components with keyboard focus. Call `done()` when finished: `ctx.ui.custom(component, done)`.
- **Branch summarization**: When switching branches via `/tree`, generates a summary of the abandoned branch including file operations (read/modified files). Summaries are stored as `BranchSummaryEntry` with cumulative file tracking in `details`.
- **Structured compaction**: Both compaction and branch summarization now use structured output format with clear sections (Goal, Progress, Key Information, File Operations). Conversations are serialized to text before summarization to prevent the model from "continuing" conversations.
- **File tracking in summaries**: Compaction and branch summaries now track `readFiles` and `modifiedFiles` arrays in the `details` field, accumulated across multiple compactions/summaries. This provides cumulative file operation history.
- **`selectedBg` theme color**: Background color for selected/active lines in tree selector and other components.
- **Entry labels**: Label any session entry with `/tree` → select entry → press `l`. Labels appear in tree view and are persisted as `LabelEntry` in the session. Use `labeled-only` filter mode to show only labeled entries.
- **`enabledModels` setting**: Configure whitelisted models in `settings.json` (same format as `--models` CLI flag). CLI `--models` takes precedence over the setting.
- **Snake game example hook**: Added `examples/hooks/snake.ts` demonstrating `ui.custom()`, `registerCommand()`, and session persistence.
- Automatic image resizing: images larger than 2000x2000 are resized for better model compatibility. Original dimensions are injected into the prompt. Controlled via `/settings` or `images.autoResize` in settings.json. ([#402](https://github.com/badlogic/pi-mono/pull/402) by [@mitsuhiko](https://github.com/mitsuhiko))
- Alt+Enter keybind to queue follow-up messages while agent is streaming
- `Theme` and `ThemeColor` types now exported for hooks using `ctx.ui.custom()`
- Terminal window title now displays "pi - dirname" to identify which project session you're in ([#407](https://github.com/badlogic/pi-mono/pull/407) by [@kaofelix](https://github.com/kaofelix))
### Fixed
- Shift+Space, Shift+Backspace, and Shift+Delete now work correctly in Kitty-protocol terminals (Kitty, WezTerm, etc.) instead of being silently ignored ([#411](https://github.com/badlogic/pi-mono/pull/411) by [@nathyong](https://github.com/nathyong))
- `AgentSession.prompt()` now throws if called while the agent is already streaming, preventing race conditions. Use `steer()` or `followUp()` to queue messages during streaming.
- Ctrl+C now works like Escape in selector components, so mashing Ctrl+C will eventually close the program ([#400](https://github.com/badlogic/pi-mono/pull/400) by [@mitsuhiko](https://github.com/mitsuhiko))
## [0.31.1] - 2026-01-02
### Fixed
- Model selector no longer allows negative index when pressing arrow keys before models finish loading ([#398](https://github.com/badlogic/pi-mono/pull/398) by [@mitsuhiko](https://github.com/mitsuhiko))
- Type guard functions (`isBashToolResult`, etc.) now exported at runtime, not just in type declarations ([#397](https://github.com/badlogic/pi-mono/issues/397))
## [0.31.0] - 2026-01-02
This release introduces session trees for in-place branching, major API changes to hooks and custom tools, and structured compaction with file tracking.
### Session Tree
Sessions now use a tree structure with `id`/`parentId` fields. This enables in-place branching: navigate to any previous point with `/tree`, continue from there, and switch between branches while preserving all history in a single file.
**Existing sessions are automatically migrated** (v1 → v2) on first load. No manual action required.
New entry types: `BranchSummaryEntry` (context from abandoned branches), `CustomEntry` (hook state), `CustomMessageEntry` (hook-injected messages), `LabelEntry` (bookmarks).
See [docs/session.md](docs/session.md) for the file format and `SessionManager` API.
### Hooks Migration
The hooks API has been restructured with more granular events and better session access.
**Type renames:**
- `HookEventContext``HookContext`
- `HookCommandContext` is now a new interface extending `HookContext` with session control methods
**Event changes:**
- The monolithic `session` event is now split into granular events: `session_start`, `session_before_switch`, `session_switch`, `session_before_branch`, `session_branch`, `session_before_compact`, `session_compact`, `session_shutdown`
- `session_before_switch` and `session_switch` events now include `reason: "new" | "resume"` to distinguish between `/new` and `/resume`
- New `session_before_tree` and `session_tree` events for `/tree` navigation (hook can provide custom branch summary)
- New `before_agent_start` event: inject messages before the agent loop starts
- New `context` event: modify messages non-destructively before each LLM call
- Session entries are no longer passed in events. Use `ctx.sessionManager.getEntries()` or `ctx.sessionManager.getBranch()` instead
**API changes:**
- `pi.send(text, attachments?)``pi.sendMessage(message, triggerTurn?)` (creates `CustomMessageEntry`)
- New `pi.appendEntry(customType, data?)` for hook state persistence (not in LLM context)
- New `pi.registerCommand(name, options)` for custom slash commands (handler receives `HookCommandContext`)
- New `pi.registerMessageRenderer(customType, renderer)` for custom TUI rendering
- New `ctx.isIdle()`, `ctx.abort()`, `ctx.hasQueuedMessages()` for agent state (available in all events)
- New `ctx.ui.editor(title, prefill?)` for multi-line text editing with Ctrl+G external editor support
- New `ctx.ui.custom(component)` for full TUI component rendering with keyboard focus
- New `ctx.ui.setStatus(key, text)` for persistent status text in footer (multiple hooks can set their own)
- New `ctx.ui.theme` getter for styling text with theme colors
- `ctx.exec()` moved to `pi.exec()`
- `ctx.sessionFile``ctx.sessionManager.getSessionFile()`
- New `ctx.modelRegistry` and `ctx.model` for API key resolution
**HookCommandContext (slash commands only):**
- `ctx.waitForIdle()` - wait for agent to finish streaming
- `ctx.newSession(options?)` - create new sessions with optional setup callback
- `ctx.branch(entryId)` - branch from a specific entry
- `ctx.navigateTree(targetId, options?)` - navigate the session tree
These methods are only on `HookCommandContext` (not `HookContext`) because they can deadlock if called from event handlers that run inside the agent loop.
**Removed:**
- `hookTimeout` setting (hooks no longer have timeouts; use Ctrl+C to abort)
- `resolveApiKey` parameter (use `ctx.modelRegistry.getApiKey(model)`)
See [docs/hooks.md](docs/hooks.md) and [examples/hooks/](examples/hooks/) for the current API.
### Custom Tools Migration
The custom tools API has been restructured to mirror the hooks pattern with a context object.
**Type renames:**
- `CustomAgentTool``CustomTool`
- `ToolAPI``CustomToolAPI`
- `ToolContext``CustomToolContext`
- `ToolSessionEvent``CustomToolSessionEvent`
**Execute signature changed:**
```typescript
// Before (v0.30.2)
execute(toolCallId, params, signal, onUpdate)
// After
execute(toolCallId, params, onUpdate, ctx, signal?)
```
The new `ctx: CustomToolContext` provides `sessionManager`, `modelRegistry`, `model`, and agent state methods:
- `ctx.isIdle()` - check if agent is streaming
- `ctx.hasQueuedMessages()` - check if user has queued messages (skip interactive prompts)
- `ctx.abort()` - abort current operation (fire-and-forget)
**Session event changes:**
- `CustomToolSessionEvent` now only has `reason` and `previousSessionFile`
- Session entries are no longer in the event. Use `ctx.sessionManager.getBranch()` or `ctx.sessionManager.getEntries()` to reconstruct state
- Reasons: `"start" | "switch" | "branch" | "tree" | "shutdown"` (no separate `"new"` reason; `/new` triggers `"switch"`)
- `dispose()` method removed. Use `onSession` with `reason: "shutdown"` for cleanup
See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](examples/custom-tools/) for the current API.
### SDK Migration
**Type changes:**
- `CustomAgentTool``CustomTool`
- `AppMessage``AgentMessage`
- `sessionFile` returns `string | undefined` (was `string | null`)
- `model` returns `Model | undefined` (was `Model | null`)
- `Attachment` type removed. Use `ImageContent` from `@mariozechner/pi-ai` instead. Add images directly to message content arrays.
**AgentSession API:**
- `branch(entryIndex: number)``branch(entryId: string)`
- `getUserMessagesForBranching()` returns `{ entryId, text }` instead of `{ entryIndex, text }`
- `reset()``newSession(options?)` where options has optional `parentSession` for lineage tracking
- `newSession()` and `switchSession()` now return `Promise<boolean>` (false if cancelled by hook)
- New `navigateTree(targetId, options?)` for in-place tree navigation
**Hook integration:**
- New `sendHookMessage(message, triggerTurn?)` for hook message injection
**SessionManager API:**
- Method renames: `saveXXX()``appendXXX()` (e.g., `appendMessage`, `appendCompaction`)
- `branchInPlace()``branch()`
- `reset()``newSession(options?)` with optional `parentSession` for lineage tracking
- `createBranchedSessionFromEntries(entries, index)``createBranchedSession(leafId)`
- `SessionHeader.branchedFrom``SessionHeader.parentSession`
- `saveCompaction(entry)``appendCompaction(summary, firstKeptEntryId, tokensBefore, details?)`
- `getEntries()` now excludes the session header (use `getHeader()` separately)
- `getSessionFile()` returns `string | undefined` (undefined for in-memory sessions)
- New tree methods: `getTree()`, `getBranch()`, `getLeafId()`, `getLeafEntry()`, `getEntry()`, `getChildren()`, `getLabel()`
- New append methods: `appendCustomEntry()`, `appendCustomMessageEntry()`, `appendLabelChange()`
- New branch methods: `branch(entryId)`, `branchWithSummary()`
**ModelRegistry (new):**
`ModelRegistry` is a new class that manages model discovery and API key resolution. It combines built-in models with custom models from `models.json` and resolves API keys via `AuthStorage`.
```typescript
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
const authStorage = discoverAuthStorage(); // ~/.pi/agent/auth.json
const modelRegistry = discoverModels(authStorage); // + ~/.pi/agent/models.json
// Get all models (built-in + custom)
const allModels = modelRegistry.getAll();
// Get only models with valid API keys
const available = await modelRegistry.getAvailable();
// Find specific model
const model = modelRegistry.find("anthropic", "claude-sonnet-4-20250514");
// Get API key for a model
const apiKey = await modelRegistry.getApiKey(model);
```
This replaces the old `resolveApiKey` callback pattern. Hooks and custom tools access it via `ctx.modelRegistry`.
**Renamed exports:**
- `messageTransformer``convertToLlm`
- `SessionContext` alias `LoadedSession` removed
See [docs/sdk.md](docs/sdk.md) and [examples/sdk/](examples/sdk/) for the current API.
### RPC Migration
**Session commands:**
- `reset` command → `new_session` command with optional `parentSession` field
**Branching commands:**
- `branch` command: `entryIndex``entryId`
- `get_branch_messages` response: `entryIndex``entryId`
**Type changes:**
- Messages are now `AgentMessage` (was `AppMessage`)
- `prompt` command: `attachments` field replaced with `images` field using `ImageContent` format
**Compaction events:**
- `auto_compaction_start` now includes `reason` field (`"threshold"` or `"overflow"`)
- `auto_compaction_end` now includes `willRetry` field
- `compact` response includes full `CompactionResult` (`summary`, `firstKeptEntryId`, `tokensBefore`, `details`)
See [docs/rpc.md](docs/rpc.md) for the current protocol.
### Structured Compaction
Compaction and branch summarization now use a structured output format:
- Clear sections: Goal, Progress, Key Information, File Operations
- File tracking: `readFiles` and `modifiedFiles` arrays in `details`, accumulated across compactions
- Conversations are serialized to text before summarization to prevent the model from "continuing" them
The `before_compact` and `before_tree` hook events allow custom compaction implementations. See [docs/compaction.md](docs/compaction.md).
### Interactive Mode
**`/tree` command:**
- Navigate the full session tree in-place
- Search by typing, page with ←/→
- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all
- Press `l` to label entries as bookmarks
- Selecting a branch switches context and optionally injects a summary of the abandoned branch
**Entry labels:**
- Bookmark any entry via `/tree` → select → `l`
- Labels appear in tree view and persist as `LabelEntry`
**Theme changes (breaking for custom themes):**
Custom themes must add these new color tokens or they will fail to load:
- `selectedBg`: background for selected/highlighted items in tree selector and other components
- `customMessageBg`: background for hook-injected messages (`CustomMessageEntry`)
- `customMessageText`: text color for hook messages
- `customMessageLabel`: label color for hook messages (the `[customType]` prefix)
Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) for the full color list and copy values from the built-in dark/light themes.
**Settings:**
- `enabledModels`: allowlist models in `settings.json` (same format as `--models` CLI)
### Added
- `ctx.ui.setStatus(key, text)` for hooks to display persistent status text in the footer ([#385](https://github.com/badlogic/pi-mono/pull/385) by [@prateekmedia](https://github.com/prateekmedia))
- `ctx.ui.theme` getter for styling status text and other output with theme colors
- `/share` command to upload session as a secret GitHub gist and get a shareable URL via shittycodingagent.ai ([#380](https://github.com/badlogic/pi-mono/issues/380))
- HTML export now includes a tree visualization sidebar for navigating session branches ([#375](https://github.com/badlogic/pi-mono/issues/375))
- HTML export supports keyboard shortcuts: Ctrl+T to toggle thinking blocks, Ctrl+O to toggle tool outputs
- HTML export supports theme-configurable background colors via optional `export` section in theme JSON ([#387](https://github.com/badlogic/pi-mono/pull/387) by [@mitsuhiko](https://github.com/mitsuhiko))
- HTML export syntax highlighting now uses theme colors and matches TUI rendering
- **Snake game example hook**: Demonstrates `ui.custom()`, `registerCommand()`, and session persistence. See [examples/hooks/snake.ts](examples/hooks/snake.ts).
- **`thinkingText` theme token**: Configurable color for thinking block text. ([#366](https://github.com/badlogic/pi-mono/pull/366) by [@paulbettner](https://github.com/paulbettner))
### Changed
- **Entry IDs**: Session entries now use short 8-character hex IDs instead of full UUIDs
- **API key priority**: `ANTHROPIC_OAUTH_TOKEN` now takes precedence over `ANTHROPIC_API_KEY`
- **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry<T>` for hook state persistence, `CustomMessageEntry<T>` for hook-injected context messages, `LabelEntry` for user-defined bookmarks
- **TUI**: `CustomMessageEntry` renders with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors). Entries with `display: false` are hidden.
- **AgentSession**: New `sendHookMessage(message, triggerTurn?)` method for hooks to inject messages. Handles queuing during streaming, direct append when idle, and optional turn triggering.
- **HookMessage**: New message type with `role: "hookMessage"` for hook-injected messages in agent events. Use `isHookMessage(msg)` type guard to identify them. These are converted to user messages for LLM context via `messageTransformer`.
- **Agent.prompt()**: Now accepts `AppMessage` directly (in addition to `string, attachments?`) for custom message types like `HookMessage`.
- HTML export template split into separate files (template.html, template.css, template.js) for easier maintenance
### Fixed
- HTML export now properly sanitizes user messages containing HTML tags like `<style>` that could break DOM rendering
- Crash when displaying bash output containing Unicode format characters like U+0600-U+0604 ([#372](https://github.com/badlogic/pi-mono/pull/372) by [@HACKE-RC](https://github.com/HACKE-RC))
- **Footer shows full session stats**: Token usage and cost now include all messages, not just those after compaction. ([#322](https://github.com/badlogic/pi-mono/issues/322))
- **Status messages spam chat log**: Rapidly changing settings (e.g., thinking level via Shift+Tab) would add multiple status lines. Sequential status updates now coalesce into a single line. ([#365](https://github.com/badlogic/pi-mono/pull/365) by [@paulbettner](https://github.com/paulbettner))
- **Toggling thinking blocks during streaming shows nothing**: Pressing Ctrl+T while streaming would hide the current message until streaming completed.
- **Resuming session resets thinking level to off**: Initial model and thinking level were not saved to session file, causing `--resume`/`--continue` to default to `off`. ([#342](https://github.com/badlogic/pi-mono/issues/342) by [@aliou](https://github.com/aliou))
- **Hook `tool_result` event ignores errors from custom tools**: The `tool_result` hook event was never emitted when tools threw errors, and always had `isError: false` for successful executions. Now emits the event with correct `isError` value in both success and error cases. ([#374](https://github.com/badlogic/pi-mono/issues/374) by [@nicobailon](https://github.com/nicobailon))
- **Edit tool fails on Windows due to CRLF line endings**: Files with CRLF line endings now match correctly when LLMs send LF-only text. Line endings are normalized before matching and restored to original style on write. ([#355](https://github.com/badlogic/pi-mono/issues/355) by [@Pratham-Dubey](https://github.com/Pratham-Dubey))
- **Edit tool fails on files with UTF-8 BOM**: Files with UTF-8 BOM marker could cause "text not found" errors since the LLM doesn't include the invisible BOM character. BOM is now stripped before matching and restored on write. ([#394](https://github.com/badlogic/pi-mono/pull/394) by [@prathamdby](https://github.com/prathamdby))
- **Use bash instead of sh on Unix**: Fixed shell commands using `/bin/sh` instead of `/bin/bash` on Unix systems. ([#328](https://github.com/badlogic/pi-mono/pull/328) by [@dnouri](https://github.com/dnouri))
- **OAuth login URL clickable**: Made OAuth login URLs clickable in terminal. ([#349](https://github.com/badlogic/pi-mono/pull/349) by [@Cursivez](https://github.com/Cursivez))
- **Improved error messages**: Better error messages when `apiKey` or `model` are missing. ([#346](https://github.com/badlogic/pi-mono/pull/346) by [@ronyrus](https://github.com/ronyrus))
- **Session file validation**: `findMostRecentSession()` now validates session headers before returning, preventing non-session JSONL files from being loaded
- **Compaction error handling**: `generateSummary()` and `generateTurnPrefixSummary()` now throw on LLM errors instead of returning empty strings
- **Compaction with branched sessions**: Fixed compaction incorrectly including entries from abandoned branches, causing token overflow errors. Compaction now uses `sessionManager.getPath()` to work only on the current branch path, eliminating 80+ lines of duplicate entry collection logic between `prepareCompaction()` and `compact()`
- **enabledModels glob patterns**: `--models` and `enabledModels` now support glob patterns like `github-copilot/*` or `*sonnet*`. Previously, patterns were only matched literally or via substring search. ([#337](https://github.com/badlogic/pi-mono/issues/337))
## [0.30.2] - 2025-12-26

View file

@ -1,339 +0,0 @@
# coding-agent Development Guide
This document describes the architecture and development workflow for the coding-agent package.
## Architecture Overview
The coding-agent is structured into distinct layers:
```
┌─────────────────────────────────────────────────────────────┐
│ CLI Layer │
│ cli.ts → main.ts → cli/args.ts, cli/file-processor.ts │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Mode Layer │
│ modes/interactive/ modes/print-mode.ts modes/rpc/ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Core Layer │
│ core/agent-session.ts (central abstraction) │
│ core/session-manager.ts, core/model-config.ts, etc. │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ External Dependencies │
@mariozechner/pi-agent (Agent, tools) │
@mariozechner/pi-ai (models, providers) │
@mariozechner/pi-tui (TUI components) │
└─────────────────────────────────────────────────────────────┘
```
## Directory Structure
```
src/
├── cli.ts # CLI entry point (shebang, calls main)
├── main.ts # Main orchestration, argument handling, mode routing
├── index.ts # Public API exports
├── config.ts # APP_NAME, VERSION, paths (getAgentDir, etc.)
├── cli/ # CLI-specific utilities
│ ├── args.ts # parseArgs(), printHelp(), Args interface
│ ├── file-processor.ts # processFileArguments() for @file args
│ ├── list-models.ts # --list-models implementation
│ └── session-picker.ts # selectSession() TUI for --resume
├── core/ # Core business logic (mode-agnostic)
│ ├── index.ts # Core exports
│ ├── agent-session.ts # AgentSession class - THE central abstraction
│ ├── bash-executor.ts # executeBash() with streaming, abort
│ ├── compaction.ts # Context compaction logic
│ ├── export-html.ts # exportSession(), exportFromFile()
│ ├── messages.ts # BashExecutionMessage, messageTransformer
│ ├── model-config.ts # findModel(), getAvailableModels(), getApiKeyForModel()
│ ├── model-resolver.ts # resolveModelScope(), restoreModelFromSession()
│ ├── session-manager.ts # SessionManager class - JSONL persistence
│ ├── settings-manager.ts # SettingsManager class - user preferences
│ ├── skills.ts # loadSkills(), skill discovery from multiple locations
│ ├── slash-commands.ts # loadSlashCommands() from ~/.pi/agent/commands/
│ ├── system-prompt.ts # buildSystemPrompt(), loadProjectContextFiles()
│ │
│ ├── oauth/ # OAuth authentication (thin wrapper)
│ │ └── index.ts # Re-exports from @mariozechner/pi-ai with convenience wrappers
│ │
│ ├── hooks/ # Hook system for extending behavior
│ │ ├── index.ts # Hook exports
│ │ ├── types.ts # HookAPI, HookContext, event types
│ │ ├── loader.ts # loadHooks() from multiple locations
│ │ ├── runner.ts # runHook() event dispatch
│ │ └── tool-wrapper.ts # wrapToolsWithHooks() for tool_call events
│ │
│ ├── custom-tools/ # Custom tool loading system
│ │ ├── index.ts # Custom tool exports
│ │ ├── types.ts # CustomToolFactory, CustomToolDefinition
│ │ └── loader.ts # loadCustomTools() from multiple locations
│ │
│ └── tools/ # Built-in tool implementations
│ ├── index.ts # Tool exports, allTools, codingTools
│ ├── bash.ts # Bash command execution
│ ├── edit.ts # Surgical file editing
│ ├── find.ts # File search by glob
│ ├── grep.ts # Content search (regex/literal)
│ ├── ls.ts # Directory listing
│ ├── read.ts # File reading (text and images)
│ ├── write.ts # File writing
│ ├── path-utils.ts # Path resolution utilities
│ └── truncate.ts # Output truncation utilities
├── modes/ # Run mode implementations
│ ├── index.ts # Re-exports InteractiveMode, runPrintMode, runRpcMode, RpcClient
│ ├── print-mode.ts # Non-interactive: process messages, print output, exit
│ │
│ ├── rpc/ # RPC mode for programmatic control
│ │ ├── rpc-mode.ts # runRpcMode() - JSON stdin/stdout protocol
│ │ ├── rpc-types.ts # RpcCommand, RpcResponse, RpcSessionState types
│ │ └── rpc-client.ts # RpcClient class for spawning/controlling agent
│ │
│ └── interactive/ # Interactive TUI mode
│ ├── interactive-mode.ts # InteractiveMode class
│ │
│ ├── components/ # TUI components
│ │ ├── assistant-message.ts # Agent response rendering
│ │ ├── bash-execution.ts # Bash output display
│ │ ├── compaction.ts # Compaction status display
│ │ ├── 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
│ │ ├── model-selector.ts # Model picker
│ │ ├── oauth-selector.ts # OAuth provider picker
│ │ ├── queue-mode-selector.ts # Message queue mode picker
│ │ ├── session-selector.ts # Session browser for --resume
│ │ ├── show-images-selector.ts # Image display toggle
│ │ ├── theme-selector.ts # Theme picker
│ │ ├── thinking-selector.ts # Thinking level picker
│ │ ├── tool-execution.ts # Tool call/result rendering
│ │ ├── user-message-selector.ts # Message selector for /branch
│ │ └── user-message.ts # User message rendering
│ │
│ └── theme/
│ ├── theme.ts # Theme loading, getEditorTheme(), etc.
│ ├── dark.json
│ ├── light.json
│ └── theme-schema.json
└── utils/ # Generic utilities
├── changelog.ts # parseChangelog(), getNewEntries()
├── clipboard.ts # copyToClipboard()
├── fuzzy.ts # Fuzzy string matching
├── mime.ts # MIME type detection
├── shell.ts # getShellConfig()
└── tools-manager.ts # ensureTool() - download fd, etc.
```
## Key Abstractions
### AgentSession (core/agent-session.ts)
The central abstraction that wraps the low-level `Agent` with:
- Session persistence (via SessionManager)
- Settings persistence (via SettingsManager)
- Model cycling with scoped models
- Context compaction
- Bash command execution
- Message queuing
- Hook integration
- Custom tool loading
All three modes (interactive, print, rpc) use AgentSession.
### InteractiveMode (modes/interactive/interactive-mode.ts)
Handles TUI rendering and user interaction:
- Subscribes to AgentSession events
- Renders messages, tool executions, streaming
- Manages editor, selectors, key handlers
- Delegates all business logic to AgentSession
### RPC Mode (modes/rpc/)
Headless operation via JSON protocol over stdin/stdout:
- **rpc-mode.ts**: `runRpcMode()` function that listens for JSON commands on stdin and emits responses/events on stdout
- **rpc-types.ts**: Typed protocol definitions (`RpcCommand`, `RpcResponse`, `RpcSessionState`)
- **rpc-client.ts**: `RpcClient` class for spawning the agent as a subprocess and controlling it programmatically
The RPC mode exposes the full AgentSession API via JSON commands. See [docs/rpc.md](docs/rpc.md) for protocol documentation.
### SessionManager (core/session-manager.ts)
Handles session persistence:
- JSONL format for append-only writes
- Session file location management
- Message loading/saving
- Model/thinking level persistence
### SettingsManager (core/settings-manager.ts)
Handles user preferences:
- Default model/provider
- Theme selection
- Queue mode
- Thinking block visibility
- Compaction settings
- Hook/custom tool paths
### Hook System (core/hooks/)
Extensibility layer for intercepting agent behavior:
- **loader.ts**: Discovers and loads hooks from `~/.pi/agent/hooks/`, `.pi/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`)
See [docs/hooks.md](docs/hooks.md) for full documentation.
### Custom Tools (core/custom-tools/)
System for adding LLM-callable tools:
- **loader.ts**: Discovers and loads tools from `~/.pi/agent/tools/`, `.pi/tools/`, and CLI
- **types.ts**: `CustomToolFactory`, `CustomToolDefinition`, `CustomToolResult`
See [docs/custom-tools.md](docs/custom-tools.md) for full documentation.
### Skills (core/skills.ts)
On-demand capability packages:
- Discovers SKILL.md files from multiple locations
- Provides specialized workflows and instructions
- Loaded when task matches description
See [docs/skills.md](docs/skills.md) for full documentation.
## Development Workflow
### Running in Development
Start the watch build in the monorepo root to continuously rebuild all packages:
```bash
# Terminal 1: Watch build (from monorepo root)
npm run dev
```
Then run the CLI with tsx in a separate terminal:
```bash
# Terminal 2: Run CLI (from monorepo root)
npx tsx packages/coding-agent/src/cli.ts
# With arguments
npx tsx packages/coding-agent/src/cli.ts --help
npx tsx packages/coding-agent/src/cli.ts -p "Hello"
# RPC mode
npx tsx packages/coding-agent/src/cli.ts --mode rpc --no-session
```
The watch build ensures changes to dependent packages (`pi-agent`, `pi-ai`, `pi-tui`) are automatically rebuilt.
### Type Checking
```bash
# From monorepo root
npm run check
```
### Building
```bash
# Build all packages
npm run build
# Build standalone binary
cd packages/coding-agent
npm run build:binary
```
## Adding New Features
### Adding a New Slash Command
1. If it's a UI-only command (e.g., `/theme`), add handler in `interactive-mode.ts` `setupEditorSubmitHandler()`
2. If it needs session logic, add method to `AgentSession` and call from mode
### Adding a New Tool
1. Create tool in `core/tools/` following existing patterns
2. Export from `core/tools/index.ts`
3. Add to `allTools` and optionally `codingTools`
4. Add description to `toolDescriptions` in `core/system-prompt.ts`
### Adding a New Hook Event
1. Add event type to `HookEvent` union in `core/hooks/types.ts`
2. Add emission point in relevant code (AgentSession, tool wrapper, etc.)
3. Document in `docs/hooks.md`
### Adding a New RPC Command
1. Add command type to `RpcCommand` union in `rpc-types.ts`
2. Add response type to `RpcResponse` union in `rpc-types.ts`
3. Add handler case in `handleCommand()` switch in `rpc-mode.ts`
4. Add client method in `RpcClient` class in `rpc-client.ts`
5. Document in `docs/rpc.md`
### Adding a New Selector
1. Create component in `modes/interactive/components/`
2. Use `showSelector()` helper in `interactive-mode.ts`:
```typescript
private showMySelector(): void {
this.showSelector((done) => {
const selector = new MySelectorComponent(
// ... params
(result) => {
// Handle selection
done();
this.showStatus(`Selected: ${result}`);
},
() => {
done();
this.ui.requestRender();
},
);
return { component: selector, focus: selector.getSelectList() };
});
}
```
## Testing
The package uses E2E tests only (no unit tests by design). Tests are in `test/`:
```bash
# Run all tests
npm test
# Run specific test pattern
npm test -- --testNamePattern="RPC"
# Run RPC example interactively
npx tsx test/rpc-example.ts
```
## Code Style
- No `any` types unless absolutely necessary
- No inline dynamic imports
- Use `showStatus()` for dim status messages
- Use `showError()` / `showWarning()` for errors/warnings
- Keep InteractiveMode focused on UI, delegate logic to AgentSession

View file

@ -25,12 +25,13 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows-
- [Project Context Files](#project-context-files)
- [Custom System Prompt](#custom-system-prompt)
- [Custom Models and Providers](#custom-models-and-providers)
- [Settings File](#settings-file)
- [Extensions](#extensions)
- [Themes](#themes)
- [Custom Slash Commands](#custom-slash-commands)
- [Skills](#skills)
- [Hooks](#hooks)
- [Custom Tools](#custom-tools)
- [Settings File](#settings-file)
- [CLI Reference](#cli-reference)
- [Tools](#tools)
- [Programmatic Usage](#programmatic-usage)
@ -187,12 +188,14 @@ The agent reads, writes, and edits files, and executes commands via bash.
| Command | Description |
|---------|-------------|
| `/settings` | Open settings menu (thinking, theme, queue mode, toggles) |
| `/settings` | Open settings menu (thinking, theme, message delivery modes, toggles) |
| `/model` | Switch models mid-session (fuzzy search, arrow keys, Enter to select) |
| `/export [file]` | Export session to self-contained HTML |
| `/share` | Upload session as secret GitHub gist, get shareable URL (requires `gh` CLI) |
| `/session` | Show session info: path, message counts, token usage, cost |
| `/hotkeys` | Show all keyboard shortcuts |
| `/changelog` | Display full version history |
| `/tree` | Navigate session tree in-place (search, filter, label entries) |
| `/branch` | Create new conversation branch from a previous message |
| `/resume` | Switch to a different session (interactive selector) |
| `/login` | OAuth login for subscription-based models |
@ -211,7 +214,11 @@ The agent reads, writes, and edits files, and executes commands via bash.
**Multi-line paste:** Pasted content is collapsed to `[paste #N <lines> lines]` but sent in full.
**Message queuing:** Submit messages while the agent is working. They queue and process based on queue mode (configurable via `/settings`). Press Escape to abort and restore queued messages to editor.
**Message queuing:** Submit messages while the agent is working:
- **Enter** queues a *steering* message, delivered after current tool execution (interrupts remaining tools)
- **Alt+Enter** queues a *follow-up* message, delivered only after the agent finishes all work
Both modes are configurable via `/settings`: "one-at-a-time" delivers messages one by one waiting for responses, "all" delivers all queued messages at once. Press Escape to abort and restore queued messages to editor.
### Keyboard Shortcuts
@ -283,6 +290,8 @@ You: What's in this screenshot? /path/to/image.png
Supported formats: `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`
**Auto-resize:** Images larger than 2000x2000 pixels are automatically resized to fit within this limit for better compatibility with Anthropic models. The original dimensions are noted in the context so the model can map coordinates back if needed. Disable via `images.autoResize: false` in settings.
**Inline rendering:** On terminals that support the Kitty graphics protocol (Kitty, Ghostty, WezTerm) or iTerm2 inline images, images in tool output are rendered inline. On unsupported terminals, a text placeholder is shown instead.
Toggle inline images via `/settings` or set `terminal.showImages: false` in settings.
@ -291,6 +300,10 @@ Toggle inline images via `/settings` or set `terminal.showImages: false` in sett
## Sessions
Sessions are stored as JSONL files with a **tree structure**. Each entry has an `id` and `parentId`, enabling in-place branching: navigate to any previous point with `/tree`, continue from there, and switch between branches while preserving all history in a single file.
See [docs/session.md](docs/session.md) for the file format and programmatic API.
### Session Management
Sessions auto-save to `~/.pi/agent/sessions/` organized by working directory.
@ -319,14 +332,6 @@ Long sessions can exhaust context windows. Compaction summarizes older messages
When disabled, neither case triggers automatic compaction (use `/compact` manually if needed).
**How it works:**
1. Cut point calculated to keep ~20k tokens of recent messages
2. Messages before cut point are summarized
3. Summary replaces old messages as "context handoff"
4. Previous compaction summaries chain into new ones
Compaction does not create a new session, but continues the existing one, with a marker in the `.jsonl` file that encodes the compaction point.
**Configuration** (`~/.pi/agent/settings.json`):
```json
@ -339,11 +344,20 @@ Compaction does not create a new session, but continues the existing one, with a
}
```
> **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/branch` to revisit any previous point.
> **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/tree` to revisit any previous point.
See [docs/compaction.md](docs/compaction.md) for how compaction works internally and how to customize it via hooks.
### Branching
Use `/branch` to explore alternative conversation paths:
**In-place navigation (`/tree`):** Navigate the session tree without creating new files. Select any previous point, continue from there, and switch between branches while preserving all history.
- Search by typing, page with ←/→
- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all
- Press `l` to label entries as bookmarks
- When switching branches, you're prompted whether to generate a summary of the abandoned branch (messages up to the common ancestor)
**Create new session (`/branch`):** Branch to a new session file:
1. Opens selector showing all your user messages
2. Select a message to branch from
@ -473,6 +487,81 @@ Add custom models (Ollama, vLLM, LM Studio, etc.) via `~/.pi/agent/models.json`:
> pi can help you create custom provider and model configurations.
### Settings File
Settings are loaded from two locations and merged:
1. **Global:** `~/.pi/agent/settings.json` - user preferences
2. **Project:** `<cwd>/.pi/settings.json` - project-specific overrides (version control friendly)
Project settings override global settings. For nested objects, individual keys merge. Settings changed via TUI (model, thinking level, etc.) are saved to global preferences only.
Global `~/.pi/agent/settings.json` stores persistent preferences:
```json
{
"theme": "dark",
"defaultProvider": "anthropic",
"defaultModel": "claude-sonnet-4-20250514",
"defaultThinkingLevel": "medium",
"enabledModels": ["anthropic/*", "*gpt*", "gemini-2.5-pro:high"],
"steeringMode": "one-at-a-time",
"followUpMode": "one-at-a-time",
"shellPath": "C:\\path\\to\\bash.exe",
"hideThinkingBlock": false,
"collapseChangelog": false,
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
},
"skills": {
"enabled": true
},
"retry": {
"enabled": true,
"maxRetries": 3,
"baseDelayMs": 2000
},
"terminal": {
"showImages": true
},
"images": {
"autoResize": true
},
"hooks": ["/path/to/hook.ts"],
"customTools": ["/path/to/tool.ts"]
}
```
| Setting | Description | Default |
|---------|-------------|---------|
| `theme` | Color theme name | auto-detected |
| `defaultProvider` | Default model provider | - |
| `defaultModel` | Default model ID | - |
| `defaultThinkingLevel` | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | - |
| `enabledModels` | Model patterns for cycling. Supports glob patterns (`github-copilot/*`, `*sonnet*`) and fuzzy matching. Same as `--models` CLI flag | - |
| `steeringMode` | Steering message delivery: `all` or `one-at-a-time` | `one-at-a-time` |
| `followUpMode` | Follow-up message delivery: `all` or `one-at-a-time` | `one-at-a-time` |
| `shellPath` | Custom bash path (Windows) | auto-detected |
| `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` |
| `collapseChangelog` | Show condensed changelog after update | `false` |
| `compaction.enabled` | Enable auto-compaction | `true` |
| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` |
| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` |
| `skills.enabled` | Enable skills discovery | `true` |
| `retry.enabled` | Auto-retry on transient errors | `true` |
| `retry.maxRetries` | Maximum retry attempts | `3` |
| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
| `terminal.showImages` | Render images inline (supported terminals) | `true` |
| `images.autoResize` | Auto-resize images to 2000x2000 max for better model compatibility | `true` |
| `hooks` | Additional hook file paths | `[]` |
| `customTools` | Additional custom tool file paths | `[]` |
---
## Extensions
### Themes
Built-in themes: `dark` (default), `light`. Auto-detected on first run.
@ -612,18 +701,29 @@ export default function (pi: HookAPI) {
**Sending messages from hooks:**
Use `pi.send(text, attachments?)` to inject messages into the session. If the agent is streaming, the message is queued; otherwise a new agent loop starts immediately.
Use `pi.sendMessage(message, options?)` to inject messages into the session. Messages are persisted as `CustomMessageEntry` and sent to the LLM.
Options:
- `triggerTurn`: If true and agent is idle, starts a new agent turn. Default: false.
- `deliverAs`: When agent is streaming, controls delivery timing:
- `"steer"` (default): Delivered after current tool execution, interrupts remaining tools.
- `"followUp"`: Delivered only after agent finishes all work.
```typescript
import * as fs from "node:fs";
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
pi.on("session", async (event) => {
if (event.reason !== "start") return;
pi.on("session_start", async () => {
fs.watch("/tmp/trigger.txt", () => {
const content = fs.readFileSync("/tmp/trigger.txt", "utf-8").trim();
if (content) pi.send(content);
if (content) {
pi.sendMessage({
customType: "file-trigger",
content,
display: true,
}, true); // triggerTurn: start agent loop
}
});
});
}
@ -659,10 +759,11 @@ const factory: CustomToolFactory = (pi) => ({
name: Type.String({ description: "Name to greet" }),
}),
async execute(toolCallId, params) {
async execute(toolCallId, params, onUpdate, ctx, signal) {
const { name } = params as { name: string };
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: { greeted: params.name },
content: [{ type: "text", text: `Hello, ${name}!` }],
details: { greeted: name },
};
},
});
@ -682,73 +783,6 @@ export default factory;
> See [examples/custom-tools/](examples/custom-tools/) for working examples including a todo list with session state management and a question tool with UI interaction.
### Settings File
Settings are loaded from two locations and merged:
1. **Global:** `~/.pi/agent/settings.json` - user preferences
2. **Project:** `<cwd>/.pi/settings.json` - project-specific overrides (version control friendly)
Project settings override global settings. For nested objects, individual keys merge. Settings changed via TUI (model, thinking level, etc.) are saved to global preferences only.
Global `~/.pi/agent/settings.json` stores persistent preferences:
```json
{
"theme": "dark",
"defaultProvider": "anthropic",
"defaultModel": "claude-sonnet-4-20250514",
"defaultThinkingLevel": "medium",
"enabledModels": ["claude-sonnet", "gpt-4o", "gemini-2.5-pro:high"],
"queueMode": "one-at-a-time",
"shellPath": "C:\\path\\to\\bash.exe",
"hideThinkingBlock": false,
"collapseChangelog": false,
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
},
"skills": {
"enabled": true
},
"retry": {
"enabled": true,
"maxRetries": 3,
"baseDelayMs": 2000
},
"terminal": {
"showImages": true
},
"hooks": ["/path/to/hook.ts"],
"hookTimeout": 30000,
"customTools": ["/path/to/tool.ts"]
}
```
| Setting | Description | Default |
|---------|-------------|---------|
| `theme` | Color theme name | auto-detected |
| `defaultProvider` | Default model provider | - |
| `defaultModel` | Default model ID | - |
| `defaultThinkingLevel` | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | - |
| `enabledModels` | Model patterns for cycling (same as `--models` CLI flag) | - |
| `queueMode` | Message queue mode: `all` or `one-at-a-time` | `one-at-a-time` |
| `shellPath` | Custom bash path (Windows) | auto-detected |
| `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` |
| `collapseChangelog` | Show condensed changelog after update | `false` |
| `compaction.enabled` | Enable auto-compaction | `true` |
| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` |
| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` |
| `skills.enabled` | Enable skills discovery | `true` |
| `retry.enabled` | Auto-retry on transient errors | `true` |
| `retry.maxRetries` | Maximum retry attempts | `3` |
| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
| `terminal.showImages` | Render images inline (supported terminals) | `true` |
| `hooks` | Additional hook file paths | `[]` |
| `hookTimeout` | Timeout for hook operations (ms) | `30000` |
| `customTools` | Additional custom tool file paths | `[]` |
---
## CLI Reference
@ -773,7 +807,7 @@ pi [options] [@files...] [messages...]
| `--session-dir <dir>` | Directory for session storage and lookup |
| `--continue`, `-c` | Continue most recent session |
| `--resume`, `-r` | Select session to resume |
| `--models <patterns>` | Comma-separated patterns for Ctrl+P cycling (e.g., `sonnet:high,haiku:low`) |
| `--models <patterns>` | Comma-separated patterns for Ctrl+P cycling. Supports glob patterns (e.g., `anthropic/*`, `*sonnet*:high`) and fuzzy matching (e.g., `sonnet,haiku:low`) |
| `--tools <tools>` | Comma-separated tool list (default: `read,bash,edit,write`) |
| `--thinking <level>` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` |
| `--hook <path>` | Load a hook file (can be used multiple times) |
@ -825,6 +859,9 @@ pi --provider openai --model gpt-4o "Help me refactor"
# Model cycling with thinking levels
pi --models sonnet:high,haiku:low
# Limit to specific provider with glob pattern
pi --models "github-copilot/*"
# Read-only mode
pi --tools read,grep,find,ls -p "Review the architecture"

View file

@ -0,0 +1,388 @@
# Compaction & Branch Summarization
LLMs have limited context windows. When conversations grow too long, pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization.
**Source files:**
- [`src/core/compaction/compaction.ts`](../src/core/compaction/compaction.ts) - Auto-compaction logic
- [`src/core/compaction/branch-summarization.ts`](../src/core/compaction/branch-summarization.ts) - Branch summarization
- [`src/core/compaction/utils.ts`](../src/core/compaction/utils.ts) - Shared utilities (file tracking, serialization)
- [`src/core/session-manager.ts`](../src/core/session-manager.ts) - Entry types (`CompactionEntry`, `BranchSummaryEntry`)
- [`src/core/hooks/types.ts`](../src/core/hooks/types.ts) - Hook event types
## Overview
Pi has two summarization mechanisms:
| Mechanism | Trigger | Purpose |
|-----------|---------|---------|
| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context |
| Branch summarization | `/tree` navigation | Preserve context when switching branches |
Both use the same structured summary format and track file operations cumulatively.
## Compaction
### When It Triggers
Auto-compaction triggers when:
```
contextTokens > contextWindow - reserveTokens
```
By default, `reserveTokens` is 16384 tokens (configurable in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`). This leaves room for the LLM's response.
You can also trigger manually with `/compact [instructions]`, where optional instructions focus the summary.
### How It Works
1. **Find cut point**: Walk backwards from newest message, accumulating token estimates until `keepRecentTokens` (default 20k, configurable in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`) is reached
2. **Extract messages**: Collect messages from previous compaction (or start) up to cut point
3. **Generate summary**: Call LLM to summarize with structured format
4. **Append entry**: Save `CompactionEntry` with summary and `firstKeptEntryId`
5. **Reload**: Session reloads, using summary + messages from `firstKeptEntryId` onwards
```
Before compaction:
entry: 0 1 2 3 4 5 6 7 8 9
┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┐
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘
└────────┬───────┘ └──────────────┬──────────────┘
messagesToSummarize kept messages
firstKeptEntryId (entry 4)
After compaction (new entry appended):
entry: 0 1 2 3 4 5 6 7 8 9 10
┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐
│ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │
└─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘
└──────────┬──────┘ └──────────────────────┬───────────────────┘
not sent to LLM sent to LLM
starts from firstKeptEntryId
What the LLM sees:
┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
│ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
└────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
↑ ↑ └─────────────────┬────────────────┘
prompt from cmp messages from firstKeptEntryId
```
### Split Turns
A "turn" starts with a user message and includes all assistant responses and tool calls until the next user message. Normally, compaction cuts at turn boundaries.
When a single turn exceeds `keepRecentTokens`, the cut point lands mid-turn at an assistant message. This is a "split turn":
```
Split turn (one huge turn exceeds budget):
entry: 0 1 2 3 4 5 6 7 8
┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐
│ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │
└─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘
↑ ↑
turnStartIndex = 1 firstKeptEntryId = 7
│ │
└──── turnPrefixMessages (1-6) ───────┘
└── kept (7-8)
isSplitTurn = true
messagesToSummarize = [] (no complete turns before)
turnPrefixMessages = [usr, ass, tool, ass, tool, tool]
```
For split turns, pi generates two summaries and merges them:
1. **History summary**: Previous context (if any)
2. **Turn prefix summary**: The early part of the split turn
### Cut Point Rules
Valid cut points are:
- User messages
- Assistant messages
- BashExecution messages
- Hook messages (custom_message, branch_summary)
Never cut at tool results (they must stay with their tool call).
### CompactionEntry Structure
Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts):
```typescript
interface CompactionEntry<T = unknown> {
type: "compaction";
id: string;
parentId: string;
timestamp: number;
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
fromHook?: boolean; // true if hook provided the compaction
details?: T; // hook-specific data
}
// Default compaction uses this for details (from compaction.ts):
interface CompactionDetails {
readFiles: string[];
modifiedFiles: string[];
}
```
Hooks can store any JSON-serializable data in `details`. The default compaction tracks file operations, but custom compaction hooks can use their own structure.
See [`prepareCompaction()`](../src/core/compaction/compaction.ts) and [`compact()`](../src/core/compaction/compaction.ts) for the implementation.
## Branch Summarization
### When It Triggers
When you use `/tree` to navigate to a different branch, pi offers to summarize the work you're leaving. This injects context from the left branch into the new branch.
### How It Works
1. **Find common ancestor**: Deepest node shared by old and new positions
2. **Collect entries**: Walk from old leaf back to common ancestor
3. **Prepare with budget**: Include messages up to token budget (newest first)
4. **Generate summary**: Call LLM with structured format
5. **Append entry**: Save `BranchSummaryEntry` at navigation point
```
Tree before navigation:
┌─ B ─ C ─ D (old leaf, being abandoned)
A ───┤
└─ E ─ F (target)
Common ancestor: A
Entries to summarize: B, C, D
After navigation with summary:
┌─ B ─ C ─ D ─ [summary of B,C,D]
A ───┤
└─ E ─ F (new leaf)
```
### Cumulative File Tracking
Both compaction and branch summarization track files cumulatively. When generating a summary, pi extracts file operations from:
- Tool calls in the messages being summarized
- Previous compaction or branch summary `details` (if any)
This means file tracking accumulates across multiple compactions or nested branch summaries, preserving the full history of read and modified files.
### BranchSummaryEntry Structure
Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts):
```typescript
interface BranchSummaryEntry<T = unknown> {
type: "branch_summary";
id: string;
parentId: string;
timestamp: number;
summary: string;
fromId: string; // Entry we navigated from
fromHook?: boolean; // true if hook provided the summary
details?: T; // hook-specific data
}
// Default branch summarization uses this for details (from branch-summarization.ts):
interface BranchSummaryDetails {
readFiles: string[];
modifiedFiles: string[];
}
```
Same as compaction, hooks can store custom data in `details`.
See [`collectEntriesForBranchSummary()`](../src/core/compaction/branch-summarization.ts), [`prepareBranchEntries()`](../src/core/compaction/branch-summarization.ts), and [`generateBranchSummary()`](../src/core/compaction/branch-summarization.ts) for the implementation.
## Summary Format
Both compaction and branch summarization use the same structured format:
```markdown
## Goal
[What the user is trying to accomplish]
## Constraints & Preferences
- [Requirements mentioned by user]
## Progress
### Done
- [x] [Completed tasks]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues, if any]
## Key Decisions
- **[Decision]**: [Rationale]
## Next Steps
1. [What should happen next]
## Critical Context
- [Data needed to continue]
<read-files>
path/to/file1.ts
path/to/file2.ts
</read-files>
<modified-files>
path/to/changed.ts
</modified-files>
```
### Message Serialization
Before summarization, messages are serialized to text via [`serializeConversation()`](../src/core/compaction/utils.ts):
```
[User]: What they said
[Assistant thinking]: Internal reasoning
[Assistant]: Response text
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
[Tool result]: Output from tool
```
This prevents the model from treating it as a conversation to continue.
## Custom Summarization via Hooks
Hooks can intercept and customize both compaction and branch summarization. See [`src/core/hooks/types.ts`](../src/core/hooks/types.ts) for event type definitions.
### session_before_compact
Fired before auto-compaction or `/compact`. Can cancel or provide custom summary. See `SessionBeforeCompactEvent` and `CompactionPreparation` in the types file.
```typescript
pi.on("session_before_compact", async (event, ctx) => {
const { preparation, branchEntries, customInstructions, signal } = event;
// preparation.messagesToSummarize - messages to summarize
// preparation.turnPrefixMessages - split turn prefix (if isSplitTurn)
// preparation.previousSummary - previous compaction summary
// preparation.fileOps - extracted file operations
// preparation.tokensBefore - context tokens before compaction
// preparation.firstKeptEntryId - where kept messages start
// preparation.settings - compaction settings
// branchEntries - all entries on current branch (for custom state)
// signal - AbortSignal (pass to LLM calls)
// Cancel:
return { cancel: true };
// Custom summary:
return {
compaction: {
summary: "Your summary...",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
details: { /* custom data */ },
}
};
});
```
#### Converting Messages to Text
To generate a summary with your own model, convert messages to text using `serializeConversation`:
```typescript
import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
pi.on("session_before_compact", async (event, ctx) => {
const { preparation } = event;
// Convert AgentMessage[] to Message[], then serialize to text
const conversationText = serializeConversation(
convertToLlm(preparation.messagesToSummarize)
);
// Returns:
// [User]: message text
// [Assistant thinking]: thinking content
// [Assistant]: response text
// [Assistant tool calls]: read(path="..."); bash(command="...")
// [Tool result]: output text
// Now send to your model for summarization
const summary = await myModel.summarize(conversationText);
return {
compaction: {
summary,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
}
};
});
```
See [examples/hooks/custom-compaction.ts](../examples/hooks/custom-compaction.ts) for a complete example using a different model.
### session_before_tree
Fired before `/tree` navigation. Always fires regardless of whether user chose to summarize. Can cancel navigation or provide custom summary.
```typescript
pi.on("session_before_tree", async (event, ctx) => {
const { preparation, signal } = event;
// preparation.targetId - where we're navigating to
// preparation.oldLeafId - current position (being abandoned)
// preparation.commonAncestorId - shared ancestor
// preparation.entriesToSummarize - entries that would be summarized
// preparation.userWantsSummary - whether user chose to summarize
// Cancel navigation entirely:
return { cancel: true };
// Provide custom summary (only used if userWantsSummary is true):
if (preparation.userWantsSummary) {
return {
summary: {
summary: "Your summary...",
details: { /* custom data */ },
}
};
}
});
```
See `SessionBeforeTreeEvent` and `TreePreparation` in the types file.
## Settings
Configure compaction in `~/.pi/agent/settings.json` or `<project-dir>/.pi/settings.json`:
```json
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
}
}
```
| Setting | Default | Description |
|---------|---------|-------------|
| `enabled` | `true` | Enable auto-compaction |
| `reserveTokens` | `16384` | Tokens to reserve for LLM response |
| `keepRecentTokens` | `20000` | Recent tokens to keep (not summarized) |
Disable auto-compaction with `"enabled": false`. You can still compact manually with `/compact`.

View file

@ -1,13 +1,21 @@
> pi can create custom tools. Ask it to build one for your use case.
# Custom Tools
Custom tools are additional tools that the LLM can call directly, just like the built-in `read`, `write`, `edit`, and `bash` tools. They are TypeScript modules that define callable functions with parameters, return values, and optional TUI rendering.
**Key capabilities:**
- **User interaction** - Prompt users via `pi.ui` (select, confirm, input dialogs)
- **Custom rendering** - Control how tool calls and results appear via `renderCall`/`renderResult`
- **TUI components** - Render custom components with `pi.ui.custom()` (see [tui.md](tui.md))
- **State management** - Persist state in tool result `details` for proper branching support
- **Streaming results** - Send partial updates via `onUpdate` callback
**Example use cases:**
- Ask the user questions with selectable options
- Maintain state across calls (todo lists, connection pools)
- Custom TUI rendering (progress indicators, structured output)
- Integrate external services with proper error handling
- Tools that need user confirmation before proceeding
- Interactive dialogs (questions with selectable options)
- Stateful tools (todo lists, connection pools)
- Rich output rendering (progress indicators, structured views)
- External service integrations with confirmation flows
**When to use custom tools vs. alternatives:**
@ -36,10 +44,11 @@ const factory: CustomToolFactory = (pi) => ({
name: Type.String({ description: "Name to greet" }),
}),
async execute(toolCallId, params) {
async execute(toolCallId, params, onUpdate, ctx, signal) {
const { name } = params as { name: string };
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: { greeted: params.name },
content: [{ type: "text", text: `Hello, ${name}!` }],
details: { greeted: name },
};
},
});
@ -82,7 +91,7 @@ Custom tools can import from these packages (automatically resolved by pi):
| Package | Purpose |
|---------|---------|
| `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) |
| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `ToolSessionEvent` (alias for `SessionEvent`), etc.) |
| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `CustomTool`, `CustomToolContext`, etc.) |
| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) |
| `@mariozechner/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) |
@ -94,7 +103,12 @@ Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.
import { Type } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import { Text } from "@mariozechner/pi-tui";
import type { CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";
import type {
CustomTool,
CustomToolContext,
CustomToolFactory,
CustomToolSessionEvent,
} from "@mariozechner/pi-coding-agent";
const factory: CustomToolFactory = (pi) => ({
name: "my_tool",
@ -106,9 +120,10 @@ const factory: CustomToolFactory = (pi) => ({
text: Type.Optional(Type.String()),
}),
async execute(toolCallId, params, signal, onUpdate) {
async execute(toolCallId, params, onUpdate, ctx, signal) {
// signal - AbortSignal for cancellation
// onUpdate - Callback for streaming partial results
// ctx - CustomToolContext with sessionManager, modelRegistry, model
return {
content: [{ type: "text", text: "Result for LLM" }],
details: { /* structured data for rendering */ },
@ -116,12 +131,12 @@ const factory: CustomToolFactory = (pi) => ({
},
// Optional: Session lifecycle callback
onSession(event) {
onSession(event, ctx) {
if (event.reason === "shutdown") {
// Cleanup resources (close connections, save state, etc.)
return;
}
// Reconstruct state from entries for other events
// Reconstruct state from ctx.sessionManager.getBranch()
},
// Optional: Custom rendering
@ -134,12 +149,12 @@ export default factory;
**Important:** Use `StringEnum` from `@mariozechner/pi-ai` instead of `Type.Union`/`Type.Literal` for string enums. The latter doesn't work with Google's API.
## ToolAPI Object
## CustomToolAPI Object
The factory receives a `ToolAPI` object (named `pi` by convention):
The factory receives a `CustomToolAPI` object (named `pi` by convention):
```typescript
interface ToolAPI {
interface CustomToolAPI {
cwd: string; // Current working directory
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
ui: ToolUIContext;
@ -174,7 +189,7 @@ Always check `pi.hasUI` before using UI methods.
Pass the `signal` from `execute` to `pi.exec` to support cancellation:
```typescript
async execute(toolCallId, params, signal) {
async execute(toolCallId, params, onUpdate, ctx, signal) {
const result = await pi.exec("long-running-command", ["arg"], { signal });
if (result.killed) {
return { content: [{ type: "text", text: "Cancelled" }] };
@ -183,27 +198,102 @@ async execute(toolCallId, params, signal) {
}
```
### Error Handling
**Throw an error** when the tool fails. Do not return an error message as content.
```typescript
async execute(toolCallId, params, onUpdate, ctx, signal) {
const { path } = params as { path: string };
// Throw on error - pi will catch it and report to the LLM
if (!fs.existsSync(path)) {
throw new Error(`File not found: ${path}`);
}
// Return content only on success
return { content: [{ type: "text", text: "Success" }] };
}
```
Thrown errors are:
- Reported to the LLM as tool errors (with `isError: true`)
- Emitted to hooks via `tool_result` event (hooks can inspect `event.isError`)
- Displayed in the TUI with error styling
## CustomToolContext
The `execute` and `onSession` callbacks receive a `CustomToolContext`:
```typescript
interface CustomToolContext {
sessionManager: ReadonlySessionManager; // Read-only access to session
modelRegistry: ModelRegistry; // For API key resolution
model: Model | undefined; // Current model (may be undefined)
isIdle(): boolean; // Whether agent is streaming
hasQueuedMessages(): boolean; // Whether user has queued messages
abort(): void; // Abort current operation (fire-and-forget)
}
```
Use `ctx.sessionManager.getBranch()` to get entries on the current branch for state reconstruction.
### Checking Queue State
Interactive tools can skip prompts when the user has already queued a message:
```typescript
async execute(toolCallId, params, onUpdate, ctx, signal) {
// If user already queued a message, skip the interactive prompt
if (ctx.hasQueuedMessages()) {
return {
content: [{ type: "text", text: "Skipped - user has queued input" }],
};
}
// Otherwise, prompt for input
const answer = await pi.ui.input("What would you like to do?");
// ...
}
```
### Multi-line Editor
For longer text editing, use `pi.ui.editor()` which supports Ctrl+G for external editor:
```typescript
async execute(toolCallId, params, onUpdate, ctx, signal) {
const text = await pi.ui.editor("Edit your response:", "prefilled text");
// Returns edited text or undefined if cancelled (Escape)
// Ctrl+Enter to submit, Ctrl+G to open $VISUAL or $EDITOR
if (!text) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
// ...
}
```
## Session Lifecycle
Tools can implement `onSession` to react to session changes:
```typescript
interface SessionEvent {
entries: SessionEntry[]; // All session entries
sessionFile: string | undefined; // Current session file (undefined with --no-session)
previousSessionFile: string | undefined; // Previous session file
reason: "start" | "switch" | "branch" | "new" | "tree";
interface CustomToolSessionEvent {
reason: "start" | "switch" | "branch" | "tree" | "shutdown";
previousSessionFile: string | undefined;
}
```
**Reasons:**
- `start`: Initial session load on startup
- `switch`: User switched to a different session (`/resume`)
- `switch`: User started a new session (`/new`) or switched to a different session (`/resume`)
- `branch`: User branched from a previous message (`/branch`)
- `new`: User started a new session (`/new`)
- `tree`: User navigated to a different point in the session tree (`/tree`)
- `shutdown`: Process is exiting (Ctrl+C, Ctrl+D, or SIGTERM) - use to cleanup resources
To check if a session is fresh (no messages), use `ctx.sessionManager.getEntries().length === 0`.
### State Management Pattern
Tools that maintain state should store it in `details` of their results, not external files. This allows branching to work correctly, as the state is reconstructed from the session history.
@ -218,9 +308,11 @@ const factory: CustomToolFactory = (pi) => {
let items: string[] = [];
// Reconstruct state from session entries
const reconstructState = (event: ToolSessionEvent) => {
const reconstructState = (event: CustomToolSessionEvent, ctx: CustomToolContext) => {
if (event.reason === "shutdown") return;
items = [];
for (const entry of event.entries) {
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
const msg = entry.message;
if (msg.role !== "toolResult") continue;
@ -241,7 +333,7 @@ const factory: CustomToolFactory = (pi) => {
onSession: reconstructState,
async execute(toolCallId, params) {
async execute(toolCallId, params, onUpdate, ctx, signal) {
// Modify items...
items.push("new item");
@ -262,7 +354,7 @@ This pattern ensures:
## Custom Rendering
Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional.
Custom tools can provide `renderCall` and `renderResult` methods to control how they appear in the TUI. Both are optional. See [tui.md](tui.md) for the full component API.
### How It Works
@ -363,7 +455,7 @@ If `renderCall` or `renderResult` is not defined or throws an error:
## Execute Function
```typescript
async execute(toolCallId, args, signal, onUpdate) {
async execute(toolCallId, args, onUpdate, ctx, signal) {
// Type assertion for params (TypeBox schema doesn't flow through)
const params = args as { action: "list" | "add"; text?: string };
@ -395,7 +487,7 @@ const factory: CustomToolFactory = (pi) => {
// Shared state
let connection = null;
const handleSession = (event: ToolSessionEvent) => {
const handleSession = (event: CustomToolSessionEvent, ctx: CustomToolContext) => {
if (event.reason === "shutdown") {
connection?.close();
}

File diff suppressed because it is too large Load diff

View file

@ -76,22 +76,27 @@ Response:
{"type": "response", "command": "abort", "success": true}
```
#### reset
#### new_session
Clear context and start a fresh session. Can be cancelled by a `before_clear` hook.
Start a fresh session. Can be cancelled by a `session_before_switch` hook.
```json
{"type": "reset"}
{"type": "new_session"}
```
With optional parent session tracking:
```json
{"type": "new_session", "parentSession": "/path/to/parent-session.jsonl"}
```
Response:
```json
{"type": "response", "command": "reset", "success": true, "data": {"cancelled": false}}
{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": false}}
```
If a hook cancelled the reset:
If a hook cancelled:
```json
{"type": "response", "command": "reset", "success": true, "data": {"cancelled": true}}
{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": true}}
```
### State
@ -145,7 +150,7 @@ Response:
}
```
Messages are `AppMessage` objects (see [Message Types](#message-types)).
Messages are `AgentMessage` objects (see [Message Types](#message-types)).
### Model
@ -289,8 +294,10 @@ Response:
"command": "compact",
"success": true,
"data": {
"summary": "Summary of conversation...",
"firstKeptEntryId": "abc123",
"tokensBefore": 150000,
"summary": "Summary of conversation..."
"details": {}
}
}
```
@ -491,7 +498,7 @@ If a hook cancelled the switch:
Create a new branch from a previous user message. Can be cancelled by a `before_branch` hook. Returns the text of the message being branched from.
```json
{"type": "branch", "entryIndex": 2}
{"type": "branch", "entryId": "abc123"}
```
Response:
@ -530,8 +537,8 @@ Response:
"success": true,
"data": {
"messages": [
{"entryIndex": 0, "text": "First prompt..."},
{"entryIndex": 2, "text": "Second prompt..."}
{"entryId": "abc123", "text": "First prompt..."},
{"entryId": "def456", "text": "Second prompt..."}
]
}
}
@ -618,7 +625,7 @@ A turn consists of one assistant response plus any resulting tool calls and resu
### message_start / message_end
Emitted when a message begins and completes. The `message` field contains an `AppMessage`.
Emitted when a message begins and completes. The `message` field contains an `AgentMessage`.
```json
{"type": "message_start", "message": {...}}
@ -717,20 +724,27 @@ Use `toolCallId` to correlate events. The `partialResult` in `tool_execution_upd
Emitted when automatic compaction runs (when context is nearly full).
```json
{"type": "auto_compaction_start"}
{"type": "auto_compaction_start", "reason": "threshold"}
```
The `reason` field is `"threshold"` (context getting large) or `"overflow"` (context exceeded limit).
```json
{
"type": "auto_compaction_end",
"result": {
"summary": "Summary of conversation...",
"firstKeptEntryId": "abc123",
"tokensBefore": 150000,
"summary": "Summary of conversation..."
"details": {}
},
"aborted": false
"aborted": false,
"willRetry": false
}
```
If `reason` was `"overflow"` and compaction succeeds, `willRetry` is `true` and the agent will automatically retry the prompt.
If compaction was aborted, `result` is `null` and `aborted` is `true`.
### auto_retry_start / auto_retry_end
@ -806,7 +820,7 @@ Parse errors:
Source files:
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `Model`, `UserMessage`, `AssistantMessage`, `ToolResultMessage`
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AppMessage`, `Attachment`, `AgentEvent`
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `AgentEvent`
- [`src/core/messages.ts`](../src/core/messages.ts) - `BashExecutionMessage`
- [`src/modes/rpc/rpc-types.ts`](../src/modes/rpc/rpc-types.ts) - RPC command/response types

View file

@ -1,3 +1,5 @@
> pi can help you use the SDK. Ask it to build an integration for your use case.
# SDK
The SDK provides programmatic access to pi's agent capabilities. Use it to embed pi in other applications, build custom interfaces, or integrate with automated workflows.
@ -76,7 +78,6 @@ The session manages the agent lifecycle, message history, and event streaming.
interface AgentSession {
// Send a prompt and wait for completion
prompt(text: string, options?: PromptOptions): Promise<void>;
prompt(message: AppMessage): Promise<void>; // For HookMessage, etc.
// Subscribe to events (returns unsubscribe function)
subscribe(listener: (event: AgentSessionEvent) => void): () => void;
@ -88,25 +89,26 @@ interface AgentSession {
// Model control
setModel(model: Model): Promise<void>;
setThinkingLevel(level: ThinkingLevel): void;
cycleModel(): Promise<ModelCycleResult | null>;
cycleThinkingLevel(): ThinkingLevel | null;
cycleModel(): Promise<ModelCycleResult | undefined>;
cycleThinkingLevel(): ThinkingLevel | undefined;
// State access
agent: Agent;
model: Model | null;
model: Model | undefined;
thinkingLevel: ThinkingLevel;
messages: AppMessage[];
messages: AgentMessage[];
isStreaming: boolean;
// Session management
newSession(): Promise<boolean>; // Returns false if cancelled by hook
newSession(options?: { parentSession?: string }): Promise<boolean>; // Returns false if cancelled by hook
switchSession(sessionPath: string): Promise<boolean>;
// Branching (tree-based)
branch(entryId: string): Promise<{ cancelled: boolean }>;
// Branching
branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }>; // Creates new session file
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation
// Hook message injection
sendHookMessage(message: HookMessage, triggerTurn?: boolean): void;
sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise<void>;
// Compaction
compact(customInstructions?: string): Promise<CompactionResult>;
@ -128,7 +130,7 @@ The `Agent` class (from `@mariozechner/pi-agent-core`) handles the core LLM inte
// Access current state
const state = session.agent.state;
// state.messages: AppMessage[] - conversation history
// state.messages: AgentMessage[] - conversation history
// state.model: Model - current model
// state.thinkingLevel: ThinkingLevel - current thinking level
// state.systemPrompt: string - system prompt
@ -400,10 +402,10 @@ const { session } = await createAgentSession({
```typescript
import { Type } from "@sinclair/typebox";
import { createAgentSession, discoverCustomTools, type CustomAgentTool } from "@mariozechner/pi-coding-agent";
import { createAgentSession, discoverCustomTools, type CustomTool } from "@mariozechner/pi-coding-agent";
// Inline custom tool
const myTool: CustomAgentTool = {
const myTool: CustomTool = {
name: "my_tool",
label: "My Tool",
description: "Does something useful",
@ -793,7 +795,7 @@ import {
readTool,
bashTool,
type HookFactory,
type CustomAgentTool,
type CustomTool,
} from "@mariozechner/pi-coding-agent";
// Set up auth storage (custom location)
@ -816,7 +818,7 @@ const auditHook: HookFactory = (api) => {
};
// Inline tool
const statusTool: CustomAgentTool = {
const statusTool: CustomTool = {
name: "status",
label: "Status",
description: "Get system status",
@ -932,7 +934,7 @@ createGrepTool, createFindTool, createLsTool
// Types
type CreateAgentSessionOptions
type CreateAgentSessionResult
type CustomAgentTool
type CustomTool
type HookFactory
type Skill
type FileSlashCommand

View file

@ -48,10 +48,10 @@ First line of the file. Metadata only, not part of the tree (no `id`/`parentId`)
{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}
```
For branched sessions (created via `/branch` command):
For sessions with a parent (created via `/branch` or `newSession({ parentSession })`):
```json
{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","branchedFrom":"/path/to/original/session.jsonl"}
{"type":"session","version":2,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"}
```
### SessionMessageEntry

View file

@ -1,3 +1,5 @@
> pi can create skills. Ask it to build one for your use case.
# Skills
Skills are self-contained capability packages that the agent loads on-demand. A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks.

View file

@ -1,3 +1,5 @@
> pi can create themes. Ask it to build one for your use case.
# Pi Coding Agent Themes
Themes allow you to customize the colors used throughout the coding agent TUI.
@ -20,6 +22,7 @@ Every theme must define all color tokens. There are no optional colors.
| `muted` | Secondary/dimmed text | Metadata, descriptions, output |
| `dim` | Very dimmed text | Less important info, placeholders |
| `text` | Default text color | Main content (usually `""`) |
| `thinkingText` | Thinking block text | Assistant reasoning traces |
### Backgrounds & Content Text (11 colors)
@ -101,6 +104,27 @@ These create a visual hierarchy: off → minimal → low → medium → high →
**Total: 50 color tokens** (all required)
### HTML Export Colors (optional)
The `export` section is optional and controls colors used when exporting sessions to HTML via `/export`. If not specified, these colors are automatically derived from `userMessageBg` based on luminance detection.
| Token | Purpose |
|-------|---------|
| `pageBg` | Page background color |
| `cardBg` | Card/container background (headers, stats boxes) |
| `infoBg` | Info sections background (system prompt, notices, compaction) |
Example:
```json
{
"export": {
"pageBg": "#18181e",
"cardBg": "#1e1e24",
"infoBg": "#3c3728"
}
}
```
## Theme Format
Themes are defined in JSON files with the following structure:
@ -117,6 +141,7 @@ Themes are defined in JSON files with the following structure:
"colors": {
"accent": "blue",
"muted": "gray",
"thinkingText": "gray",
"text": "",
...
}

View file

@ -0,0 +1,343 @@
> pi can create TUI components. Ask it to build one for your use case.
# TUI Components
Hooks and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks.
**Source:** [`@mariozechner/pi-tui`](https://github.com/badlogic/pi-mono/tree/main/packages/tui)
## Component Interface
All components implement:
```typescript
interface Component {
render(width: number): string[];
handleInput?(data: string): void;
invalidate?(): void;
}
```
| Method | Description |
|--------|-------------|
| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. |
| `handleInput?(data)` | Receive keyboard input when component has focus. |
| `invalidate?()` | Clear cached render state. |
## Using Components
**In hooks** via `ctx.ui.custom()`:
```typescript
pi.on("session_start", async (_event, ctx) => {
const handle = ctx.ui.custom(myComponent);
// handle.requestRender() - trigger re-render
// handle.close() - restore normal UI
});
```
**In custom tools** via `pi.ui.custom()`:
```typescript
async execute(toolCallId, params, onUpdate, ctx, signal) {
const handle = pi.ui.custom(myComponent);
// ...
handle.close();
}
```
## Built-in Components
Import from `@mariozechner/pi-tui`:
```typescript
import { Text, Box, Container, Spacer, Markdown } from "@mariozechner/pi-tui";
```
### Text
Multi-line text with word wrapping.
```typescript
const text = new Text(
"Hello World", // content
1, // paddingX (default: 1)
1, // paddingY (default: 1)
(s) => bgGray(s) // optional background function
);
text.setText("Updated");
```
### Box
Container with padding and background color.
```typescript
const box = new Box(
1, // paddingX
1, // paddingY
(s) => bgGray(s) // background function
);
box.addChild(new Text("Content", 0, 0));
box.setBgFn((s) => bgBlue(s));
```
### Container
Groups child components vertically.
```typescript
const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);
```
### Spacer
Empty vertical space.
```typescript
const spacer = new Spacer(2); // 2 empty lines
```
### Markdown
Renders markdown with syntax highlighting.
```typescript
const md = new Markdown(
"# Title\n\nSome **bold** text",
1, // paddingX
1, // paddingY
theme // MarkdownTheme (see below)
);
md.setText("Updated markdown");
```
### Image
Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm).
```typescript
const image = new Image(
base64Data, // base64-encoded image
"image/png", // MIME type
theme, // ImageTheme
{ maxWidthCells: 80, maxHeightCells: 24 }
);
```
## Keyboard Input
Use key detection helpers:
```typescript
import {
isEnter, isEscape, isTab,
isArrowUp, isArrowDown, isArrowLeft, isArrowRight,
isCtrlC, isCtrlO, isBackspace, isDelete,
// ... and more
} from "@mariozechner/pi-tui";
handleInput(data: string) {
if (isArrowUp(data)) {
this.selectedIndex--;
} else if (isEnter(data)) {
this.onSelect?.(this.selectedIndex);
} else if (isEscape(data)) {
this.onCancel?.();
}
}
```
## Line Width
**Critical:** Each line from `render()` must not exceed the `width` parameter.
```typescript
import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
render(width: number): string[] {
// Truncate long lines
return [truncateToWidth(this.text, width)];
}
```
Utilities:
- `visibleWidth(str)` - Get display width (ignores ANSI codes)
- `truncateToWidth(str, width, ellipsis?)` - Truncate with optional ellipsis
- `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes
## Creating Custom Components
Example: Interactive selector
```typescript
import {
isEnter, isEscape, isArrowUp, isArrowDown,
truncateToWidth, visibleWidth
} from "@mariozechner/pi-tui";
class MySelector {
private items: string[];
private selected = 0;
private cachedWidth?: number;
private cachedLines?: string[];
public onSelect?: (item: string) => void;
public onCancel?: () => void;
constructor(items: string[]) {
this.items = items;
}
handleInput(data: string): void {
if (isArrowUp(data) && this.selected > 0) {
this.selected--;
this.invalidate();
} else if (isArrowDown(data) && this.selected < this.items.length - 1) {
this.selected++;
this.invalidate();
} else if (isEnter(data)) {
this.onSelect?.(this.items[this.selected]);
} else if (isEscape(data)) {
this.onCancel?.();
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
this.cachedLines = this.items.map((item, i) => {
const prefix = i === this.selected ? "> " : " ";
return truncateToWidth(prefix + item, width);
});
this.cachedWidth = width;
return this.cachedLines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
```
Usage in a hook:
```typescript
pi.registerCommand("pick", {
description: "Pick an item",
handler: async (args, ctx) => {
const items = ["Option A", "Option B", "Option C"];
const selector = new MySelector(items);
let handle: { close: () => void; requestRender: () => void };
await new Promise<void>((resolve) => {
selector.onSelect = (item) => {
ctx.ui.notify(`Selected: ${item}`, "info");
handle.close();
resolve();
};
selector.onCancel = () => {
handle.close();
resolve();
};
handle = ctx.ui.custom(selector);
});
}
});
```
## Theming
Components accept theme objects for styling.
**In `renderCall`/`renderResult`**, use the `theme` parameter:
```typescript
renderResult(result, options, theme) {
// Use theme.fg() for foreground colors
return new Text(theme.fg("success", "Done!"), 0, 0);
// Use theme.bg() for background colors
const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
}
```
**Foreground colors** (`theme.fg(color, text)`):
| Category | Colors |
|----------|--------|
| General | `text`, `accent`, `muted`, `dim` |
| Status | `success`, `error`, `warning` |
| Borders | `border`, `borderAccent`, `borderMuted` |
| Messages | `userMessageText`, `customMessageText`, `customMessageLabel` |
| Tools | `toolTitle`, `toolOutput` |
| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |
| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |
| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |
| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` |
| Modes | `bashMode` |
**Background colors** (`theme.bg(color, text)`):
`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`
**For Markdown**, use `getMarkdownTheme()`:
```typescript
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import { Markdown } from "@mariozechner/pi-tui";
renderResult(result, options, theme) {
const mdTheme = getMarkdownTheme();
return new Markdown(result.details.markdown, 0, 0, mdTheme);
}
```
**For custom components**, define your own theme interface:
```typescript
interface MyTheme {
selected: (s: string) => string;
normal: (s: string) => string;
}
```
## Performance
Cache rendered output when possible:
```typescript
class CachedComponent {
private cachedWidth?: number;
private cachedLines?: string[];
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
// ... compute lines ...
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
```
Call `invalidate()` when state changes, then `handle.requestRender()` to trigger re-render.
## Examples
- **Snake game**: [examples/hooks/snake.ts](../examples/hooks/snake.ts) - Full game with keyboard input, game loop, state persistence
- **Custom tool rendering**: [examples/custom-tools/todo/](../examples/custom-tools/todo/) - Custom `renderCall` and `renderResult`

View file

@ -1,6 +1,6 @@
# Examples
Example code for pi-coding-agent.
Example code for pi-coding-agent SDK, hooks, and custom tools.
## Directories
@ -13,13 +13,11 @@ Example hooks for intercepting tool calls, adding safety gates, and integrating
### [custom-tools/](custom-tools/)
Example custom tools that extend the agent's capabilities.
## Running Examples
## Tool + Hook Combinations
```bash
cd packages/coding-agent
npx tsx examples/sdk/01-minimal.ts
npx tsx examples/hooks/permission-gate.ts
```
Some examples are designed to work together:
- **todo/** - The [custom tool](custom-tools/todo/) lets the LLM manage a todo list, while the [hook](hooks/todo/) adds a `/todos` command for users to view todos at any time.
## Documentation

View file

@ -19,6 +19,8 @@ Full-featured example demonstrating:
- Proper branching support via details storage
- State management without external files
**Companion hook:** [hooks/todo/](../hooks/todo/) adds a `/todos` command for users to view the todo list.
### subagent/
Delegate tasks to specialized subagents with isolated context windows. Includes:
- `index.ts` - The custom tool (single, parallel, and chain modes)

View file

@ -9,10 +9,11 @@ const factory: CustomToolFactory = (_pi) => ({
name: Type.String({ description: "Name to greet" }),
}),
async execute(_toolCallId, params) {
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
const { name } = params as { name: string };
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: { greeted: params.name },
content: [{ type: "text", text: `Hello, ${name}!` }],
details: { greeted: name },
};
},
});

View file

@ -2,7 +2,7 @@
* Question Tool - Let the LLM ask the user a question with options
*/
import type { CustomAgentTool, CustomToolFactory } from "@mariozechner/pi-coding-agent";
import type { CustomTool, CustomToolFactory } from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
@ -18,13 +18,13 @@ const QuestionParams = Type.Object({
});
const factory: CustomToolFactory = (pi) => {
const tool: CustomAgentTool<typeof QuestionParams, QuestionDetails> = {
const tool: CustomTool<typeof QuestionParams, QuestionDetails> = {
name: "question",
label: "Question",
description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.",
parameters: QuestionParams,
async execute(_toolCallId, params) {
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
if (!pi.hasUI) {
return {
content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],

View file

@ -20,10 +20,10 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { Message } from "@mariozechner/pi-ai";
import { StringEnum } from "@mariozechner/pi-ai";
import {
type CustomAgentTool,
type CustomTool,
type CustomToolAPI,
type CustomToolFactory,
getMarkdownTheme,
type ToolAPI,
} from "@mariozechner/pi-coding-agent";
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
@ -224,7 +224,7 @@ function writePromptToTempFile(agentName: string, prompt: string): { dir: string
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
async function runSingleAgent(
pi: ToolAPI,
pi: CustomToolAPI,
agents: AgentConfig[],
agentName: string,
task: string,
@ -411,7 +411,7 @@ const SubagentParams = Type.Object({
});
const factory: CustomToolFactory = (pi) => {
const tool: CustomAgentTool<typeof SubagentParams, SubagentDetails> = {
const tool: CustomTool<typeof SubagentParams, SubagentDetails> = {
name: "subagent",
label: "Subagent",
get description() {
@ -433,7 +433,7 @@ const factory: CustomToolFactory = (pi) => {
},
parameters: SubagentParams,
async execute(_toolCallId, params, signal, onUpdate) {
async execute(_toolCallId, params, onUpdate, _ctx, signal) {
const agentScope: AgentScope = params.agentScope ?? "user";
const discovery = discoverAgents(pi.cwd, agentScope);
const agents = discovery.agents;

View file

@ -9,7 +9,12 @@
*/
import { StringEnum } from "@mariozechner/pi-ai";
import type { CustomAgentTool, CustomToolFactory, ToolSessionEvent } from "@mariozechner/pi-coding-agent";
import type {
CustomTool,
CustomToolContext,
CustomToolFactory,
CustomToolSessionEvent,
} from "@mariozechner/pi-coding-agent";
import { Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
@ -43,11 +48,12 @@ const factory: CustomToolFactory = (_pi) => {
* Reconstruct state from session entries.
* Scans tool results for this tool and applies them in order.
*/
const reconstructState = (event: ToolSessionEvent) => {
const reconstructState = (_event: CustomToolSessionEvent, ctx: CustomToolContext) => {
todos = [];
nextId = 1;
for (const entry of event.entries) {
// Use getBranch() to get entries on the current branch
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
const msg = entry.message;
@ -63,7 +69,7 @@ const factory: CustomToolFactory = (_pi) => {
}
};
const tool: CustomAgentTool<typeof TodoParams, TodoDetails> = {
const tool: CustomTool<typeof TodoParams, TodoDetails> = {
name: "todo",
label: "Todo",
description: "Manage a todo list. Actions: list, add (text), toggle (id), clear",
@ -72,7 +78,7 @@ const factory: CustomToolFactory = (_pi) => {
// Called on session start/switch/branch/clear
onSession: reconstructState,
async execute(_toolCallId, params) {
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
switch (params.action) {
case "list":
return {

View file

@ -2,97 +2,56 @@
Example hooks for pi-coding-agent.
## Examples
### permission-gate.ts
Prompts for confirmation before running dangerous bash commands (rm -rf, sudo, chmod 777, etc.).
### git-checkpoint.ts
Creates git stash checkpoints at each turn, allowing code restoration when branching.
### protected-paths.ts
Blocks writes to protected paths (.env, .git/, node_modules/).
### file-trigger.ts
Watches a trigger file and injects its contents into the conversation. Useful for external systems (CI, file watchers, webhooks) to send messages to the agent.
### confirm-destructive.ts
Prompts for confirmation before destructive session actions (clear, switch, branch). Demonstrates how to cancel `before_*` session events.
### dirty-repo-guard.ts
Prevents session changes when there are uncommitted git changes. Blocks clear/switch/branch until you commit.
### auto-commit-on-exit.ts
Automatically commits changes when the agent exits (shutdown event). Uses the last assistant message to generate a commit message.
### custom-compaction.ts
Custom context compaction that summarizes the entire conversation instead of keeping recent turns. Uses the `before_compact` hook event to intercept compaction and generate a comprehensive summary using `complete()` from the AI package. Useful when you want maximum context window space at the cost of losing exact conversation history.
## Usage
```bash
# Test directly
# Load a hook with --hook flag
pi --hook examples/hooks/permission-gate.ts
# Or copy to hooks directory for persistent use
# Or copy to hooks directory for auto-discovery
cp permission-gate.ts ~/.pi/agent/hooks/
```
## Examples
| Hook | Description |
|------|-------------|
| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch |
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
| `file-trigger.ts` | Watches a trigger file and injects contents into conversation |
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) |
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message |
| `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors |
| `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` |
| `todo/` | Adds `/todos` command to view todos managed by the [todo custom tool](../custom-tools/todo/) |
## Writing Hooks
See [docs/hooks.md](../../docs/hooks.md) for full documentation.
### Key Points
**Hook structure:**
```typescript
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
pi.on("session", async (event, ctx) => {
// event.reason: "start" | "before_switch" | "switch" | "before_clear" | "clear" |
// "before_branch" | "branch" | "shutdown"
// event.targetTurnIndex: number (only for before_branch/branch)
// ctx.ui, ctx.exec, ctx.cwd, ctx.sessionFile, ctx.hasUI
// Cancel before_* actions:
if (event.reason === "before_clear") {
return { cancel: true };
}
return undefined;
});
// Subscribe to events
pi.on("tool_call", async (event, ctx) => {
// Can block tool execution
if (dangerous) {
return { block: true, reason: "Blocked" };
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
if (!ok) return { block: true, reason: "Blocked by user" };
}
return undefined;
});
pi.on("tool_result", async (event, ctx) => {
// Can modify result
return { result: "modified result" };
// Register custom commands
pi.registerCommand("hello", {
description: "Say hello",
handler: async (args, ctx) => {
ctx.ui.notify("Hello!", "info");
},
});
}
```
**Available events:**
- `session` - lifecycle events with before/after variants (can cancel before_* actions)
- `agent_start` / `agent_end` - per user prompt
- `turn_start` / `turn_end` - per LLM turn
- `tool_call` - before tool execution (can block)
- `tool_result` - after tool execution (can modify)
**UI methods:**
```typescript
const choice = await ctx.ui.select("Title", ["Option A", "Option B"]);
const confirmed = await ctx.ui.confirm("Title", "Are you sure?");
const input = await ctx.ui.input("Title", "placeholder");
ctx.ui.notify("Message", "info"); // or "warning", "error"
```
**Sending messages:**
```typescript
pi.send("Message to inject into conversation");
```

View file

@ -5,7 +5,7 @@
* Uses the last assistant message to generate a commit message.
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
pi.on("session_shutdown", async (_event, ctx) => {

View file

@ -5,25 +5,26 @@
* Demonstrates how to cancel session events using the before_* events.
*/
import type { SessionMessageEntry } from "@mariozechner/pi-coding-agent";
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
pi.on("session_before_new", async (_event, ctx) => {
pi.on("session_before_switch", async (event: SessionBeforeSwitchEvent, ctx) => {
if (!ctx.hasUI) return;
const confirmed = await ctx.ui.confirm("Clear session?", "This will delete all messages in the current session.");
if (event.reason === "new") {
const confirmed = await ctx.ui.confirm(
"Clear session?",
"This will delete all messages in the current session.",
);
if (!confirmed) {
ctx.ui.notify("Clear cancelled", "info");
return { cancel: true };
if (!confirmed) {
ctx.ui.notify("Clear cancelled", "info");
return { cancel: true };
}
return;
}
});
pi.on("session_before_switch", async (_event, ctx) => {
if (!ctx.hasUI) return;
// Check if there are unsaved changes (messages since last assistant response)
// reason === "resume" - check if there are unsaved changes (messages since last assistant response)
const entries = ctx.sessionManager.getEntries();
const hasUnsavedWork = entries.some(
(e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user",
@ -45,7 +46,7 @@ export default function (pi: HookAPI) {
pi.on("session_before_branch", async (event, ctx) => {
if (!ctx.hasUI) return;
const choice = await ctx.ui.select(`Branch from turn ${event.entryIndex}?`, [
const choice = await ctx.ui.select(`Branch from entry ${event.entryId.slice(0, 8)}?`, [
"Yes, create branch",
"No, stay in current session",
]);

View file

@ -14,15 +14,14 @@
*/
import { complete, getModel } from "@mariozechner/pi-ai";
import type { CompactionEntry } from "@mariozechner/pi-coding-agent";
import { convertToLlm } from "@mariozechner/pi-coding-agent";
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
pi.on("session_before_compact", async (event, ctx) => {
ctx.ui.notify("Custom compaction hook triggered", "info");
const { preparation, branchEntries, signal } = event;
const { preparation, branchEntries: _, signal } = event;
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation;
// Use Gemini Flash for summarization (cheaper/faster than most conversation models)
@ -47,21 +46,20 @@ export default function (pi: HookAPI) {
"info",
);
// Transform app messages to pi-ai package format
const transformedMessages = convertToLlm(allMessages);
// Convert messages to readable text format
const conversationText = serializeConversation(convertToLlm(allMessages));
// Include previous summary context if available
const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : "";
// Build messages that ask for a comprehensive summary
const summaryMessages = [
...transformedMessages,
{
role: "user" as const,
content: [
{
type: "text" as const,
text: `You are a conversation summarizer. Create a comprehensive summary of this entire conversation that captures:${previousContext}
text: `You are a conversation summarizer. Create a comprehensive summary of this conversation that captures:${previousContext}
1. The main goals and objectives discussed
2. Key decisions made and their rationale
@ -72,7 +70,11 @@ export default function (pi: HookAPI) {
Be thorough but concise. The summary will replace the ENTIRE conversation history, so include all information needed to continue the work effectively.
Format the summary as structured markdown with clear sections.`,
Format the summary as structured markdown with clear sections.
<conversation>
${conversationText}
</conversation>`,
},
],
timestamp: Date.now(),

View file

@ -5,7 +5,7 @@
* Useful to ensure work is committed before switching context.
*/
import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent";
async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Promise<{ cancel: boolean } | undefined> {
// Check for uncommitted changes
@ -41,12 +41,9 @@ async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Pr
}
export default function (pi: HookAPI) {
pi.on("session_before_new", async (_event, ctx) => {
return checkDirtyRepo(pi, ctx, "new session");
});
pi.on("session_before_switch", async (_event, ctx) => {
return checkDirtyRepo(pi, ctx, "switch session");
pi.on("session_before_switch", async (event, ctx) => {
const action = event.reason === "new" ? "new session" : "switch session";
return checkDirtyRepo(pi, ctx, action);
});
pi.on("session_before_branch", async (_event, ctx) => {

View file

@ -9,7 +9,7 @@
*/
import * as fs from "node:fs";
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
pi.on("session_start", async (_event, ctx) => {
@ -25,7 +25,7 @@ export default function (pi: HookAPI) {
content: `External trigger: ${content}`,
display: true,
},
true, // triggerTurn - get LLM to respond
{ triggerTurn: true }, // triggerTurn - get LLM to respond
);
fs.writeFileSync(triggerFile, ""); // Clear after reading
}

View file

@ -5,22 +5,29 @@
* When branching, offers to restore code to that point in history.
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
const checkpoints = new Map<number, string>();
const checkpoints = new Map<string, string>();
let currentEntryId: string | undefined;
pi.on("turn_start", async (event) => {
// Track the current entry ID when user messages are saved
pi.on("tool_result", async (_event, ctx) => {
const leaf = ctx.sessionManager.getLeafEntry();
if (leaf) currentEntryId = leaf.id;
});
pi.on("turn_start", async () => {
// Create a git stash entry before LLM makes changes
const { stdout } = await pi.exec("git", ["stash", "create"]);
const ref = stdout.trim();
if (ref) {
checkpoints.set(event.turnIndex, ref);
if (ref && currentEntryId) {
checkpoints.set(currentEntryId, ref);
}
});
pi.on("session_before_branch", async (event, ctx) => {
const ref = checkpoints.get(event.entryIndex);
const ref = checkpoints.get(event.entryId);
if (!ref) return;
if (!ctx.hasUI) {

View file

@ -0,0 +1,150 @@
/**
* Handoff hook - transfer context to a new focused session
*
* Instead of compacting (which is lossy), handoff extracts what matters
* for your next task and creates a new session with a generated prompt.
*
* Usage:
* /handoff now implement this for teams as well
* /handoff execute phase one of the plan
* /handoff check other places that need this fix
*
* The generated prompt appears as a draft in the editor for review/editing.
*/
import { complete, type Message } from "@mariozechner/pi-ai";
import type { HookAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:
1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
2. Lists any relevant files that were discussed or modified
3. Clearly states the next task based on the user's goal
4. Is self-contained - the new thread should be able to proceed without the old conversation
Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.
Example output format:
## Context
We've been working on X. Key decisions:
- Decision 1
- Decision 2
Files involved:
- path/to/file1.ts
- path/to/file2.ts
## Task
[Clear description of what to do next based on user's goal]`;
export default function (pi: HookAPI) {
pi.registerCommand("handoff", {
description: "Transfer context to a new focused session",
handler: async (args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("handoff requires interactive mode", "error");
return;
}
if (!ctx.model) {
ctx.ui.notify("No model selected", "error");
return;
}
const goal = args.trim();
if (!goal) {
ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
return;
}
// Gather conversation context from current branch
const branch = ctx.sessionManager.getBranch();
const messages = branch
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
.map((entry) => entry.message);
if (messages.length === 0) {
ctx.ui.notify("No conversation to hand off", "error");
return;
}
// Convert to LLM format and serialize
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
const currentSessionFile = ctx.sessionManager.getSessionFile();
// Generate the handoff prompt with loader UI
const result = await ctx.ui.custom<string | null>((tui, theme, done) => {
const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`);
loader.onAbort = () => done(null);
const doGenerate = async () => {
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
const userMessage: Message = {
role: "user",
content: [
{
type: "text",
text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
},
],
timestamp: Date.now(),
};
const response = await complete(
ctx.model!,
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
{ apiKey, signal: loader.signal },
);
if (response.stopReason === "aborted") {
return null;
}
return response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
};
doGenerate()
.then(done)
.catch((err) => {
console.error("Handoff generation failed:", err);
done(null);
});
return loader;
});
if (result === null) {
ctx.ui.notify("Cancelled", "info");
return;
}
// Let user edit the generated prompt
const editedPrompt = await ctx.ui.editor("Edit handoff prompt (ctrl+enter to submit, esc to cancel)", result);
if (editedPrompt === undefined) {
ctx.ui.notify("Cancelled", "info");
return;
}
// Create new session with parent tracking
const newSessionResult = await ctx.newSession({
parentSession: currentSessionFile,
});
if (newSessionResult.cancelled) {
ctx.ui.notify("New session cancelled", "info");
return;
}
// Set the edited prompt in the main editor for submission
ctx.ui.setEditorText(editedPrompt);
ctx.ui.notify("Handoff ready. Submit when ready.", "info");
},
});
}

View file

@ -5,7 +5,7 @@
* Patterns checked: rm -rf, sudo, chmod/chown 777
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];

View file

@ -5,7 +5,7 @@
* Useful for preventing accidental modifications to sensitive files.
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
const protectedPaths = [".env", ".git/", "node_modules/"];

View file

@ -0,0 +1,119 @@
/**
* Q&A extraction hook - extracts questions from assistant responses
*
* Demonstrates the "prompt generator" pattern:
* 1. /qna command gets the last assistant message
* 2. Shows a spinner while extracting (hides editor)
* 3. Loads the result into the editor for user to fill in answers
*/
import { complete, type UserMessage } from "@mariozechner/pi-ai";
import type { HookAPI } from "@mariozechner/pi-coding-agent";
import { BorderedLoader } from "@mariozechner/pi-coding-agent";
const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in.
Output format:
- List each question on its own line, prefixed with "Q: "
- After each question, add a blank line for the answer prefixed with "A: "
- If no questions are found, output "No questions found in the last message."
Example output:
Q: What is your preferred database?
A:
Q: Should we use TypeScript or JavaScript?
A:
Keep questions in the order they appeared. Be concise.`;
export default function (pi: HookAPI) {
pi.registerCommand("qna", {
description: "Extract questions from last assistant message into editor",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("qna requires interactive mode", "error");
return;
}
if (!ctx.model) {
ctx.ui.notify("No model selected", "error");
return;
}
// Find the last assistant message on the current branch
const branch = ctx.sessionManager.getBranch();
let lastAssistantText: string | undefined;
for (let i = branch.length - 1; i >= 0; i--) {
const entry = branch[i];
if (entry.type === "message") {
const msg = entry.message;
if ("role" in msg && msg.role === "assistant") {
if (msg.stopReason !== "stop") {
ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
return;
}
const textParts = msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text);
if (textParts.length > 0) {
lastAssistantText = textParts.join("\n");
break;
}
}
}
}
if (!lastAssistantText) {
ctx.ui.notify("No assistant messages found", "error");
return;
}
// Run extraction with loader UI
const result = await ctx.ui.custom<string | null>((tui, theme, done) => {
const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`);
loader.onAbort = () => done(null);
// Do the work
const doExtract = async () => {
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
const userMessage: UserMessage = {
role: "user",
content: [{ type: "text", text: lastAssistantText! }],
timestamp: Date.now(),
};
const response = await complete(
ctx.model!,
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
{ apiKey, signal: loader.signal },
);
if (response.stopReason === "aborted") {
return null;
}
return response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
};
doExtract()
.then(done)
.catch(() => done(null));
return loader;
});
if (result === null) {
ctx.ui.notify("Cancelled", "info");
return;
}
ctx.ui.setEditorText(result);
ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info");
},
});
}

View file

@ -2,8 +2,8 @@
* Snake game hook - play snake with /snake command
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent";
import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp, isEscape, visibleWidth } from "@mariozechner/pi-tui";
import type { HookAPI } from "../../src/core/hooks/types.js";
const GAME_WIDTH = 40;
const GAME_HEIGHT = 15;
@ -56,7 +56,7 @@ class SnakeComponent {
private interval: ReturnType<typeof setInterval> | null = null;
private onClose: () => void;
private onSave: (state: GameState | null) => void;
private requestRender: () => void;
private tui: { requestRender: () => void };
private cachedLines: string[] = [];
private cachedWidth = 0;
private version = 0;
@ -64,11 +64,12 @@ class SnakeComponent {
private paused: boolean;
constructor(
tui: { requestRender: () => void },
onClose: () => void,
onSave: (state: GameState | null) => void,
requestRender: () => void,
savedState?: GameState,
) {
this.tui = tui;
if (savedState && !savedState.gameOver) {
// Resume from saved state, start paused
this.state = savedState;
@ -84,7 +85,6 @@ class SnakeComponent {
}
this.onClose = onClose;
this.onSave = onSave;
this.requestRender = requestRender;
}
private startGame(): void {
@ -92,7 +92,7 @@ class SnakeComponent {
if (!this.state.gameOver) {
this.tick();
this.version++;
this.requestRender();
this.tui.requestRender();
}
}, TICK_MS);
}
@ -196,7 +196,7 @@ class SnakeComponent {
this.state.highScore = highScore;
this.onSave(null); // Clear saved state on restart
this.version++;
this.requestRender();
this.tui.requestRender();
}
}
@ -327,19 +327,17 @@ export default function (pi: HookAPI) {
}
}
let ui: { close: () => void; requestRender: () => void } | null = null;
const component = new SnakeComponent(
() => ui?.close(),
(state) => {
// Save or clear state
pi.appendEntry(SNAKE_SAVE_TYPE, state);
},
() => ui?.requestRender(),
savedState,
);
ui = ctx.ui.custom(component);
await ctx.ui.custom((tui, _theme, done) => {
return new SnakeComponent(
tui,
() => done(undefined),
(state) => {
// Save or clear state
pi.appendEntry(SNAKE_SAVE_TYPE, state);
},
savedState,
);
});
},
});
}

View file

@ -0,0 +1,40 @@
/**
* Status Line Hook
*
* Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer.
* Shows turn progress with themed colors.
*/
import type { HookAPI } from "@mariozechner/pi-coding-agent";
export default function (pi: HookAPI) {
let turnCount = 0;
pi.on("session_start", async (_event, ctx) => {
const theme = ctx.ui.theme;
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
});
pi.on("turn_start", async (_event, ctx) => {
turnCount++;
const theme = ctx.ui.theme;
const spinner = theme.fg("accent", "●");
const text = theme.fg("dim", ` Turn ${turnCount}...`);
ctx.ui.setStatus("status-demo", spinner + text);
});
pi.on("turn_end", async (_event, ctx) => {
const theme = ctx.ui.theme;
const check = theme.fg("success", "✓");
const text = theme.fg("dim", ` Turn ${turnCount} complete`);
ctx.ui.setStatus("status-demo", check + text);
});
pi.on("session_switch", async (event, ctx) => {
if (event.reason === "new") {
turnCount = 0;
const theme = ctx.ui.theme;
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
}
});
}

View file

@ -0,0 +1,134 @@
/**
* Todo Hook - Companion to the todo custom tool
*
* Registers a /todos command that displays all todos on the current branch
* with a nice custom UI.
*/
import type { HookAPI, Theme } from "@mariozechner/pi-coding-agent";
import { isCtrlC, isEscape, truncateToWidth } from "@mariozechner/pi-tui";
interface Todo {
id: number;
text: string;
done: boolean;
}
interface TodoDetails {
action: "list" | "add" | "toggle" | "clear";
todos: Todo[];
nextId: number;
error?: string;
}
class TodoListComponent {
private todos: Todo[];
private theme: Theme;
private onClose: () => void;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(todos: Todo[], theme: Theme, onClose: () => void) {
this.todos = todos;
this.theme = theme;
this.onClose = onClose;
}
handleInput(data: string): void {
if (isEscape(data) || isCtrlC(data)) {
this.onClose();
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const lines: string[] = [];
const th = this.theme;
// Header
lines.push("");
const title = th.fg("accent", " Todos ");
const headerLine =
th.fg("borderMuted", "─".repeat(3)) + title + th.fg("borderMuted", "─".repeat(Math.max(0, width - 10)));
lines.push(truncateToWidth(headerLine, width));
lines.push("");
if (this.todos.length === 0) {
lines.push(truncateToWidth(` ${th.fg("dim", "No todos yet. Ask the agent to add some!")}`, width));
} else {
// Stats
const done = this.todos.filter((t) => t.done).length;
const total = this.todos.length;
const statsText = ` ${th.fg("muted", `${done}/${total} completed`)}`;
lines.push(truncateToWidth(statsText, width));
lines.push("");
// Todo items
for (const todo of this.todos) {
const check = todo.done ? th.fg("success", "✓") : th.fg("dim", "○");
const id = th.fg("accent", `#${todo.id}`);
const text = todo.done ? th.fg("dim", todo.text) : th.fg("text", todo.text);
const line = ` ${check} ${id} ${text}`;
lines.push(truncateToWidth(line, width));
}
}
lines.push("");
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
lines.push("");
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
export default function (pi: HookAPI) {
/**
* Reconstruct todos from session entries on the current branch.
*/
function getTodos(ctx: {
sessionManager: {
getBranch: () => Array<{ type: string; message?: { role?: string; toolName?: string; details?: unknown } }>;
};
}): Todo[] {
let todos: Todo[] = [];
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
const msg = entry.message;
if (!msg || msg.role !== "toolResult" || msg.toolName !== "todo") continue;
const details = msg.details as TodoDetails | undefined;
if (details) {
todos = details.todos;
}
}
return todos;
}
pi.registerCommand("todos", {
description: "Show all todos on the current branch",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("/todos requires interactive mode", "error");
return;
}
const todos = getTodos(ctx);
await ctx.ui.custom<void>((_tui, theme, done) => {
return new TodoListComponent(todos, theme, () => done());
});
},
});
}

View file

@ -5,7 +5,7 @@
* from cwd and ~/.pi/agent. Model chosen from settings or first available.
*/
import { createAgentSession } from "../../src/index.js";
import { createAgentSession } from "@mariozechner/pi-coding-agent";
const { session } = await createAgentSession();

View file

@ -5,7 +5,7 @@
*/
import { getModel } from "@mariozechner/pi-ai";
import { createAgentSession, discoverAuthStorage, discoverModels } from "../../src/index.js";
import { createAgentSession, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
// Set up auth storage and model registry
const authStorage = discoverAuthStorage();

View file

@ -4,7 +4,7 @@
* Shows how to replace or modify the default system prompt.
*/
import { createAgentSession, SessionManager } from "../../src/index.js";
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
// Option 1: Replace prompt entirely
const { session: session1 } = await createAgentSession({

View file

@ -5,7 +5,7 @@
* Discover, filter, merge, or replace them.
*/
import { createAgentSession, discoverSkills, SessionManager, type Skill } from "../../src/index.js";
import { createAgentSession, discoverSkills, SessionManager, type Skill } from "@mariozechner/pi-coding-agent";
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
const allSkills = discoverSkills();

View file

@ -8,10 +8,9 @@
* tools resolve paths relative to your cwd, not process.cwd().
*/
import { Type } from "@sinclair/typebox";
import {
bashTool, // read, bash, edit, write - uses process.cwd()
type CustomAgentTool,
type CustomTool,
createAgentSession,
createBashTool,
createCodingTools, // Factory: creates tools for specific cwd
@ -21,7 +20,8 @@ import {
readOnlyTools, // read, grep, find, ls - uses process.cwd()
readTool,
SessionManager,
} from "../../src/index.js";
} from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
// Read-only mode (no edit/write) - uses process.cwd()
await createAgentSession({
@ -55,7 +55,7 @@ await createAgentSession({
console.log("Specific tools with custom cwd session created");
// Inline custom tool (needs TypeBox schema)
const weatherTool: CustomAgentTool = {
const weatherTool: CustomTool = {
name: "get_weather",
label: "Get Weather",
description: "Get current weather for a city",

View file

@ -4,7 +4,7 @@
* Hooks intercept agent events for logging, blocking, or modification.
*/
import { createAgentSession, type HookFactory, SessionManager } from "../../src/index.js";
import { createAgentSession, type HookFactory, SessionManager } from "@mariozechner/pi-coding-agent";
// Logging hook
const loggingHook: HookFactory = (api) => {

View file

@ -4,7 +4,7 @@
* Context files provide project-specific instructions loaded into the system prompt.
*/
import { createAgentSession, discoverContextFiles, SessionManager } from "../../src/index.js";
import { createAgentSession, discoverContextFiles, SessionManager } from "@mariozechner/pi-coding-agent";
// Discover AGENTS.md files walking up from cwd
const discovered = discoverContextFiles();

View file

@ -4,7 +4,12 @@
* File-based commands that inject content when invoked with /commandname.
*/
import { createAgentSession, discoverSlashCommands, type FileSlashCommand, SessionManager } from "../../src/index.js";
import {
createAgentSession,
discoverSlashCommands,
type FileSlashCommand,
SessionManager,
} from "@mariozechner/pi-coding-agent";
// Discover commands from cwd/.pi/commands/ and ~/.pi/agent/commands/
const discovered = discoverSlashCommands();

View file

@ -11,7 +11,7 @@ import {
discoverModels,
ModelRegistry,
SessionManager,
} from "../../src/index.js";
} from "@mariozechner/pi-coding-agent";
// Default: discoverAuthStorage() uses ~/.pi/agent/auth.json
// discoverModels() loads built-in + custom models from ~/.pi/agent/models.json

View file

@ -4,7 +4,7 @@
* Override settings using SettingsManager.
*/
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "../../src/index.js";
import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
// Load current settings (merged global + project)
const settings = loadSettings();

View file

@ -4,7 +4,7 @@
* Control session persistence: in-memory, new file, continue, or open specific.
*/
import { createAgentSession, SessionManager } from "../../src/index.js";
import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
// In-memory (no persistence)
const { session: inMemory } = await createAgentSession({

View file

@ -9,10 +9,9 @@
*/
import { getModel } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import {
AuthStorage,
type CustomAgentTool,
type CustomTool,
createAgentSession,
createBashTool,
createReadTool,
@ -20,7 +19,8 @@ import {
ModelRegistry,
SessionManager,
SettingsManager,
} from "../../src/index.js";
} from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
// Custom auth storage location
const authStorage = new AuthStorage("/tmp/my-agent/auth.json");
@ -42,7 +42,7 @@ const auditHook: HookFactory = (api) => {
};
// Inline custom tool
const statusTool: CustomAgentTool = {
const statusTool: CustomTool = {
name: "status",
label: "Status",
description: "Get system status",
@ -68,15 +68,12 @@ const cwd = process.cwd();
const { session } = await createAgentSession({
cwd,
agentDir: "/tmp/my-agent",
model,
thinkingLevel: "off",
authStorage,
modelRegistry,
systemPrompt: `You are a minimal assistant.
Available: read, bash, status. Be concise.`,
// Use factory functions with the same cwd to ensure path resolution works correctly
tools: [createReadTool(cwd), createBashTool(cwd)],
customTools: [{ tool: statusTool }],

View file

@ -1,6 +1,6 @@
{
"name": "@mariozechner/pi-coding-agent",
"version": "0.30.2",
"version": "0.31.1",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
"piConfig": {
@ -32,22 +32,23 @@
"clean": "rm -rf dist",
"build": "tsgo -p tsconfig.build.json && chmod +x dist/cli.js && npm run copy-assets",
"build:binary": "npm run build && bun build --compile ./dist/cli.js --outfile dist/pi && npm run copy-binary-assets",
"copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/",
"copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && cp -r docs dist/ && cp -r examples dist/",
"copy-assets": "mkdir -p dist/modes/interactive/theme && cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && mkdir -p dist/core/export-html/vendor && cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/",
"copy-binary-assets": "cp package.json dist/ && cp README.md dist/ && cp CHANGELOG.md dist/ && mkdir -p dist/theme && cp src/modes/interactive/theme/*.json dist/theme/ && mkdir -p dist/export-html/vendor && cp src/core/export-html/template.html dist/export-html/ && cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && cp -r docs dist/ && cp -r examples dist/",
"test": "vitest --run",
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@mariozechner/pi-agent-core": "^0.30.2",
"@mariozechner/pi-ai": "^0.30.2",
"@mariozechner/pi-tui": "^0.30.2",
"@mariozechner/pi-agent-core": "^0.31.1",
"@mariozechner/pi-ai": "^0.31.1",
"@mariozechner/pi-tui": "^0.31.1",
"chalk": "^5.5.0",
"cli-highlight": "^2.1.11",
"diff": "^8.0.2",
"file-type": "^21.1.1",
"glob": "^11.0.3",
"jiti": "^2.6.1",
"marked": "^15.0.12"
"marked": "^15.0.12",
"sharp": "^0.34.2"
},
"devDependencies": {
"@types/diff": "^7.0.2",

View file

@ -158,7 +158,8 @@ ${chalk.bold("Options:")}
--session <path> Use specific session file
--session-dir <dir> Directory for session storage and lookup
--no-session Don't save session (ephemeral)
--models <patterns> Comma-separated model patterns for quick cycling with Ctrl+P
--models <patterns> Comma-separated model patterns for Ctrl+P cycling
Supports globs (anthropic/*, *sonnet*) and fuzzy matching
--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
@ -196,6 +197,9 @@ ${chalk.bold("Examples:")}
# Limit model cycling to specific models
${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o
# Limit to a specific provider with glob pattern
${APP_NAME} --models "github-copilot/*"
# Cycle models with fixed thinking levels
${APP_NAME} --models sonnet:high,haiku:low

View file

@ -7,6 +7,7 @@ import type { ImageContent } from "@mariozechner/pi-ai";
import chalk from "chalk";
import { resolve } from "path";
import { resolveReadPath } from "../core/tools/path-utils.js";
import { formatDimensionNote, resizeImage } from "../utils/image-resize.js";
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime.js";
export interface ProcessedFiles {
@ -14,8 +15,14 @@ export interface ProcessedFiles {
images: ImageContent[];
}
export interface ProcessFileOptions {
/** Whether to auto-resize images to 2000x2000 max. Default: true */
autoResizeImages?: boolean;
}
/** Process @file arguments into text content and image attachments */
export async function processFileArguments(fileArgs: string[]): Promise<ProcessedFiles> {
export async function processFileArguments(fileArgs: string[], options?: ProcessFileOptions): Promise<ProcessedFiles> {
const autoResizeImages = options?.autoResizeImages ?? true;
let text = "";
const images: ImageContent[] = [];
@ -45,16 +52,33 @@ export async function processFileArguments(fileArgs: string[]): Promise<Processe
const content = await readFile(absolutePath);
const base64Content = content.toString("base64");
const attachment: ImageContent = {
type: "image",
mimeType,
data: base64Content,
};
let attachment: ImageContent;
let dimensionNote: string | undefined;
if (autoResizeImages) {
const resized = await resizeImage({ type: "image", data: base64Content, mimeType });
dimensionNote = formatDimensionNote(resized);
attachment = {
type: "image",
mimeType: resized.mimeType,
data: resized.data,
};
} else {
attachment = {
type: "image",
mimeType,
data: base64Content,
};
}
images.push(attachment);
// Add text reference to image
text += `<file name="${absolutePath}"></file>\n`;
// Add text reference to image with optional dimension note
if (dimensionNote) {
text += `<file name="${absolutePath}">${dimensionNote}</file>\n`;
} else {
text += `<file name="${absolutePath}"></file>\n`;
}
} else {
// Handle text file
try {

View file

@ -60,6 +60,21 @@ export function getThemesDir(): string {
return join(packageDir, srcOrDist, "modes", "interactive", "theme");
}
/**
* Get path to HTML export template directory (shipped with package)
* - For Bun binary: export-html/ next to executable
* - For Node.js (dist/): dist/core/export-html/
* - For tsx (src/): src/core/export-html/
*/
export function getExportTemplateDir(): string {
if (isBunBinary) {
return join(dirname(process.execPath), "export-html");
}
const packageDir = getPackageDir();
const srcOrDist = existsSync(join(packageDir, "src")) ? "src" : "dist";
return join(packageDir, srcOrDist, "core", "export-html");
}
/** Get path to package.json */
export function getPackageJsonPath(): string {
return join(getPackageDir(), "package.json");
@ -75,6 +90,11 @@ export function getDocsPath(): string {
return resolve(join(getPackageDir(), "docs"));
}
/** Get path to examples directory */
export function getExamplesPath(): string {
return resolve(join(getPackageDir(), "examples"));
}
/** Get path to CHANGELOG.md */
export function getChangelogPath(): string {
return resolve(join(getPackageDir(), "CHANGELOG.md"));

View file

@ -27,14 +27,12 @@ import {
prepareCompaction,
shouldCompact,
} from "./compaction/index.js";
import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "./custom-tools/index.js";
import { exportSessionToHtml } from "./export-html.js";
import type { CustomToolContext, CustomToolSessionEvent, LoadedCustomTool } from "./custom-tools/index.js";
import { exportSessionToHtml } from "./export-html/index.js";
import type {
HookContext,
HookRunner,
SessionBeforeBranchResult,
SessionBeforeCompactResult,
SessionBeforeNewResult,
SessionBeforeSwitchResult,
SessionBeforeTreeResult,
TreePreparation,
@ -43,7 +41,7 @@ import type {
} from "./hooks/index.js";
import type { BashExecutionMessage, HookMessage } from "./messages.js";
import type { ModelRegistry } from "./model-registry.js";
import type { BranchSummaryEntry, CompactionEntry, SessionManager } from "./session-manager.js";
import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions, SessionManager } from "./session-manager.js";
import type { SettingsManager, SkillsSettings } from "./settings-manager.js";
import { expandSlashCommand, type FileSlashCommand } from "./slash-commands.js";
@ -114,7 +112,6 @@ export interface SessionStats {
cost: number;
}
/** Internal marker for hook messages queued through the agent loop */
// ============================================================================
// Constants
// ============================================================================
@ -141,8 +138,10 @@ export class AgentSession {
private _unsubscribeAgent?: () => void;
private _eventListeners: AgentSessionEventListener[] = [];
// Message queue state
private _queuedMessages: string[] = [];
/** Tracks pending steering messages for UI display. Removed when delivered. */
private _steeringMessages: string[] = [];
/** Tracks pending follow-up messages for UI display. Removed when delivered. */
private _followUpMessages: string[] = [];
// Compaction state
private _compactionAbortController: AbortController | undefined = undefined;
@ -210,16 +209,21 @@ export class AgentSession {
/** Internal handler for agent events - shared by subscribe and reconnect */
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
// When a user message starts, check if it's from the queue and remove it BEFORE emitting
// When a user message starts, check if it's from either queue and remove it BEFORE emitting
// This ensures the UI sees the updated queue state
if (event.type === "message_start" && event.message.role === "user" && this._queuedMessages.length > 0) {
// Extract text content from the message
if (event.type === "message_start" && event.message.role === "user") {
const messageText = this._getUserMessageText(event.message);
if (messageText && this._queuedMessages.includes(messageText)) {
// Remove the first occurrence of this message from the queue
const index = this._queuedMessages.indexOf(messageText);
if (index !== -1) {
this._queuedMessages.splice(index, 1);
if (messageText) {
// Check steering queue first
const steeringIndex = this._steeringMessages.indexOf(messageText);
if (steeringIndex !== -1) {
this._steeringMessages.splice(steeringIndex, 1);
} else {
// Check follow-up queue
const followUpIndex = this._followUpMessages.indexOf(messageText);
if (followUpIndex !== -1) {
this._followUpMessages.splice(followUpIndex, 1);
}
}
}
}
@ -421,9 +425,14 @@ export class AgentSession {
return this.agent.state.messages;
}
/** Current queue mode */
get queueMode(): "all" | "one-at-a-time" {
return this.agent.getQueueMode();
/** Current steering mode */
get steeringMode(): "all" | "one-at-a-time" {
return this.agent.getSteeringMode();
}
/** Current follow-up mode */
get followUpMode(): "all" | "one-at-a-time" {
return this.agent.getFollowUpMode();
}
/** Current session file path, or undefined if sessions are disabled */
@ -458,6 +467,10 @@ export class AgentSession {
* @throws Error if no model selected or no API key available
*/
async prompt(text: string, options?: PromptOptions): Promise<void> {
if (this.isStreaming) {
throw new Error("Agent is already processing. Use steer() or followUp() to queue messages during streaming.");
}
// Flush any pending bash messages before the new prompt
this._flushPendingBashMessages();
@ -546,20 +559,8 @@ export class AgentSession {
const command = this._hookRunner.getCommand(commandName);
if (!command) return false;
// Get UI context from hook runner (set by mode)
const uiContext = this._hookRunner.getUIContext();
if (!uiContext) return false;
// Build command context
const cwd = process.cwd();
const ctx: HookContext = {
ui: uiContext,
hasUI: this._hookRunner.getHasUI(),
cwd,
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
model: this.model,
};
// Get command context from hook runner (includes session control methods)
const ctx = this._hookRunner.createCommandContext();
try {
await command.handler(args, ctx);
@ -576,12 +577,25 @@ export class AgentSession {
}
/**
* Queue a message to be sent after the current response completes.
* Use when agent is currently streaming.
* Queue a steering message to interrupt the agent mid-run.
* Delivered after current tool execution, skips remaining tools.
*/
async queueMessage(text: string): Promise<void> {
this._queuedMessages.push(text);
await this.agent.queueMessage({
async steer(text: string): Promise<void> {
this._steeringMessages.push(text);
this.agent.steer({
role: "user",
content: [{ type: "text", text }],
timestamp: Date.now(),
});
}
/**
* Queue a follow-up message to be processed after the agent finishes.
* Delivered only when agent has no more tool calls or steering messages.
*/
async followUp(text: string): Promise<void> {
this._followUpMessages.push(text);
this.agent.followUp({
role: "user",
content: [{ type: "text", text }],
timestamp: Date.now(),
@ -597,11 +611,12 @@ export class AgentSession {
* - Not streaming + no trigger: appends to state/session, no turn
*
* @param message Hook message with customType, content, display, details
* @param triggerTurn If true and not streaming, triggers a new LLM turn
* @param options.triggerTurn If true and not streaming, triggers a new LLM turn
* @param options.deliverAs When streaming, use "steer" (default) for immediate or "followUp" to wait
*/
async sendHookMessage<T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
triggerTurn?: boolean,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
): Promise<void> {
const appMessage = {
role: "hookMessage" as const,
@ -613,8 +628,12 @@ export class AgentSession {
} satisfies HookMessage<T>;
if (this.isStreaming) {
// Queue for processing by agent loop
await this.agent.queueMessage(appMessage);
} else if (triggerTurn) {
if (options?.deliverAs === "followUp") {
this.agent.followUp(appMessage);
} else {
this.agent.steer(appMessage);
}
} else if (options?.triggerTurn) {
// Send as prompt - agent loop will emit message events
await this.agent.prompt(appMessage);
} else {
@ -630,24 +649,32 @@ export class AgentSession {
}
/**
* Clear queued messages and return them.
* Clear all queued messages and return them.
* Useful for restoring to editor when user aborts.
* @returns Object with steering and followUp arrays
*/
clearQueue(): string[] {
const queued = [...this._queuedMessages];
this._queuedMessages = [];
this.agent.clearMessageQueue();
return queued;
clearQueue(): { steering: string[]; followUp: string[] } {
const steering = [...this._steeringMessages];
const followUp = [...this._followUpMessages];
this._steeringMessages = [];
this._followUpMessages = [];
this.agent.clearAllQueues();
return { steering, followUp };
}
/** Number of messages currently queued */
get queuedMessageCount(): number {
return this._queuedMessages.length;
/** Number of pending messages (includes both steering and follow-up) */
get pendingMessageCount(): number {
return this._steeringMessages.length + this._followUpMessages.length;
}
/** Get queued messages (read-only) */
getQueuedMessages(): readonly string[] {
return this._queuedMessages;
/** Get pending steering messages (read-only) */
getSteeringMessages(): readonly string[] {
return this._steeringMessages;
}
/** Get pending follow-up messages (read-only) */
getFollowUpMessages(): readonly string[] {
return this._followUpMessages;
}
get skillsSettings(): Required<SkillsSettings> | undefined {
@ -664,19 +691,21 @@ export class AgentSession {
}
/**
* Reset agent and session to start fresh.
* Start a new session, optionally with initial messages and parent tracking.
* Clears all messages and starts a new session.
* Listeners are preserved and will continue receiving events.
* @returns true if reset completed, false if cancelled by hook
* @param options - Optional initial messages and parent session path
* @returns true if completed, false if cancelled by hook
*/
async reset(): Promise<boolean> {
async newSession(options?: NewSessionOptions): Promise<boolean> {
const previousSessionFile = this.sessionFile;
// Emit session_before_new event (can be cancelled)
if (this._hookRunner?.hasHandlers("session_before_new")) {
// Emit session_before_switch event with reason "new" (can be cancelled)
if (this._hookRunner?.hasHandlers("session_before_switch")) {
const result = (await this._hookRunner.emit({
type: "session_before_new",
})) as SessionBeforeNewResult | undefined;
type: "session_before_switch",
reason: "new",
})) as SessionBeforeSwitchResult | undefined;
if (result?.cancel) {
return false;
@ -686,19 +715,22 @@ export class AgentSession {
this._disconnectFromAgent();
await this.abort();
this.agent.reset();
this.sessionManager.newSession();
this._queuedMessages = [];
this.sessionManager.newSession(options);
this._steeringMessages = [];
this._followUpMessages = [];
this._reconnectToAgent();
// Emit session_new event to hooks
// Emit session_switch event with reason "new" to hooks
if (this._hookRunner) {
await this._hookRunner.emit({
type: "session_new",
type: "session_switch",
reason: "new",
previousSessionFile,
});
}
// Emit session event to custom tools
await this.emitToolSessionEvent("new", previousSessionFile);
await this.emitCustomToolSessionEvent("switch", previousSessionFile);
return true;
}
@ -863,12 +895,21 @@ export class AgentSession {
// =========================================================================
/**
* Set message queue mode.
* Set steering message mode.
* Saves to settings.
*/
setQueueMode(mode: "all" | "one-at-a-time"): void {
this.agent.setQueueMode(mode);
this.settingsManager.setQueueMode(mode);
setSteeringMode(mode: "all" | "one-at-a-time"): void {
this.agent.setSteeringMode(mode);
this.settingsManager.setSteeringMode(mode);
}
/**
* Set follow-up message mode.
* Saves to settings.
*/
setFollowUpMode(mode: "all" | "one-at-a-time"): void {
this.agent.setFollowUpMode(mode);
this.settingsManager.setFollowUpMode(mode);
}
// =========================================================================
@ -895,7 +936,7 @@ export class AgentSession {
throw new Error(`No API key for ${this.model.provider}`);
}
const pathEntries = this.sessionManager.getPath();
const pathEntries = this.sessionManager.getBranch();
const settings = this.settingsManager.getCompactionSettings();
const preparation = prepareCompaction(pathEntries, settings);
@ -1068,7 +1109,7 @@ export class AgentSession {
return;
}
const pathEntries = this.sessionManager.getPath();
const pathEntries = this.sessionManager.getBranch();
const preparation = prepareCompaction(pathEntries, settings);
if (!preparation) {
@ -1446,6 +1487,7 @@ export class AgentSession {
if (this._hookRunner?.hasHandlers("session_before_switch")) {
const result = (await this._hookRunner.emit({
type: "session_before_switch",
reason: "resume",
targetSessionFile: sessionPath,
})) as SessionBeforeSwitchResult | undefined;
@ -1456,7 +1498,8 @@ export class AgentSession {
this._disconnectFromAgent();
await this.abort();
this._queuedMessages = [];
this._steeringMessages = [];
this._followUpMessages = [];
// Set new session
this.sessionManager.setSessionFile(sessionPath);
@ -1468,12 +1511,13 @@ export class AgentSession {
if (this._hookRunner) {
await this._hookRunner.emit({
type: "session_switch",
reason: "resume",
previousSessionFile,
});
}
// Emit session event to custom tools
await this.emitToolSessionEvent("switch", previousSessionFile);
await this.emitCustomToolSessionEvent("switch", previousSessionFile);
this.agent.replaceMessages(sessionContext.messages);
@ -1498,21 +1542,20 @@ export class AgentSession {
}
/**
* Create a branch from a specific entry index.
* Create a branch from a specific entry.
* Emits before_branch/branch session events to hooks.
*
* @param entryIndex Index into session entries to branch from
* @param entryId ID of the entry to branch from
* @returns Object with:
* - selectedText: The text of the selected user message (for editor pre-fill)
* - cancelled: True if a hook cancelled the branch
*/
async branch(entryIndex: number): Promise<{ selectedText: string; cancelled: boolean }> {
async branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }> {
const previousSessionFile = this.sessionFile;
const entries = this.sessionManager.getEntries();
const selectedEntry = entries[entryIndex];
const selectedEntry = this.sessionManager.getEntry(entryId);
if (!selectedEntry || selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
throw new Error("Invalid entry index for branching");
throw new Error("Invalid entry ID for branching");
}
const selectedText = this._extractUserMessageText(selectedEntry.message.content);
@ -1523,7 +1566,7 @@ export class AgentSession {
if (this._hookRunner?.hasHandlers("session_before_branch")) {
const result = (await this._hookRunner.emit({
type: "session_before_branch",
entryIndex: entryIndex,
entryId,
})) as SessionBeforeBranchResult | undefined;
if (result?.cancel) {
@ -1550,7 +1593,7 @@ export class AgentSession {
}
// Emit session event to custom tools (with reason "branch")
await this.emitToolSessionEvent("branch", previousSessionFile);
await this.emitCustomToolSessionEvent("branch", previousSessionFile);
if (!skipConversationRestore) {
this.agent.replaceMessages(sessionContext.messages);
@ -1720,7 +1763,7 @@ export class AgentSession {
}
// Emit to custom tools
await this.emitToolSessionEvent("tree", this.sessionFile);
await this.emitCustomToolSessionEvent("tree", this.sessionFile);
this._branchSummaryAbortController = undefined;
return { editorText, cancelled: false, summaryEntry };
@ -1729,18 +1772,17 @@ export class AgentSession {
/**
* Get all user messages from session for branch selector.
*/
getUserMessagesForBranching(): Array<{ entryIndex: number; text: string }> {
getUserMessagesForBranching(): Array<{ entryId: string; text: string }> {
const entries = this.sessionManager.getEntries();
const result: Array<{ entryIndex: number; text: string }> = [];
const result: Array<{ entryId: string; text: string }> = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
for (const entry of entries) {
if (entry.type !== "message") continue;
if (entry.message.role !== "user") continue;
const text = this._extractUserMessageText(entry.message.content);
if (text) {
result.push({ entryIndex: i, text });
result.push({ entryId: entry.id, text });
}
}
@ -1877,20 +1919,28 @@ export class AgentSession {
* Emit session event to all custom tools.
* Called on session switch, branch, tree navigation, and shutdown.
*/
async emitToolSessionEvent(
reason: ToolSessionEvent["reason"],
async emitCustomToolSessionEvent(
reason: CustomToolSessionEvent["reason"],
previousSessionFile?: string | undefined,
): Promise<void> {
const event: ToolSessionEvent = {
entries: this.sessionManager.getEntries(),
sessionFile: this.sessionFile,
previousSessionFile,
reason,
if (!this._customTools) return;
const event: CustomToolSessionEvent = { reason, previousSessionFile };
const ctx: CustomToolContext = {
sessionManager: this.sessionManager,
modelRegistry: this._modelRegistry,
model: this.agent.state.model,
isIdle: () => !this.isStreaming,
hasPendingMessages: () => this.pendingMessageCount > 0,
abort: () => {
this.abort();
},
};
for (const { tool } of this._customTools) {
if (tool.onSession) {
try {
await tool.onSession(event);
await tool.onSession(event, ctx);
} catch (_err) {
// Silently ignore tool errors during session events
}

View file

@ -102,8 +102,8 @@ export function collectEntriesForBranchSummary(
}
// Find common ancestor (deepest node that's on both paths)
const oldPath = new Set(session.getPath(oldLeafId).map((e) => e.id));
const targetPath = session.getPath(targetId);
const oldPath = new Set(session.getBranch(oldLeafId).map((e) => e.id));
const targetPath = session.getBranch(targetId);
// targetPath is root-first, so iterate backwards to find deepest common ancestor
let commonAncestorId: string | null = null;

View file

@ -4,14 +4,18 @@
export { discoverAndLoadCustomTools, loadCustomTools } from "./loader.js";
export type {
AgentToolResult,
AgentToolUpdateCallback,
CustomAgentTool,
CustomTool,
CustomToolAPI,
CustomToolContext,
CustomToolFactory,
CustomToolResult,
CustomToolSessionEvent,
CustomToolsLoadResult,
CustomToolUIContext,
ExecResult,
LoadedCustomTool,
RenderResultOptions,
SessionEvent,
ToolAPI,
ToolUIContext,
} from "./types.js";
export { wrapCustomTool, wrapCustomTools } from "./wrapper.js";

View file

@ -14,10 +14,11 @@ import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { getAgentDir, isBunBinary } from "../../config.js";
import { theme } from "../../modes/interactive/theme/theme.js";
import type { ExecOptions } from "../exec.js";
import { execCommand } from "../exec.js";
import type { HookUIContext } from "../hooks/types.js";
import type { CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool, ToolAPI } from "./types.js";
import type { CustomToolAPI, CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool } from "./types.js";
// Create require function to resolve module paths at runtime
const require = createRequire(import.meta.url);
@ -90,7 +91,14 @@ function createNoOpUIContext(): HookUIContext {
confirm: async () => false,
input: async () => undefined,
notify: () => {},
custom: () => ({ close: () => {}, requestRender: () => {} }),
setStatus: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return theme;
},
};
}
@ -104,7 +112,7 @@ function createNoOpUIContext(): HookUIContext {
*/
async function loadToolWithBun(
resolvedPath: string,
sharedApi: ToolAPI,
sharedApi: CustomToolAPI,
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
try {
// Try to import directly - will work for tools without @mariozechner/* imports
@ -149,7 +157,7 @@ async function loadToolWithBun(
async function loadTool(
toolPath: string,
cwd: string,
sharedApi: ToolAPI,
sharedApi: CustomToolAPI,
): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> {
const resolvedPath = resolveToolPath(toolPath, cwd);
@ -209,7 +217,7 @@ export async function loadCustomTools(
const seenNames = new Set<string>(builtInToolNames);
// Shared API object - all tools get the same instance
const sharedApi: ToolAPI = {
const sharedApi: CustomToolAPI = {
cwd,
exec: (command: string, args: string[], options?: ExecOptions) =>
execCommand(command, args, options?.cwd ?? cwd, options),

View file

@ -5,45 +5,62 @@
* They can provide custom rendering for tool calls and results in the TUI.
*/
import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
import type { AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import type { Component } from "@mariozechner/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { HookUIContext } from "../hooks/types.js";
import type { SessionEntry } from "../session-manager.js";
import type { ModelRegistry } from "../model-registry.js";
import type { ReadonlySessionManager } from "../session-manager.js";
/** Alias for clarity */
export type ToolUIContext = HookUIContext;
export type CustomToolUIContext = HookUIContext;
/** Re-export for custom tools to use in execute signature */
export type { AgentToolUpdateCallback };
export type { AgentToolResult, AgentToolUpdateCallback };
// Re-export for backward compatibility
export type { ExecOptions, ExecResult } from "../exec.js";
/** API passed to custom tool factory (stable across session changes) */
export interface ToolAPI {
export interface CustomToolAPI {
/** Current working directory */
cwd: string;
/** Execute a command */
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
/** UI methods for user interaction (select, confirm, input, notify) */
ui: ToolUIContext;
/** UI methods for user interaction (select, confirm, input, notify, custom) */
ui: CustomToolUIContext;
/** Whether UI is available (false in print/RPC mode) */
hasUI: boolean;
}
/**
* Context passed to tool execute and onSession callbacks.
* Provides access to session state and model information.
*/
export interface CustomToolContext {
/** Session manager (read-only) */
sessionManager: ReadonlySessionManager;
/** Model registry - use for API key resolution and model retrieval */
modelRegistry: ModelRegistry;
/** Current model (may be undefined if no model is selected yet) */
model: Model<any> | undefined;
/** Whether the agent is idle (not streaming) */
isIdle(): boolean;
/** Whether there are queued messages waiting to be processed */
hasPendingMessages(): boolean;
/** Abort the current agent operation (fire-and-forget, does not wait) */
abort(): void;
}
/** Session event passed to onSession callback */
export interface SessionEvent {
/** All session entries (including pre-compaction history) */
entries: SessionEntry[];
/** Current session file path, or undefined in --no-session mode */
sessionFile: string | undefined;
/** Previous session file path, or undefined for "start", "new", and "shutdown" */
previousSessionFile: string | undefined;
export interface CustomToolSessionEvent {
/** Reason for the session event */
reason: "start" | "switch" | "branch" | "new" | "tree" | "shutdown";
reason: "start" | "switch" | "branch" | "tree" | "shutdown";
/** Previous session file path, or undefined for "start" and "shutdown" */
previousSessionFile: string | undefined;
}
/** Rendering options passed to renderResult */
@ -54,58 +71,89 @@ export interface RenderResultOptions {
isPartial: boolean;
}
export type CustomToolResult<TDetails = any> = AgentToolResult<TDetails>;
/**
* Custom tool with optional lifecycle and rendering methods.
* Custom tool definition.
*
* The execute signature inherited from AgentTool includes an optional onUpdate callback
* for streaming progress updates during long-running operations:
* - The callback emits partial results to subscribers (e.g. TUI/RPC), not to the LLM.
* - Partial updates should use the same TDetails type as the final result (use a union if needed).
* Custom tools are standalone - they don't extend AgentTool directly.
* When loaded, they are wrapped in an AgentTool for the agent to use.
*
* The execute callback receives a ToolContext with access to session state,
* model registry, and current model.
*
* @example
* ```typescript
* type Details =
* | { status: "running"; step: number; total: number }
* | { status: "done"; count: number };
* const factory: CustomToolFactory = (pi) => ({
* name: "my_tool",
* label: "My Tool",
* description: "Does something useful",
* parameters: Type.Object({ input: Type.String() }),
*
* async execute(toolCallId, params, signal, onUpdate) {
* const items = params.items || [];
* for (let i = 0; i < items.length; i++) {
* onUpdate?.({
* content: [{ type: "text", text: `Step ${i + 1}/${items.length}...` }],
* details: { status: "running", step: i + 1, total: items.length },
* });
* await processItem(items[i], signal);
* async execute(toolCallId, params, onUpdate, ctx, signal) {
* // Access session state via ctx.sessionManager
* // Access model registry via ctx.modelRegistry
* // Current model via ctx.model
* return { content: [{ type: "text", text: "Done" }] };
* },
*
* onSession(event, ctx) {
* if (event.reason === "shutdown") {
* // Cleanup
* }
* // Reconstruct state from ctx.sessionManager.getEntries()
* }
* return { content: [{ type: "text", text: "Done" }], details: { status: "done", count: items.length } };
* }
* });
* ```
*
* Progress updates are rendered via renderResult with isPartial: true.
*/
export interface CustomAgentTool<TParams extends TSchema = TSchema, TDetails = any>
extends AgentTool<TParams, TDetails> {
export interface CustomTool<TParams extends TSchema = TSchema, TDetails = any> {
/** Tool name (used in LLM tool calls) */
name: string;
/** Human-readable label for UI */
label: string;
/** Description for LLM */
description: string;
/** Parameter schema (TypeBox) */
parameters: TParams;
/**
* Execute the tool.
* @param toolCallId - Unique ID for this tool call
* @param params - Parsed parameters matching the schema
* @param onUpdate - Callback for streaming partial results (for UI, not LLM)
* @param ctx - Context with session manager, model registry, and current model
* @param signal - Optional abort signal for cancellation
*/
execute(
toolCallId: string,
params: Static<TParams>,
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
ctx: CustomToolContext,
signal?: AbortSignal,
): Promise<AgentToolResult<TDetails>>;
/** Called on session lifecycle events - use to reconstruct state or cleanup resources */
onSession?: (event: SessionEvent) => void | Promise<void>;
onSession?: (event: CustomToolSessionEvent, ctx: CustomToolContext) => void | Promise<void>;
/** Custom rendering for tool call display - return a Component */
renderCall?: (args: Static<TParams>, theme: Theme) => Component;
/** Custom rendering for tool result display - return a Component */
renderResult?: (result: AgentToolResult<TDetails>, options: RenderResultOptions, theme: Theme) => Component;
renderResult?: (result: CustomToolResult<TDetails>, options: RenderResultOptions, theme: Theme) => Component;
}
/** Factory function that creates a custom tool or array of tools */
export type CustomToolFactory = (
pi: ToolAPI,
) => CustomAgentTool<any> | CustomAgentTool[] | Promise<CustomAgentTool | CustomAgentTool[]>;
pi: CustomToolAPI,
) => CustomTool<any, any> | CustomTool<any, any>[] | Promise<CustomTool<any, any> | CustomTool<any, any>[]>;
/** Loaded custom tool with metadata */
/** Loaded custom tool with metadata and wrapped AgentTool */
export interface LoadedCustomTool {
/** Original path (as specified) */
path: string;
/** Resolved absolute path */
resolvedPath: string;
/** The tool instance */
tool: CustomAgentTool;
/** The original custom tool instance */
tool: CustomTool;
}
/** Result from loading custom tools */
@ -113,5 +161,5 @@ export interface CustomToolsLoadResult {
tools: LoadedCustomTool[];
errors: Array<{ path: string; error: string }>;
/** Update the UI context for all loaded tools. Call when mode initializes. */
setUIContext(uiContext: ToolUIContext, hasUI: boolean): void;
setUIContext(uiContext: CustomToolUIContext, hasUI: boolean): void;
}

View file

@ -0,0 +1,28 @@
/**
* Wraps CustomTool instances into AgentTool for use with the agent.
*/
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types.js";
/**
* Wrap a CustomTool into an AgentTool.
* The wrapper injects the ToolContext into execute calls.
*/
export function wrapCustomTool(tool: CustomTool, getContext: () => CustomToolContext): AgentTool {
return {
name: tool.name,
label: tool.label,
description: tool.description,
parameters: tool.parameters,
execute: (toolCallId, params, signal, onUpdate) =>
tool.execute(toolCallId, params, onUpdate, getContext(), signal),
};
}
/**
* Wrap all loaded custom tools into AgentTools.
*/
export function wrapCustomTools(loadedTools: LoadedCustomTool[], getContext: () => CustomToolContext): AgentTool[] {
return loadedTools.map((lt) => wrapCustomTool(lt.tool, getContext));
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,211 @@
import type { AgentState } from "@mariozechner/pi-agent-core";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { basename, join } from "path";
import { APP_NAME, getExportTemplateDir } from "../../config.js";
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.js";
import { SessionManager } from "../session-manager.js";
export interface ExportOptions {
outputPath?: string;
themeName?: string;
}
/** Parse a color string to RGB values. Supports hex (#RRGGBB) and rgb(r,g,b) formats. */
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})$/);
if (hexMatch) {
return {
r: Number.parseInt(hexMatch[1], 16),
g: Number.parseInt(hexMatch[2], 16),
b: Number.parseInt(hexMatch[3], 16),
};
}
const rgbMatch = color.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
if (rgbMatch) {
return {
r: Number.parseInt(rgbMatch[1], 10),
g: Number.parseInt(rgbMatch[2], 10),
b: Number.parseInt(rgbMatch[3], 10),
};
}
return undefined;
}
/** Calculate relative luminance of a color (0-1, higher = lighter). */
function getLuminance(r: number, g: number, b: number): number {
const toLinear = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
/** Adjust color brightness. Factor > 1 lightens, < 1 darkens. */
function adjustBrightness(color: string, factor: number): string {
const parsed = parseColor(color);
if (!parsed) return color;
const adjust = (c: number) => Math.min(255, Math.max(0, Math.round(c * factor)));
return `rgb(${adjust(parsed.r)}, ${adjust(parsed.g)}, ${adjust(parsed.b)})`;
}
/** Derive export background colors from a base color (e.g., userMessageBg). */
function deriveExportColors(baseColor: string): { pageBg: string; cardBg: string; infoBg: string } {
const parsed = parseColor(baseColor);
if (!parsed) {
return {
pageBg: "rgb(24, 24, 30)",
cardBg: "rgb(30, 30, 36)",
infoBg: "rgb(60, 55, 40)",
};
}
const luminance = getLuminance(parsed.r, parsed.g, parsed.b);
const isLight = luminance > 0.5;
if (isLight) {
return {
pageBg: adjustBrightness(baseColor, 0.96),
cardBg: baseColor,
infoBg: `rgb(${Math.min(255, parsed.r + 10)}, ${Math.min(255, parsed.g + 5)}, ${Math.max(0, parsed.b - 20)})`,
};
}
return {
pageBg: adjustBrightness(baseColor, 0.7),
cardBg: adjustBrightness(baseColor, 0.85),
infoBg: `rgb(${Math.min(255, parsed.r + 20)}, ${Math.min(255, parsed.g + 15)}, ${parsed.b})`,
};
}
/**
* Generate CSS custom property declarations from theme colors.
*/
function generateThemeVars(themeName?: string): string {
const colors = getResolvedThemeColors(themeName);
const lines: string[] = [];
for (const [key, value] of Object.entries(colors)) {
lines.push(`--${key}: ${value};`);
}
// Use explicit theme export colors if available, otherwise derive from userMessageBg
const themeExport = getThemeExportColors(themeName);
const userMessageBg = colors.userMessageBg || "#343541";
const derivedColors = deriveExportColors(userMessageBg);
lines.push(`--exportPageBg: ${themeExport.pageBg ?? derivedColors.pageBg};`);
lines.push(`--exportCardBg: ${themeExport.cardBg ?? derivedColors.cardBg};`);
lines.push(`--exportInfoBg: ${themeExport.infoBg ?? derivedColors.infoBg};`);
return lines.join("\n ");
}
interface SessionData {
header: ReturnType<SessionManager["getHeader"]>;
entries: ReturnType<SessionManager["getEntries"]>;
leafId: string | null;
systemPrompt?: string;
tools?: { name: string; description: string }[];
}
/**
* Core HTML generation logic shared by both export functions.
*/
function generateHtml(sessionData: SessionData, themeName?: string): string {
const templateDir = getExportTemplateDir();
const template = readFileSync(join(templateDir, "template.html"), "utf-8");
const templateCss = readFileSync(join(templateDir, "template.css"), "utf-8");
const templateJs = readFileSync(join(templateDir, "template.js"), "utf-8");
const markedJs = readFileSync(join(templateDir, "vendor", "marked.min.js"), "utf-8");
const hljsJs = readFileSync(join(templateDir, "vendor", "highlight.min.js"), "utf-8");
const themeVars = generateThemeVars(themeName);
const colors = getResolvedThemeColors(themeName);
const exportColors = deriveExportColors(colors.userMessageBg || "#343541");
const bodyBg = exportColors.pageBg;
const containerBg = exportColors.cardBg;
const infoBg = exportColors.infoBg;
// Base64 encode session data to avoid escaping issues
const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString("base64");
// Build the CSS with theme variables injected
const css = templateCss
.replace("{{THEME_VARS}}", themeVars)
.replace("{{BODY_BG}}", bodyBg)
.replace("{{CONTAINER_BG}}", containerBg)
.replace("{{INFO_BG}}", infoBg);
return template
.replace("{{CSS}}", css)
.replace("{{JS}}", templateJs)
.replace("{{SESSION_DATA}}", sessionDataBase64)
.replace("{{MARKED_JS}}", markedJs)
.replace("{{HIGHLIGHT_JS}}", hljsJs);
}
/**
* Export session to HTML using SessionManager and AgentState.
* Used by TUI's /export command.
*/
export function exportSessionToHtml(sm: SessionManager, state?: AgentState, options?: ExportOptions | string): string {
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
const sessionFile = sm.getSessionFile();
if (!sessionFile) {
throw new Error("Cannot export in-memory session to HTML");
}
if (!existsSync(sessionFile)) {
throw new Error("Nothing to export yet - start a conversation first");
}
const sessionData: SessionData = {
header: sm.getHeader(),
entries: sm.getEntries(),
leafId: sm.getLeafId(),
systemPrompt: state?.systemPrompt,
tools: state?.tools?.map((t) => ({ name: t.name, description: t.description })),
};
const html = generateHtml(sessionData, opts.themeName);
let outputPath = opts.outputPath;
if (!outputPath) {
const sessionBasename = basename(sessionFile, ".jsonl");
outputPath = `${APP_NAME}-session-${sessionBasename}.html`;
}
writeFileSync(outputPath, html, "utf8");
return outputPath;
}
/**
* Export session file to HTML (standalone, without AgentState).
* Used by CLI for exporting arbitrary session files.
*/
export function exportFromFile(inputPath: string, options?: ExportOptions | string): string {
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
if (!existsSync(inputPath)) {
throw new Error(`File not found: ${inputPath}`);
}
const sm = SessionManager.open(inputPath);
const sessionData: SessionData = {
header: sm.getHeader(),
entries: sm.getEntries(),
leafId: sm.getLeafId(),
systemPrompt: undefined,
tools: undefined,
};
const html = generateHtml(sessionData, opts.themeName);
let outputPath = opts.outputPath;
if (!outputPath) {
const inputBasename = basename(inputPath, ".jsonl");
outputPath = `${APP_NAME}-session-${inputBasename}.html`;
}
writeFileSync(outputPath, html, "utf8");
return outputPath;
}

View file

@ -0,0 +1,781 @@
:root {
{{THEME_VARS}}
--body-bg: {{BODY_BG}};
--container-bg: {{CONTAINER_BG}};
--info-bg: {{INFO_BG}};
}
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--line-height: 18px; /* 12px font * 1.5 */
}
body {
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
font-size: 12px;
line-height: var(--line-height);
color: var(--text);
background: var(--body-bg);
}
#app {
display: flex;
min-height: 100vh;
}
/* Sidebar */
#sidebar {
width: 400px;
background: var(--container-bg);
flex-shrink: 0;
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
border-right: 1px solid var(--dim);
}
.sidebar-header {
padding: 8px 12px;
flex-shrink: 0;
}
.sidebar-controls {
padding: 8px 8px 4px 8px;
}
.sidebar-search {
width: 100%;
box-sizing: border-box;
padding: 4px 8px;
font-size: 11px;
font-family: inherit;
background: var(--body-bg);
color: var(--text);
border: 1px solid var(--dim);
border-radius: 3px;
}
.sidebar-filters {
display: flex;
padding: 4px 8px 8px 8px;
gap: 4px;
align-items: center;
flex-wrap: wrap;
}
.sidebar-search:focus {
outline: none;
border-color: var(--accent);
}
.sidebar-search::placeholder {
color: var(--muted);
}
.filter-btn {
padding: 3px 8px;
font-size: 10px;
font-family: inherit;
background: transparent;
color: var(--muted);
border: 1px solid var(--dim);
border-radius: 3px;
cursor: pointer;
}
.filter-btn:hover {
color: var(--text);
border-color: var(--text);
}
.filter-btn.active {
background: var(--accent);
color: var(--body-bg);
border-color: var(--accent);
}
.sidebar-close {
display: none;
padding: 3px 8px;
font-size: 12px;
font-family: inherit;
background: transparent;
color: var(--muted);
border: 1px solid var(--dim);
border-radius: 3px;
cursor: pointer;
margin-left: auto;
}
.sidebar-close:hover {
color: var(--text);
border-color: var(--text);
}
.tree-container {
flex: 1;
overflow: auto;
padding: 4px 0;
}
.tree-node {
padding: 0 8px;
cursor: pointer;
display: flex;
align-items: baseline;
font-size: 11px;
line-height: 13px;
white-space: nowrap;
}
.tree-node:hover {
background: var(--selectedBg);
}
.tree-node.active {
background: var(--selectedBg);
}
.tree-node.active .tree-content {
font-weight: bold;
}
.tree-node.in-path {
}
.tree-prefix {
color: var(--muted);
flex-shrink: 0;
font-family: monospace;
white-space: pre;
}
.tree-marker {
color: var(--accent);
flex-shrink: 0;
}
.tree-content {
color: var(--text);
}
.tree-role-user {
color: var(--accent);
}
.tree-role-assistant {
color: var(--success);
}
.tree-role-tool {
color: var(--muted);
}
.tree-muted {
color: var(--muted);
}
.tree-error {
color: var(--error);
}
.tree-compaction {
color: var(--borderAccent);
}
.tree-branch-summary {
color: var(--warning);
}
.tree-custom-message {
color: var(--customMessageLabel);
}
.tree-status {
padding: 4px 12px;
font-size: 10px;
color: var(--muted);
flex-shrink: 0;
}
/* Main content */
#content {
flex: 1;
overflow-y: auto;
padding: var(--line-height) calc(var(--line-height) * 2);
display: flex;
flex-direction: column;
align-items: center;
}
#content > * {
width: 100%;
max-width: 800px;
}
/* Help bar */
.help-bar {
font-size: 11px;
color: var(--warning);
margin-bottom: var(--line-height);
}
/* Header */
.header {
background: var(--container-bg);
border-radius: 4px;
padding: var(--line-height);
margin-bottom: var(--line-height);
}
.header h1 {
font-size: 12px;
font-weight: bold;
color: var(--borderAccent);
margin-bottom: var(--line-height);
}
.header-info {
display: flex;
flex-direction: column;
gap: 0;
font-size: 11px;
}
.info-item {
color: var(--dim);
display: flex;
align-items: baseline;
}
.info-label {
font-weight: 600;
margin-right: 8px;
min-width: 100px;
}
.info-value {
color: var(--text);
flex: 1;
}
/* Messages */
#messages {
display: flex;
flex-direction: column;
gap: var(--line-height);
}
.message-timestamp {
font-size: 10px;
color: var(--dim);
opacity: 0.8;
}
.user-message {
background: var(--userMessageBg);
color: var(--userMessageText);
padding: var(--line-height);
border-radius: 4px;
}
.assistant-message {
padding: 0;
}
.assistant-message > .message-timestamp {
padding-left: var(--line-height);
}
.assistant-text {
padding: var(--line-height);
padding-bottom: 0;
}
.message-timestamp + .assistant-text,
.message-timestamp + .thinking-block {
padding-top: 0;
}
.thinking-block + .assistant-text {
padding-top: 0;
}
.thinking-text {
padding: var(--line-height);
color: var(--thinkingText);
font-style: italic;
white-space: pre-wrap;
}
.message-timestamp + .thinking-block .thinking-text,
.message-timestamp + .thinking-block .thinking-collapsed {
padding-top: 0;
}
.thinking-collapsed {
display: none;
padding: var(--line-height);
color: var(--thinkingText);
font-style: italic;
}
/* Tool execution */
.tool-execution {
padding: var(--line-height);
border-radius: 4px;
}
.tool-execution + .tool-execution {
margin-top: var(--line-height);
}
.tool-execution.pending { background: var(--toolPendingBg); }
.tool-execution.success { background: var(--toolSuccessBg); }
.tool-execution.error { background: var(--toolErrorBg); }
.tool-header, .tool-name {
font-weight: bold;
}
.tool-path {
color: var(--accent);
word-break: break-all;
}
.line-numbers {
color: var(--warning);
}
.line-count {
color: var(--dim);
}
.tool-command {
font-weight: bold;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.tool-output {
margin-top: var(--line-height);
color: var(--toolOutput);
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
font-family: inherit;
overflow-x: auto;
}
.tool-output > div,
.output-preview,
.output-full {
margin: 0;
padding: 0;
line-height: var(--line-height);
}
.tool-output pre {
margin: 0;
padding: 0;
font-family: inherit;
color: inherit;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.tool-output code {
padding: 0;
background: none;
color: var(--text);
}
.tool-output.expandable {
cursor: pointer;
}
.tool-output.expandable:hover {
opacity: 0.9;
}
.tool-output.expandable .output-full {
display: none;
}
.tool-output.expandable.expanded .output-preview {
display: none;
}
.tool-output.expandable.expanded .output-full {
display: block;
}
.tool-images {
}
.tool-image {
max-width: 100%;
max-height: 500px;
border-radius: 4px;
margin: var(--line-height) 0;
}
.expand-hint {
color: var(--toolOutput);
}
/* Diff */
.tool-diff {
font-size: 11px;
overflow-x: auto;
white-space: pre;
}
.diff-added { color: var(--toolDiffAdded); }
.diff-removed { color: var(--toolDiffRemoved); }
.diff-context { color: var(--toolDiffContext); }
/* Model change */
.model-change {
padding: 0 var(--line-height);
color: var(--dim);
font-size: 11px;
}
.model-name {
color: var(--borderAccent);
font-weight: bold;
}
/* Compaction / Branch Summary - matches customMessage colors from TUI */
.compaction {
background: var(--customMessageBg);
border-radius: 4px;
padding: var(--line-height);
cursor: pointer;
}
.compaction-label {
color: var(--customMessageLabel);
font-weight: bold;
}
.compaction-collapsed {
color: var(--customMessageText);
}
.compaction-content {
display: none;
color: var(--customMessageText);
white-space: pre-wrap;
margin-top: var(--line-height);
}
.compaction.expanded .compaction-collapsed {
display: none;
}
.compaction.expanded .compaction-content {
display: block;
}
/* System prompt */
.system-prompt {
background: var(--customMessageBg);
padding: var(--line-height);
border-radius: 4px;
margin-bottom: var(--line-height);
}
.system-prompt-header {
font-weight: bold;
color: var(--customMessageLabel);
}
.system-prompt-content {
color: var(--customMessageText);
white-space: pre-wrap;
word-wrap: break-word;
font-size: 11px;
max-height: 200px;
overflow-y: auto;
margin-top: var(--line-height);
}
/* Tools list */
.tools-list {
background: var(--customMessageBg);
padding: var(--line-height);
border-radius: 4px;
margin-bottom: var(--line-height);
}
.tools-header {
font-weight: bold;
color: var(--warning);
margin-bottom: var(--line-height);
}
.tool-item {
font-size: 11px;
}
.tool-item-name {
font-weight: bold;
color: var(--text);
}
.tool-item-desc {
color: var(--dim);
}
/* Hook/custom messages */
.hook-message {
background: var(--customMessageBg);
color: var(--customMessageText);
padding: var(--line-height);
border-radius: 4px;
}
.hook-type {
color: var(--customMessageLabel);
font-weight: bold;
}
/* Branch summary */
.branch-summary {
background: var(--customMessageBg);
padding: var(--line-height);
border-radius: 4px;
}
.branch-summary-header {
font-weight: bold;
color: var(--borderAccent);
}
/* Error */
.error-text {
color: var(--error);
padding: 0 var(--line-height);
}
/* Images */
.message-images {
margin-bottom: 12px;
}
.message-image {
max-width: 100%;
max-height: 400px;
border-radius: 4px;
margin: var(--line-height) 0;
}
/* Markdown content */
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
color: var(--mdHeading);
margin: var(--line-height) 0 0 0;
font-weight: bold;
}
.markdown-content h1 { font-size: 1em; }
.markdown-content h2 { font-size: 1em; }
.markdown-content h3 { font-size: 1em; }
.markdown-content h4 { font-size: 1em; }
.markdown-content h5 { font-size: 1em; }
.markdown-content h6 { font-size: 1em; }
.markdown-content p { margin: 0; }
.markdown-content p + p { margin-top: var(--line-height); }
.markdown-content a {
color: var(--mdLink);
text-decoration: underline;
}
.markdown-content code {
background: rgba(128, 128, 128, 0.2);
color: var(--mdCode);
padding: 0 4px;
border-radius: 3px;
font-family: inherit;
}
.markdown-content pre {
background: transparent;
margin: var(--line-height) 0;
overflow-x: auto;
}
.markdown-content pre code {
display: block;
background: none;
color: var(--text);
}
.markdown-content blockquote {
border-left: 3px solid var(--mdQuoteBorder);
padding-left: var(--line-height);
margin: var(--line-height) 0;
color: var(--mdQuote);
font-style: italic;
}
.markdown-content ul,
.markdown-content ol {
margin: var(--line-height) 0;
padding-left: calc(var(--line-height) * 2);
}
.markdown-content li { margin: 0; }
.markdown-content li::marker { color: var(--mdListBullet); }
.markdown-content hr {
border: none;
border-top: 1px solid var(--mdHr);
margin: var(--line-height) 0;
}
.markdown-content table {
border-collapse: collapse;
margin: 0.5em 0;
width: 100%;
}
.markdown-content th,
.markdown-content td {
border: 1px solid var(--mdCodeBlockBorder);
padding: 6px 10px;
text-align: left;
}
.markdown-content th {
background: rgba(128, 128, 128, 0.1);
font-weight: bold;
}
.markdown-content img {
max-width: 100%;
border-radius: 4px;
}
/* Syntax highlighting */
.hljs { background: transparent; color: var(--text); }
.hljs-comment, .hljs-quote { color: var(--syntaxComment); }
.hljs-keyword, .hljs-selector-tag { color: var(--syntaxKeyword); }
.hljs-number, .hljs-literal { color: var(--syntaxNumber); }
.hljs-string, .hljs-doctag { color: var(--syntaxString); }
/* Function names: hljs v11 uses .hljs-title.function_ compound class */
.hljs-function, .hljs-title, .hljs-title.function_, .hljs-section, .hljs-name { color: var(--syntaxFunction); }
/* Types: hljs v11 uses .hljs-title.class_ for class names */
.hljs-type, .hljs-class, .hljs-title.class_, .hljs-built_in { color: var(--syntaxType); }
.hljs-attr, .hljs-variable, .hljs-variable.language_, .hljs-params, .hljs-property { color: var(--syntaxVariable); }
.hljs-meta, .hljs-meta .hljs-keyword, .hljs-meta .hljs-string { color: var(--syntaxKeyword); }
.hljs-operator { color: var(--syntaxOperator); }
.hljs-punctuation { color: var(--syntaxPunctuation); }
.hljs-subst { color: var(--text); }
/* Footer */
.footer {
margin-top: 48px;
padding: 20px;
text-align: center;
color: var(--dim);
font-size: 10px;
}
/* Mobile */
#hamburger {
display: none;
position: fixed;
top: 10px;
left: 10px;
z-index: 100;
padding: 3px 8px;
font-size: 12px;
font-family: inherit;
background: transparent;
color: var(--muted);
border: 1px solid var(--dim);
border-radius: 3px;
cursor: pointer;
}
#hamburger:hover {
color: var(--text);
border-color: var(--text);
}
#sidebar-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 98;
}
@media (max-width: 900px) {
#sidebar {
position: fixed;
left: -400px;
width: 400px;
top: 0;
bottom: 0;
height: 100vh;
z-index: 99;
transition: left 0.3s;
}
#sidebar.open {
left: 0;
}
#sidebar-overlay.open {
display: block;
}
#hamburger {
display: block;
}
.sidebar-close {
display: block;
}
#content {
padding: var(--line-height) 16px;
}
#content > * {
max-width: 100%;
}
}
@media (max-width: 500px) {
#sidebar {
width: 100vw;
left: -100vw;
}
}
@media print {
#sidebar, #sidebar-toggle { display: none !important; }
body { background: white; color: black; }
#content { max-width: none; }
}

View file

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Session Export</title>
<style>
{{CSS}}
</style>
</head>
<body>
<button id="hamburger" title="Open sidebar"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><rect x="5" y="6" width="2" height="12"/><path d="M6 12h10c1 0 2 0 2-2V8"/></svg></button>
<div id="sidebar-overlay"></div>
<div id="app">
<aside id="sidebar">
<div class="sidebar-header">
<div class="sidebar-controls">
<input type="text" class="sidebar-search" id="tree-search" placeholder="Search...">
</div>
<div class="sidebar-filters">
<button class="filter-btn active" data-filter="default" title="Hide settings entries">Default</button>
<button class="filter-btn" data-filter="no-tools" title="Default minus tool results">No-tools</button>
<button class="filter-btn" data-filter="user-only" title="Only user messages">User</button>
<button class="filter-btn" data-filter="labeled-only" title="Only labeled entries">Labeled</button>
<button class="filter-btn" data-filter="all" title="Show everything">All</button>
<button class="sidebar-close" id="sidebar-close" title="Close"></button>
</div>
</div>
<div class="tree-container" id="tree-container"></div>
<div class="tree-status" id="tree-status"></div>
</aside>
<main id="content">
<div id="header-container"></div>
<div id="messages"></div>
</main>
<div id="image-modal" class="image-modal">
<img id="modal-image" src="" alt="">
</div>
</div>
<script id="session-data" type="application/json">{{SESSION_DATA}}</script>
<!-- Vendored libraries -->
<script>{{MARKED_JS}}</script>
<!-- highlight.js -->
<script>{{HIGHLIGHT_JS}}</script>
<!-- Main application code -->
<script>
{{JS}}
</script>
</body>
</html>

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -3,11 +3,14 @@ export {
discoverAndLoadHooks,
loadHooks,
type AppendEntryHandler,
type BranchHandler,
type LoadedHook,
type LoadHooksResult,
type NavigateTreeHandler,
type NewSessionHandler,
type SendMessageHandler,
} from "./loader.js";
export { execCommand, HookRunner, type HookErrorListener } from "./runner.js";
export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js";
export type * from "./types.js";
export * from "./types.js";
export type { ReadonlySessionManager } from "../session-manager.js";

View file

@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import { getAgentDir } from "../../config.js";
import type { HookMessage } from "../messages.js";
import type { SessionManager } from "../session-manager.js";
import { execCommand } from "./runner.js";
import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types.js";
@ -52,7 +53,7 @@ type HandlerFn = (...args: unknown[]) => Promise<unknown>;
*/
export type SendMessageHandler = <T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
triggerTurn?: boolean,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
) => void;
/**
@ -60,6 +61,27 @@ export type SendMessageHandler = <T = unknown>(
*/
export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) => void;
/**
* New session handler type for ctx.newSession() in HookCommandContext.
*/
export type NewSessionHandler = (options?: {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}) => Promise<{ cancelled: boolean }>;
/**
* Branch handler type for ctx.branch() in HookCommandContext.
*/
export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>;
/**
* Navigate tree handler type for ctx.navigateTree() in HookCommandContext.
*/
export type NavigateTreeHandler = (
targetId: string,
options?: { summarize?: boolean },
) => Promise<{ cancelled: boolean }>;
/**
* Registered handlers for a loaded hook.
*/
@ -126,7 +148,7 @@ function resolveHookPath(hookPath: string, cwd: string): string {
/**
* Create a HookAPI instance that collects handlers, renderers, and commands.
* Returns the API, maps, and a function to set the send message handler later.
* Returns the API, maps, and functions to set handlers later.
*/
function createHookAPI(
handlers: Map<string, HandlerFn[]>,
@ -155,8 +177,11 @@ function createHookAPI(
list.push(handler);
handlers.set(event, list);
},
sendMessage<T = unknown>(message: HookMessage<T>, triggerTurn?: boolean): void {
sendMessageHandler(message, triggerTurn);
sendMessage<T = unknown>(
message: HookMessage<T>,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
): void {
sendMessageHandler(message, options);
},
appendEntry<T = unknown>(customType: string, data?: T): void {
appendEntryHandler(customType, data);

View file

@ -4,14 +4,23 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { Model } from "@mariozechner/pi-ai";
import { theme } from "../../modes/interactive/theme/theme.js";
import type { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js";
import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js";
import type {
AppendEntryHandler,
BranchHandler,
LoadedHook,
NavigateTreeHandler,
NewSessionHandler,
SendMessageHandler,
} from "./loader.js";
import type {
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
ContextEvent,
ContextEventResult,
HookCommandContext,
HookContext,
HookError,
HookEvent,
@ -25,11 +34,6 @@ import type {
ToolResultEventResult,
} from "./types.js";
/**
* Default timeout for hook execution (30 seconds).
*/
const DEFAULT_TIMEOUT = 30000;
/**
* Listener for hook errors.
*/
@ -38,27 +42,20 @@ export type HookErrorListener = (error: HookError) => void;
// Re-export execCommand for backward compatibility
export { execCommand } from "../exec.js";
/**
* Create a promise that rejects after a timeout.
*/
function createTimeout(ms: number): { promise: Promise<never>; clear: () => void } {
let timeoutId: NodeJS.Timeout;
const promise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms);
});
return {
promise,
clear: () => clearTimeout(timeoutId),
};
}
/** No-op UI context used when no UI is available */
const noOpUIContext: HookUIContext = {
select: async () => undefined,
confirm: async () => false,
input: async () => undefined,
notify: () => {},
custom: () => ({ close: () => {}, requestRender: () => {} }),
setStatus: () => {},
custom: async () => undefined as never,
setEditorText: () => {},
getEditorText: () => "",
editor: async () => undefined,
get theme() {
return theme;
},
};
/**
@ -71,24 +68,23 @@ export class HookRunner {
private cwd: string;
private sessionManager: SessionManager;
private modelRegistry: ModelRegistry;
private timeout: number;
private errorListeners: Set<HookErrorListener> = new Set();
private getModel: () => Model<any> | undefined = () => undefined;
private isIdleFn: () => boolean = () => true;
private waitForIdleFn: () => Promise<void> = async () => {};
private abortFn: () => void = () => {};
private hasPendingMessagesFn: () => boolean = () => false;
private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
private branchHandler: BranchHandler = async () => ({ cancelled: false });
private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
constructor(
hooks: LoadedHook[],
cwd: string,
sessionManager: SessionManager,
modelRegistry: ModelRegistry,
timeout: number = DEFAULT_TIMEOUT,
) {
constructor(hooks: LoadedHook[], cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry) {
this.hooks = hooks;
this.uiContext = noOpUIContext;
this.hasUI = false;
this.cwd = cwd;
this.sessionManager = sessionManager;
this.modelRegistry = modelRegistry;
this.timeout = timeout;
}
/**
@ -102,26 +98,47 @@ export class HookRunner {
sendMessageHandler: SendMessageHandler;
/** Handler for hooks to append entries */
appendEntryHandler: AppendEntryHandler;
/** Handler for creating new sessions (for HookCommandContext) */
newSessionHandler?: NewSessionHandler;
/** Handler for branching sessions (for HookCommandContext) */
branchHandler?: BranchHandler;
/** Handler for navigating session tree (for HookCommandContext) */
navigateTreeHandler?: NavigateTreeHandler;
/** Function to check if agent is idle */
isIdle?: () => boolean;
/** Function to wait for agent to be idle */
waitForIdle?: () => Promise<void>;
/** Function to abort current operation (fire-and-forget) */
abort?: () => void;
/** Function to check if there are queued messages */
hasPendingMessages?: () => boolean;
/** UI context for interactive prompts */
uiContext?: HookUIContext;
/** Whether UI is available */
hasUI?: boolean;
}): void {
this.getModel = options.getModel;
this.setSendMessageHandler(options.sendMessageHandler);
this.setAppendEntryHandler(options.appendEntryHandler);
if (options.uiContext) {
this.setUIContext(options.uiContext, options.hasUI ?? false);
this.isIdleFn = options.isIdle ?? (() => true);
this.waitForIdleFn = options.waitForIdle ?? (async () => {});
this.abortFn = options.abort ?? (() => {});
this.hasPendingMessagesFn = options.hasPendingMessages ?? (() => false);
// Store session handlers for HookCommandContext
if (options.newSessionHandler) {
this.newSessionHandler = options.newSessionHandler;
}
}
/**
* Set the UI context for hooks.
* Call this when the mode initializes and UI is available.
*/
setUIContext(uiContext: HookUIContext, hasUI: boolean): void {
this.uiContext = uiContext;
this.hasUI = hasUI;
if (options.branchHandler) {
this.branchHandler = options.branchHandler;
}
if (options.navigateTreeHandler) {
this.navigateTreeHandler = options.navigateTreeHandler;
}
// Set per-hook handlers for pi.sendMessage() and pi.appendEntry()
for (const hook of this.hooks) {
hook.setSendMessageHandler(options.sendMessageHandler);
hook.setAppendEntryHandler(options.appendEntryHandler);
}
this.uiContext = options.uiContext ?? noOpUIContext;
this.hasUI = options.hasUI ?? false;
}
/**
@ -145,26 +162,6 @@ export class HookRunner {
return this.hooks.map((h) => h.path);
}
/**
* Set the send message handler for all hooks' pi.sendMessage().
* Call this when the mode initializes.
*/
setSendMessageHandler(handler: SendMessageHandler): void {
for (const hook of this.hooks) {
hook.setSendMessageHandler(handler);
}
}
/**
* Set the append entry handler for all hooks' pi.appendEntry().
* Call this when the mode initializes.
*/
setAppendEntryHandler(handler: AppendEntryHandler): void {
for (const hook of this.hooks) {
hook.setAppendEntryHandler(handler);
}
}
/**
* Subscribe to hook errors.
* @returns Unsubscribe function
@ -251,6 +248,23 @@ export class HookRunner {
sessionManager: this.sessionManager,
modelRegistry: this.modelRegistry,
model: this.getModel(),
isIdle: () => this.isIdleFn(),
abort: () => this.abortFn(),
hasPendingMessages: () => this.hasPendingMessagesFn(),
};
}
/**
* Create the command context for slash command handlers.
* Extends HookContext with session control methods that are only safe in commands.
*/
createCommandContext(): HookCommandContext {
return {
...this.createContext(),
waitForIdle: () => this.waitForIdleFn(),
newSession: (options) => this.newSessionHandler(options),
branch: (entryId) => this.branchHandler(entryId),
navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options),
};
}
@ -259,15 +273,9 @@ export class HookRunner {
*/
private isSessionBeforeEvent(
type: string,
): type is
| "session_before_switch"
| "session_before_new"
| "session_before_branch"
| "session_before_compact"
| "session_before_tree" {
): type is "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" {
return (
type === "session_before_switch" ||
type === "session_before_new" ||
type === "session_before_branch" ||
type === "session_before_compact" ||
type === "session_before_tree"
@ -290,16 +298,7 @@ export class HookRunner {
for (const handler of handlers) {
try {
// No timeout for session_before_compact events (like tool_call, they may take a while)
let handlerResult: unknown;
if (event.type === "session_before_compact") {
handlerResult = await handler(event, ctx);
} else {
const timeout = createTimeout(this.timeout);
handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
timeout.clear();
}
const handlerResult = await handler(event, ctx);
// For session before_* events, capture the result (for cancellation)
if (this.isSessionBeforeEvent(event.type) && handlerResult) {
@ -376,9 +375,7 @@ export class HookRunner {
for (const handler of handlers) {
try {
const event: ContextEvent = { type: "context", messages: currentMessages };
const timeout = createTimeout(this.timeout);
const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
timeout.clear();
const handlerResult = await handler(event, ctx);
if (handlerResult && (handlerResult as ContextEventResult).messages) {
currentMessages = (handlerResult as ContextEventResult).messages!;
@ -415,9 +412,7 @@ export class HookRunner {
for (const handler of handlers) {
try {
const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images };
const timeout = createTimeout(this.timeout);
const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
timeout.clear();
const handlerResult = await handler(event, ctx);
// Take the first message returned
if (handlerResult && (handlerResult as BeforeAgentStartEventResult).message && !result) {

View file

@ -46,30 +46,46 @@ export function wrapToolWithHooks<T>(tool: AgentTool<any, T>, hookRunner: HookRu
}
// Execute the actual tool, forwarding onUpdate for progress streaming
const result = await tool.execute(toolCallId, params, signal, onUpdate);
try {
const result = await tool.execute(toolCallId, params, signal, onUpdate);
// Emit tool_result event - hooks can modify the result
if (hookRunner.hasHandlers("tool_result")) {
const resultResult = (await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: result.content,
details: result.details,
isError: false,
})) as ToolResultEventResult | undefined;
// Emit tool_result event - hooks can modify the result
if (hookRunner.hasHandlers("tool_result")) {
const resultResult = (await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: result.content,
details: result.details,
isError: false,
})) as ToolResultEventResult | undefined;
// Apply modifications if any
if (resultResult) {
return {
content: resultResult.content ?? result.content,
details: (resultResult.details ?? result.details) as T,
};
// Apply modifications if any
if (resultResult) {
return {
content: resultResult.content ?? result.content,
details: (resultResult.details ?? result.details) as T,
};
}
}
}
return result;
return result;
} catch (err) {
// Emit tool_result event for errors so hooks can observe failures
if (hookRunner.hasHandlers("tool_result")) {
await hookRunner.emit({
type: "tool_result",
toolName: tool.name,
toolCallId,
input: params,
content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
details: undefined,
isError: true,
});
}
throw err; // Re-throw original error for agent-loop
}
},
};
}

View file

@ -7,33 +7,19 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
import type { Component } from "@mariozechner/pi-tui";
import type { Component, TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { HookMessage } from "../messages.js";
import type { ModelRegistry } from "../model-registry.js";
import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "../session-manager.js";
/**
* Read-only view of SessionManager for hooks.
* Hooks should use pi.sendMessage() and pi.appendEntry() for writes.
*/
export type ReadonlySessionManager = Pick<
import type {
BranchSummaryEntry,
CompactionEntry,
ReadonlySessionManager,
SessionEntry,
SessionManager,
| "getCwd"
| "getSessionDir"
| "getSessionId"
| "getSessionFile"
| "getLeafId"
| "getLeafEntry"
| "getEntry"
| "getLabel"
| "getPath"
| "getHeader"
| "getEntries"
| "getTree"
>;
} from "../session-manager.js";
import type { EditToolDetails } from "../tools/edit.js";
import type {
@ -78,17 +64,84 @@ export interface HookUIContext {
notify(message: string, type?: "info" | "warning" | "error"): void;
/**
* Show a custom component with keyboard focus.
* The component receives keyboard input via handleInput() if implemented.
*
* @param component - Component to display (implement handleInput for keyboard, dispose for cleanup)
* @returns Object with close() to restore normal UI and requestRender() to trigger redraw
* Set status text in the footer/status bar.
* Pass undefined as text to clear the status for this key.
* Text can include ANSI escape codes for styling.
* Note: Newlines, tabs, and carriage returns are replaced with spaces.
* The combined status line is truncated to terminal width.
* @param key - Unique key to identify this status (e.g., hook name)
* @param text - Status text to display, or undefined to clear
*/
custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void };
setStatus(key: string, text: string | undefined): void;
/**
* Show a custom component with keyboard focus.
* The factory receives TUI, theme, and a done() callback to close the component.
* Can be async for fire-and-forget work (don't await the work, just start it).
*
* @param factory - Function that creates the component. Call done() when finished.
* @returns Promise that resolves with the value passed to done()
*
* @example
* // Sync factory
* const result = await ctx.ui.custom((tui, theme, done) => {
* const component = new MyComponent(tui, theme);
* component.onFinish = (value) => done(value);
* return component;
* });
*
* // Async factory with fire-and-forget work
* const result = await ctx.ui.custom(async (tui, theme, done) => {
* const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working...");
* loader.onAbort = () => done(null);
* doWork(loader.signal).then(done); // Don't await - fire and forget
* return loader;
* });
*/
custom<T>(
factory: (
tui: TUI,
theme: Theme,
done: (result: T) => void,
) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
): Promise<T>;
/**
* Set the text in the core input editor.
* Use this to pre-fill the input box with generated content (e.g., prompt templates, extracted questions).
* @param text - Text to set in the editor
*/
setEditorText(text: string): void;
/**
* Get the current text from the core input editor.
* @returns Current editor text
*/
getEditorText(): string;
/**
* Show a multi-line editor for text editing.
* Supports Ctrl+G to open external editor ($VISUAL or $EDITOR).
* @param title - Title describing what is being edited
* @param prefill - Optional initial text
* @returns Edited text, or undefined if cancelled (Escape)
*/
editor(title: string, prefill?: string): Promise<string | undefined>;
/**
* Get the current theme for styling text with ANSI codes.
* Use theme.fg() and theme.bg() to style status text.
*
* @example
* const theme = ctx.ui.theme;
* ctx.ui.setStatus("my-hook", theme.fg("success", "✓") + " Ready");
*/
readonly theme: Theme;
}
/**
* Context passed to hook event and command handlers.
* Context passed to hook event handlers.
* For command handlers, see HookCommandContext which extends this with session control methods.
*/
export interface HookContext {
/** UI methods for user interaction */
@ -103,6 +156,63 @@ export interface HookContext {
modelRegistry: ModelRegistry;
/** Current model (may be undefined if no model is selected yet) */
model: Model<any> | undefined;
/** Whether the agent is idle (not streaming) */
isIdle(): boolean;
/** Abort the current agent operation (fire-and-forget, does not wait) */
abort(): void;
/** Whether there are queued messages waiting to be processed */
hasPendingMessages(): boolean;
}
/**
* Extended context for slash command handlers.
* Includes session control methods that are only safe in user-initiated commands.
*
* These methods are not available in event handlers because they can cause
* deadlocks when called from within the agent loop (e.g., tool_call, context events).
*/
export interface HookCommandContext extends HookContext {
/** Wait for the agent to finish streaming */
waitForIdle(): Promise<void>;
/**
* Start a new session, optionally with a setup callback to initialize it.
* The setup callback receives a writable SessionManager for the new session.
*
* @param options.parentSession - Path to parent session for lineage tracking
* @param options.setup - Async callback to initialize the new session (e.g., append messages)
* @returns Object with `cancelled: true` if a hook cancelled the new session
*
* @example
* // Handoff: summarize current session and start fresh with context
* await ctx.newSession({
* parentSession: ctx.sessionManager.getSessionFile(),
* setup: async (sm) => {
* sm.appendMessage({ role: "user", content: [{ type: "text", text: summary }] });
* }
* });
*/
newSession(options?: {
parentSession?: string;
setup?: (sessionManager: SessionManager) => Promise<void>;
}): Promise<{ cancelled: boolean }>;
/**
* Branch from a specific entry, creating a new session file.
*
* @param entryId - ID of the entry to branch from
* @returns Object with `cancelled: true` if a hook cancelled the branch
*/
branch(entryId: string): Promise<{ cancelled: boolean }>;
/**
* Navigate to a different point in the session tree (in-place).
*
* @param targetId - ID of the entry to navigate to
* @param options.summarize - Whether to summarize the abandoned branch
* @returns Object with `cancelled: true` if a hook cancelled the navigation
*/
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>;
}
// ============================================================================
@ -117,32 +227,26 @@ export interface SessionStartEvent {
/** Fired before switching to another session (can be cancelled) */
export interface SessionBeforeSwitchEvent {
type: "session_before_switch";
/** Session file we're switching to */
targetSessionFile: string;
/** Reason for the switch */
reason: "new" | "resume";
/** Session file we're switching to (only for "resume") */
targetSessionFile?: string;
}
/** Fired after switching to another session */
export interface SessionSwitchEvent {
type: "session_switch";
/** Reason for the switch */
reason: "new" | "resume";
/** Session file we came from */
previousSessionFile: string | undefined;
}
/** Fired before creating a new session (can be cancelled) */
export interface SessionBeforeNewEvent {
type: "session_before_new";
}
/** Fired after creating a new session */
export interface SessionNewEvent {
type: "session_new";
}
/** Fired before branching a session (can be cancelled) */
export interface SessionBeforeBranchEvent {
type: "session_before_branch";
/** Index of the entry in the session (SessionManager.getEntries()) to branch from */
entryIndex: number;
/** ID of the entry to branch from */
entryId: string;
}
/** Fired after branching a session */
@ -218,8 +322,6 @@ export type SessionEvent =
| SessionStartEvent
| SessionBeforeSwitchEvent
| SessionSwitchEvent
| SessionBeforeNewEvent
| SessionNewEvent
| SessionBeforeBranchEvent
| SessionBranchEvent
| SessionBeforeCompactEvent
@ -468,12 +570,6 @@ export interface SessionBeforeSwitchResult {
cancel?: boolean;
}
/** Return type for session_before_new handlers */
export interface SessionBeforeNewResult {
/** If true, cancel the new session */
cancel?: boolean;
}
/** Return type for session_before_branch handlers */
export interface SessionBeforeBranchResult {
/**
@ -551,7 +647,7 @@ export interface RegisteredCommand {
name: string;
description?: string;
handler: (args: string, ctx: HookContext) => Promise<void>;
handler: (args: string, ctx: HookCommandContext) => Promise<void>;
}
/**
@ -563,8 +659,6 @@ export interface HookAPI {
on(event: "session_start", handler: HookHandler<SessionStartEvent>): void;
on(event: "session_before_switch", handler: HookHandler<SessionBeforeSwitchEvent, SessionBeforeSwitchResult>): void;
on(event: "session_switch", handler: HookHandler<SessionSwitchEvent>): void;
on(event: "session_before_new", handler: HookHandler<SessionBeforeNewEvent, SessionBeforeNewResult>): void;
on(event: "session_new", handler: HookHandler<SessionNewEvent>): void;
on(event: "session_before_branch", handler: HookHandler<SessionBeforeBranchEvent, SessionBeforeBranchResult>): void;
on(event: "session_branch", handler: HookHandler<SessionBranchEvent>): void;
on(
@ -598,12 +692,15 @@ export interface HookAPI {
* @param message.content - Message content (string or TextContent/ImageContent array)
* @param message.display - Whether to show in TUI (true = styled display, false = hidden)
* @param message.details - Optional hook-specific metadata (not sent to LLM)
* @param triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false.
* If agent is streaming, message is queued and triggerTurn is ignored.
* @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false.
* If agent is streaming, message is queued and triggerTurn is ignored.
* @param options.deliverAs - How to deliver when agent is streaming. Default: "steer".
* - "steer": Interrupt mid-run, delivered after current tool execution.
* - "followUp": Wait until agent finishes all work before delivery.
*/
sendMessage<T = unknown>(
message: Pick<HookMessage<T>, "customType" | "content" | "display" | "details">,
triggerTurn?: boolean,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
): void;
/**
@ -643,7 +740,7 @@ export interface HookAPI {
/**
* Register a custom slash command.
* Handler receives HookCommandContext.
* Handler receives HookCommandContext with session control methods.
*/
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void;

View file

@ -14,16 +14,16 @@ export {
export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js";
export type { CompactionResult } from "./compaction/index.js";
export {
type CustomAgentTool,
type CustomTool,
type CustomToolAPI,
type CustomToolFactory,
type CustomToolsLoadResult,
type CustomToolUIContext,
discoverAndLoadCustomTools,
type ExecResult,
type LoadedCustomTool,
loadCustomTools,
type RenderResultOptions,
type ToolAPI,
type ToolUIContext,
} from "./custom-tools/index.js";
export {
type HookAPI,

View file

@ -5,6 +5,7 @@
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@mariozechner/pi-ai";
import chalk from "chalk";
import { minimatch } from "minimatch";
import { isValidThinkingLevel } from "../cli/args.js";
import type { ModelRegistry } from "./model-registry.js";
@ -172,6 +173,41 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model
const scopedModels: ScopedModel[] = [];
for (const pattern of patterns) {
// Check if pattern contains glob characters
if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) {
// Extract optional thinking level suffix (e.g., "provider/*:high")
const colonIdx = pattern.lastIndexOf(":");
let globPattern = pattern;
let thinkingLevel: ThinkingLevel = "off";
if (colonIdx !== -1) {
const suffix = pattern.substring(colonIdx + 1);
if (isValidThinkingLevel(suffix)) {
thinkingLevel = suffix;
globPattern = pattern.substring(0, colonIdx);
}
}
// Match against "provider/modelId" format OR just model ID
// This allows "*sonnet*" to match without requiring "anthropic/*sonnet*"
const matchingModels = availableModels.filter((m) => {
const fullId = `${m.provider}/${m.id}`;
return minimatch(fullId, globPattern, { nocase: true }) || minimatch(m.id, globPattern, { nocase: true });
});
if (matchingModels.length === 0) {
console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`));
continue;
}
for (const model of matchingModels) {
if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) {
scopedModels.push({ model, thinkingLevel });
}
}
continue;
}
const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels);
if (warning) {

View file

@ -35,8 +35,13 @@ import { join } from "path";
import { getAgentDir } from "../config.js";
import { AgentSession } from "./agent-session.js";
import { AuthStorage } from "./auth-storage.js";
import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./custom-tools/index.js";
import type { CustomAgentTool } from "./custom-tools/types.js";
import {
type CustomToolsLoadResult,
discoverAndLoadCustomTools,
type LoadedCustomTool,
wrapCustomTools,
} from "./custom-tools/index.js";
import type { CustomTool } from "./custom-tools/types.js";
import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js";
import type { HookFactory } from "./hooks/types.js";
import { convertToLlm } from "./messages.js";
@ -99,7 +104,7 @@ export interface CreateAgentSessionOptions {
/** Built-in tools to use. Default: codingTools [read, bash, edit, write] */
tools?: Tool[];
/** Custom tools (replaces discovery). */
customTools?: Array<{ path?: string; tool: CustomAgentTool }>;
customTools?: Array<{ path?: string; tool: CustomTool }>;
/** Additional custom tool paths to load (merged with discovery). */
additionalCustomToolPaths?: string[];
@ -127,18 +132,15 @@ export interface CreateAgentSessionResult {
/** The created session */
session: AgentSession;
/** Custom tools result (for UI context setup in interactive mode) */
customToolsResult: {
tools: LoadedCustomTool[];
setUIContext: (uiContext: any, hasUI: boolean) => void;
};
customToolsResult: CustomToolsLoadResult;
/** Warning if session was restored with a different model than saved */
modelFallbackMessage?: string;
}
// Re-exports
export type { CustomAgentTool } from "./custom-tools/types.js";
export type { HookAPI, HookFactory } from "./hooks/types.js";
export type { CustomTool } from "./custom-tools/types.js";
export type { HookAPI, HookCommandContext, HookContext, HookFactory } from "./hooks/types.js";
export type { Settings, SkillsSettings } from "./settings-manager.js";
export type { Skill } from "./skills.js";
export type { FileSlashCommand } from "./slash-commands.js";
@ -219,7 +221,7 @@ export async function discoverHooks(
export async function discoverCustomTools(
cwd?: string,
agentDir?: string,
): Promise<Array<{ path: string; tool: CustomAgentTool }>> {
): Promise<Array<{ path: string; tool: CustomTool }>> {
const resolvedCwd = cwd ?? process.cwd();
const resolvedAgentDir = agentDir ?? getDefaultAgentDir();
@ -303,7 +305,8 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
defaultProvider: manager.getDefaultProvider(),
defaultModel: manager.getDefaultModel(),
defaultThinkingLevel: manager.getDefaultThinkingLevel(),
queueMode: manager.getQueueMode(),
steeringMode: manager.getSteeringMode(),
followUpMode: manager.getFollowUpMode(),
theme: manager.getTheme(),
compaction: manager.getCompactionSettings(),
retry: manager.getRetrySettings(),
@ -311,7 +314,6 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings {
shellPath: manager.getShellPath(),
collapseChangelog: manager.getCollapseChangelog(),
hooks: manager.getHookPaths(),
hookTimeout: manager.getHookTimeout(),
customTools: manager.getCustomToolPaths(),
skills: manager.getSkillsSettings(),
terminal: { showImages: manager.getShowImages() },
@ -342,8 +344,16 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>();
const messageRenderers = new Map<string, any>();
const commands = new Map<string, any>();
let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {};
let sendMessageHandler: (
message: any,
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" },
) => void = () => {};
let appendEntryHandler: (customType: string, data?: any) => void = () => {};
let newSessionHandler: (options?: any) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false });
let branchHandler: (entryId: string) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false });
let navigateTreeHandler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }> = async () => ({
cancelled: false,
});
const api = {
on: (event: string, handler: (...args: unknown[]) => Promise<unknown>) => {
@ -351,8 +361,8 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
list.push(handler);
handlers.set(event, list);
},
sendMessage: (message: any, triggerTurn?: boolean) => {
sendMessageHandler(message, triggerTurn);
sendMessage: (message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }) => {
sendMessageHandler(message, options);
},
appendEntry: (customType: string, data?: any) => {
appendEntryHandler(customType, data);
@ -363,6 +373,9 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
registerCommand: (name: string, options: any) => {
commands.set(name, { name, ...options });
},
newSession: (options?: any) => newSessionHandler(options),
branch: (entryId: string) => branchHandler(entryId),
navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options),
};
def.factory(api as any);
@ -373,12 +386,23 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
handlers,
messageRenderers,
commands,
setSendMessageHandler: (handler: (message: any, triggerTurn?: boolean) => void) => {
setSendMessageHandler: (
handler: (message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }) => void,
) => {
sendMessageHandler = handler;
},
setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => {
appendEntryHandler = handler;
},
setNewSessionHandler: (handler: (options?: any) => Promise<{ cancelled: boolean }>) => {
newSessionHandler = handler;
},
setBranchHandler: (handler: (entryId: string) => Promise<{ cancelled: boolean }>) => {
branchHandler = handler;
},
setNavigateTreeHandler: (handler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }>) => {
navigateTreeHandler = handler;
},
};
});
}
@ -504,10 +528,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir);
time("discoverContextFiles");
const builtInTools = options.tools ?? createCodingTools(cwd);
const autoResizeImages = settingsManager.getImageAutoResize();
const builtInTools = options.tools ?? createCodingTools(cwd, { read: { autoResizeImages } });
time("createCodingTools");
let customToolsResult: { tools: LoadedCustomTool[]; setUIContext: (ctx: any, hasUI: boolean) => void };
let customToolsResult: CustomToolsLoadResult;
if (options.customTools !== undefined) {
// Use provided custom tools
const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({
@ -517,24 +542,24 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}));
customToolsResult = {
tools: loadedTools,
errors: [],
setUIContext: () => {},
};
} else {
// Discover custom tools, merging with additional paths
const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])];
const result = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir);
customToolsResult = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir);
time("discoverAndLoadCustomTools");
for (const { path, error } of result.errors) {
for (const { path, error } of customToolsResult.errors) {
console.error(`Failed to load custom tool "${path}": ${error}`);
}
customToolsResult = result;
}
let hookRunner: HookRunner | undefined;
if (options.hooks !== undefined) {
if (options.hooks.length > 0) {
const loadedHooks = createLoadedHooksFromDefinitions(options.hooks);
hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry, settingsManager.getHookTimeout());
hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry);
}
} else {
// Discover hooks, merging with additional paths
@ -545,11 +570,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
console.error(`Failed to load hook "${path}": ${error}`);
}
if (hooks.length > 0) {
hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry, settingsManager.getHookTimeout());
hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry);
}
}
let allToolsArray: Tool[] = [...builtInTools, ...customToolsResult.tools.map((lt) => lt.tool as unknown as Tool)];
// Wrap custom tools with context getter (agent/session assigned below, accessed at execute time)
let agent: Agent;
let session: AgentSession;
const wrappedCustomTools = wrapCustomTools(customToolsResult.tools, () => ({
sessionManager,
modelRegistry,
model: agent.state.model,
isIdle: () => !session.isStreaming,
hasPendingMessages: () => session.pendingMessageCount > 0,
abort: () => {
session.abort();
},
}));
let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools];
time("combineTools");
if (hookRunner) {
allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[];
@ -581,7 +620,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir);
time("discoverSlashCommands");
const agent = new Agent({
agent = new Agent({
initialState: {
systemPrompt,
model,
@ -594,7 +633,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
return hookRunner.emitContext(messages);
}
: undefined,
queueMode: settingsManager.getQueueMode(),
steeringMode: settingsManager.getSteeringMode(),
followUpMode: settingsManager.getFollowUpMode(),
getApiKey: async () => {
const currentModel = agent.state.model;
if (!currentModel) {
@ -612,9 +652,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Restore messages if session has existing data
if (hasExistingSession) {
agent.replaceMessages(existingSession.messages);
} else {
// Save initial model and thinking level for new sessions so they can be restored on resume
if (model) {
sessionManager.appendModelChange(model.provider, model.id);
}
sessionManager.appendThinkingLevelChange(thinkingLevel);
}
const session = new AgentSession({
session = new AgentSession({
agent,
sessionManager,
settingsManager,

View file

@ -31,7 +31,11 @@ export interface SessionHeader {
id: string;
timestamp: string;
cwd: string;
branchedFrom?: string;
parentSession?: string;
}
export interface NewSessionOptions {
parentSession?: string;
}
export interface SessionEntryBase {
@ -159,19 +163,21 @@ export interface SessionInfo {
allMessagesText: string;
}
/**
* Read-only interface for SessionManager.
* Used by compaction/summarization utilities that only need to read session data.
*/
export interface ReadonlySessionManager {
getLeafId(): string | null;
getEntry(id: string): SessionEntry | undefined;
getPath(fromId?: string): SessionEntry[];
getEntries(): SessionEntry[];
getChildren(parentId: string): SessionEntry[];
getTree(): SessionTreeNode[];
getLabel(id: string): string | undefined;
}
export type ReadonlySessionManager = Pick<
SessionManager,
| "getCwd"
| "getSessionDir"
| "getSessionId"
| "getSessionFile"
| "getLeafId"
| "getLeafEntry"
| "getEntry"
| "getLabel"
| "getBranch"
| "getHeader"
| "getEntries"
| "getTree"
>;
/** Generate a unique short ID (8 hex chars, collision-checked) */
function generateId(byId: { has(id: string): boolean }): string {
@ -506,7 +512,7 @@ export class SessionManager {
}
}
newSession(): string | undefined {
newSession(options?: NewSessionOptions): string | undefined {
this.sessionId = randomUUID();
const timestamp = new Date().toISOString();
const header: SessionHeader = {
@ -515,11 +521,13 @@ export class SessionManager {
id: this.sessionId,
timestamp,
cwd: this.cwd,
parentSession: options?.parentSession,
};
this.fileEntries = [header];
this.byId.clear();
this.leafId = null;
this.flushed = false;
// Only generate filename if persisting and not already set (e.g., via --session flag)
if (this.persist && !this.sessionFile) {
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
@ -772,7 +780,7 @@ export class SessionManager {
* Includes all entry types (messages, compaction, model changes, etc.).
* Use buildSessionContext() to get the resolved messages for the LLM.
*/
getPath(fromId?: string): SessionEntry[] {
getBranch(fromId?: string): SessionEntry[] {
const path: SessionEntry[] = [];
const startId = fromId ?? this.leafId;
let current = startId ? this.byId.get(startId) : undefined;
@ -908,7 +916,7 @@ export class SessionManager {
* Returns the new session file path, or undefined if not persisting.
*/
createBranchedSession(leafId: string): string | undefined {
const path = this.getPath(leafId);
const path = this.getBranch(leafId);
if (path.length === 0) {
throw new Error(`Entry ${leafId} not found`);
}
@ -927,7 +935,7 @@ export class SessionManager {
id: newSessionId,
timestamp,
cwd: this.cwd,
branchedFrom: this.persist ? this.sessionFile : undefined,
parentSession: this.persist ? this.sessionFile : undefined,
};
// Collect labels for entries in the path

View file

@ -34,12 +34,17 @@ export interface TerminalSettings {
showImages?: boolean; // default: true (only relevant if terminal supports images)
}
export interface ImageSettings {
autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility)
}
export interface Settings {
lastChangelogVersion?: string;
defaultProvider?: string;
defaultModel?: string;
defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
queueMode?: "all" | "one-at-a-time";
steeringMode?: "all" | "one-at-a-time";
followUpMode?: "all" | "one-at-a-time";
theme?: string;
compaction?: CompactionSettings;
branchSummary?: BranchSummarySettings;
@ -48,10 +53,10 @@ 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)
hooks?: string[]; // Array of hook file paths
hookTimeout?: number; // Timeout for hook execution in ms (default: 30000)
customTools?: string[]; // Array of custom tool file paths
skills?: SkillsSettings;
terminal?: TerminalSettings;
images?: ImageSettings;
enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag)
}
@ -126,13 +131,24 @@ export class SettingsManager {
}
try {
const content = readFileSync(path, "utf-8");
return JSON.parse(content);
const settings = JSON.parse(content);
return SettingsManager.migrateSettings(settings);
} catch (error) {
console.error(`Warning: Could not read settings file ${path}: ${error}`);
return {};
}
}
/** Migrate old settings format to new format */
private static migrateSettings(settings: Record<string, unknown>): Settings {
// Migrate queueMode -> steeringMode
if ("queueMode" in settings && !("steeringMode" in settings)) {
settings.steeringMode = settings.queueMode;
delete settings.queueMode;
}
return settings as Settings;
}
private loadProjectSettings(): Settings {
if (!this.projectSettingsPath || !existsSync(this.projectSettingsPath)) {
return {};
@ -140,7 +156,8 @@ export class SettingsManager {
try {
const content = readFileSync(this.projectSettingsPath, "utf-8");
return JSON.parse(content);
const settings = JSON.parse(content);
return SettingsManager.migrateSettings(settings);
} catch (error) {
console.error(`Warning: Could not read project settings file: ${error}`);
return {};
@ -205,12 +222,21 @@ export class SettingsManager {
this.save();
}
getQueueMode(): "all" | "one-at-a-time" {
return this.settings.queueMode || "one-at-a-time";
getSteeringMode(): "all" | "one-at-a-time" {
return this.settings.steeringMode || "one-at-a-time";
}
setQueueMode(mode: "all" | "one-at-a-time"): void {
this.globalSettings.queueMode = mode;
setSteeringMode(mode: "all" | "one-at-a-time"): void {
this.globalSettings.steeringMode = mode;
this.save();
}
getFollowUpMode(): "all" | "one-at-a-time" {
return this.settings.followUpMode || "one-at-a-time";
}
setFollowUpMode(mode: "all" | "one-at-a-time"): void {
this.globalSettings.followUpMode = mode;
this.save();
}
@ -322,15 +348,6 @@ export class SettingsManager {
this.save();
}
getHookTimeout(): number {
return this.settings.hookTimeout ?? 30000;
}
setHookTimeout(timeout: number): void {
this.globalSettings.hookTimeout = timeout;
this.save();
}
getCustomToolPaths(): string[] {
return [...(this.settings.customTools ?? [])];
}
@ -378,6 +395,18 @@ export class SettingsManager {
this.save();
}
getImageAutoResize(): boolean {
return this.settings.images?.autoResize ?? true;
}
setImageAutoResize(enabled: boolean): void {
if (!this.globalSettings.images) {
this.globalSettings.images = {};
}
this.globalSettings.images.autoResize = enabled;
this.save();
}
getEnabledModels(): string[] | undefined {
return this.settings.enabledModels;
}

View file

@ -5,7 +5,7 @@
import chalk from "chalk";
import { existsSync, readFileSync } from "fs";
import { join, resolve } from "path";
import { getAgentDir, getDocsPath, getReadmePath } from "../config.js";
import { getAgentDir, getDocsPath, getExamplesPath, getReadmePath } from "../config.js";
import type { SkillsSettings } from "./settings-manager.js";
import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills.js";
import type { ToolName } from "./tools/index.js";
@ -202,9 +202,10 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
return prompt;
}
// Get absolute paths to documentation
// Get absolute paths to documentation and examples
const readmePath = getReadmePath();
const docsPath = getDocsPath();
const examplesPath = getExamplesPath();
// Build tools list based on selected tools
const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]);
@ -279,7 +280,9 @@ ${guidelines}
Documentation:
- Main documentation: ${readmePath}
- Additional docs: ${docsPath}
- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md)`;
- 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`;
if (appendSection) {
prompt += appendSection;

View file

@ -0,0 +1,211 @@
/**
* Shared diff computation utilities for the edit tool.
* Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering).
*/
import * as Diff from "diff";
import { constants } from "fs";
import { access, readFile } from "fs/promises";
import { resolveToCwd } from "./path-utils.js";
export function detectLineEnding(content: string): "\r\n" | "\n" {
const crlfIdx = content.indexOf("\r\n");
const lfIdx = content.indexOf("\n");
if (lfIdx === -1) return "\n";
if (crlfIdx === -1) return "\n";
return crlfIdx < lfIdx ? "\r\n" : "\n";
}
export function normalizeToLF(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
}
/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */
export function stripBom(content: string): { bom: string; text: string } {
return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content };
}
/**
* Generate a unified diff string with line numbers and context.
* Returns both the diff string and the first changed line number (in the new file).
*/
export function generateDiffString(
oldContent: string,
newContent: string,
contextLines = 4,
): { diff: string; firstChangedLine: number | undefined } {
const parts = Diff.diffLines(oldContent, newContent);
const output: string[] = [];
const oldLines = oldContent.split("\n");
const newLines = newContent.split("\n");
const maxLineNum = Math.max(oldLines.length, newLines.length);
const lineNumWidth = String(maxLineNum).length;
let oldLineNum = 1;
let newLineNum = 1;
let lastWasChange = false;
let firstChangedLine: number | undefined;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const raw = part.value.split("\n");
if (raw[raw.length - 1] === "") {
raw.pop();
}
if (part.added || part.removed) {
// Capture the first changed line (in the new file)
if (firstChangedLine === undefined) {
firstChangedLine = newLineNum;
}
// Show the change
for (const line of raw) {
if (part.added) {
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
output.push(`+${lineNum} ${line}`);
newLineNum++;
} else {
// removed
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(`-${lineNum} ${line}`);
oldLineNum++;
}
}
lastWasChange = true;
} else {
// Context lines - only show a few before/after changes
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
if (lastWasChange || nextPartIsChange) {
// Show context
let linesToShow = raw;
let skipStart = 0;
let skipEnd = 0;
if (!lastWasChange) {
// Show only last N lines as leading context
skipStart = Math.max(0, raw.length - contextLines);
linesToShow = raw.slice(skipStart);
}
if (!nextPartIsChange && linesToShow.length > contextLines) {
// Show only first N lines as trailing context
skipEnd = linesToShow.length - contextLines;
linesToShow = linesToShow.slice(0, contextLines);
}
// Add ellipsis if we skipped lines at start
if (skipStart > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
// Update line numbers for the skipped leading context
oldLineNum += skipStart;
newLineNum += skipStart;
}
for (const line of linesToShow) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(` ${lineNum} ${line}`);
oldLineNum++;
newLineNum++;
}
// Add ellipsis if we skipped lines at end
if (skipEnd > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
// Update line numbers for the skipped trailing context
oldLineNum += skipEnd;
newLineNum += skipEnd;
}
} else {
// Skip these context lines entirely
oldLineNum += raw.length;
newLineNum += raw.length;
}
lastWasChange = false;
}
}
return { diff: output.join("\n"), firstChangedLine };
}
export interface EditDiffResult {
diff: string;
firstChangedLine: number | undefined;
}
export interface EditDiffError {
error: string;
}
/**
* Compute the diff for an edit operation without applying it.
* Used for preview rendering in the TUI before the tool executes.
*/
export async function computeEditDiff(
path: string,
oldText: string,
newText: string,
cwd: string,
): Promise<EditDiffResult | EditDiffError> {
const absolutePath = resolveToCwd(path, cwd);
try {
// Check if file exists and is readable
try {
await access(absolutePath, constants.R_OK);
} catch {
return { error: `File not found: ${path}` };
}
// Read the file
const rawContent = await readFile(absolutePath, "utf-8");
// Strip BOM before matching (LLM won't include invisible BOM in oldText)
const { text: content } = stripBom(rawContent);
const normalizedContent = normalizeToLF(content);
const normalizedOldText = normalizeToLF(oldText);
const normalizedNewText = normalizeToLF(newText);
// Check if old text exists
if (!normalizedContent.includes(normalizedOldText)) {
return {
error: `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
};
}
// Count occurrences
const occurrences = normalizedContent.split(normalizedOldText).length - 1;
if (occurrences > 1) {
return {
error: `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
};
}
// Compute the new content
const index = normalizedContent.indexOf(normalizedOldText);
const normalizedNewContent =
normalizedContent.substring(0, index) +
normalizedNewText +
normalizedContent.substring(index + normalizedOldText.length);
// Check if it would actually change anything
if (normalizedContent === normalizedNewContent) {
return {
error: `No changes would be made to ${path}. The replacement produces identical content.`,
};
}
// Generate the diff
return generateDiffString(normalizedContent, normalizedNewContent);
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}

View file

@ -1,132 +1,10 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import * as Diff from "diff";
import { constants } from "fs";
import { access, readFile, writeFile } from "fs/promises";
import { detectLineEnding, generateDiffString, normalizeToLF, restoreLineEndings, stripBom } from "./edit-diff.js";
import { resolveToCwd } from "./path-utils.js";
function detectLineEnding(content: string): "\r\n" | "\n" {
const crlfIdx = content.indexOf("\r\n");
const lfIdx = content.indexOf("\n");
if (lfIdx === -1) return "\n";
if (crlfIdx === -1) return "\n";
return crlfIdx < lfIdx ? "\r\n" : "\n";
}
function normalizeToLF(text: string): string {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string {
return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
}
/**
* Generate a unified diff string with line numbers and context
* Returns both the diff string and the first changed line number (in the new file)
*/
function generateDiffString(
oldContent: string,
newContent: string,
contextLines = 4,
): { diff: string; firstChangedLine: number | undefined } {
const parts = Diff.diffLines(oldContent, newContent);
const output: string[] = [];
const oldLines = oldContent.split("\n");
const newLines = newContent.split("\n");
const maxLineNum = Math.max(oldLines.length, newLines.length);
const lineNumWidth = String(maxLineNum).length;
let oldLineNum = 1;
let newLineNum = 1;
let lastWasChange = false;
let firstChangedLine: number | undefined;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const raw = part.value.split("\n");
if (raw[raw.length - 1] === "") {
raw.pop();
}
if (part.added || part.removed) {
// Capture the first changed line (in the new file)
if (firstChangedLine === undefined) {
firstChangedLine = newLineNum;
}
// Show the change
for (const line of raw) {
if (part.added) {
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
output.push(`+${lineNum} ${line}`);
newLineNum++;
} else {
// removed
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(`-${lineNum} ${line}`);
oldLineNum++;
}
}
lastWasChange = true;
} else {
// Context lines - only show a few before/after changes
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
if (lastWasChange || nextPartIsChange) {
// Show context
let linesToShow = raw;
let skipStart = 0;
let skipEnd = 0;
if (!lastWasChange) {
// Show only last N lines as leading context
skipStart = Math.max(0, raw.length - contextLines);
linesToShow = raw.slice(skipStart);
}
if (!nextPartIsChange && linesToShow.length > contextLines) {
// Show only first N lines as trailing context
skipEnd = linesToShow.length - contextLines;
linesToShow = linesToShow.slice(0, contextLines);
}
// Add ellipsis if we skipped lines at start
if (skipStart > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
// Update line numbers for the skipped leading context
oldLineNum += skipStart;
newLineNum += skipStart;
}
for (const line of linesToShow) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(` ${lineNum} ${line}`);
oldLineNum++;
newLineNum++;
}
// Add ellipsis if we skipped lines at end
if (skipEnd > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
// Update line numbers for the skipped trailing context
oldLineNum += skipEnd;
newLineNum += skipEnd;
}
} else {
// Skip these context lines entirely
oldLineNum += raw.length;
newLineNum += raw.length;
}
lastWasChange = false;
}
}
return { diff: output.join("\n"), firstChangedLine };
}
const editSchema = Type.Object({
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
oldText: Type.String({ description: "Exact text to find and replace (must match exactly)" }),
@ -196,13 +74,16 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
}
// Read the file
const content = await readFile(absolutePath, "utf-8");
const rawContent = await readFile(absolutePath, "utf-8");
// Check if aborted after reading
if (aborted) {
return;
}
// Strip BOM before matching (LLM won't include invisible BOM in oldText)
const { bom, text: content } = stripBom(rawContent);
const originalEnding = detectLineEnding(content);
const normalizedContent = normalizeToLF(content);
const normalizedOldText = normalizeToLF(oldText);
@ -262,7 +143,7 @@ export function createEditTool(cwd: string): AgentTool<typeof editSchema> {
return;
}
const finalContent = restoreLineEndings(normalizedNewContent, originalEnding);
const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding);
await writeFile(absolutePath, finalContent, "utf-8");
// Check if aborted after writing

View file

@ -3,7 +3,7 @@ export { createEditTool, editTool } from "./edit.js";
export { createFindTool, type FindToolDetails, findTool } from "./find.js";
export { createGrepTool, type GrepToolDetails, grepTool } from "./grep.js";
export { createLsTool, type LsToolDetails, lsTool } from "./ls.js";
export { createReadTool, type ReadToolDetails, readTool } from "./read.js";
export { createReadTool, type ReadToolDetails, type ReadToolOptions, readTool } from "./read.js";
export type { TruncationResult } from "./truncate.js";
export { createWriteTool, writeTool } from "./write.js";
@ -13,7 +13,7 @@ import { createEditTool, editTool } from "./edit.js";
import { createFindTool, findTool } from "./find.js";
import { createGrepTool, grepTool } from "./grep.js";
import { createLsTool, lsTool } from "./ls.js";
import { createReadTool, readTool } from "./read.js";
import { createReadTool, type ReadToolOptions, readTool } from "./read.js";
import { createWriteTool, writeTool } from "./write.js";
/** Tool type (AgentTool from pi-ai) */
@ -38,26 +38,31 @@ export const allTools = {
export type ToolName = keyof typeof allTools;
export interface ToolsOptions {
/** Options for the read tool */
read?: ReadToolOptions;
}
/**
* Create coding tools configured for a specific working directory.
*/
export function createCodingTools(cwd: string): Tool[] {
return [createReadTool(cwd), createBashTool(cwd), createEditTool(cwd), createWriteTool(cwd)];
export function createCodingTools(cwd: string, options?: ToolsOptions): Tool[] {
return [createReadTool(cwd, options?.read), createBashTool(cwd), createEditTool(cwd), createWriteTool(cwd)];
}
/**
* Create read-only tools configured for a specific working directory.
*/
export function createReadOnlyTools(cwd: string): Tool[] {
return [createReadTool(cwd), createGrepTool(cwd), createFindTool(cwd), createLsTool(cwd)];
export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] {
return [createReadTool(cwd, options?.read), createGrepTool(cwd), createFindTool(cwd), createLsTool(cwd)];
}
/**
* Create all tools configured for a specific working directory.
*/
export function createAllTools(cwd: string): Record<ToolName, Tool> {
export function createAllTools(cwd: string, options?: ToolsOptions): Record<ToolName, Tool> {
return {
read: createReadTool(cwd),
read: createReadTool(cwd, options?.read),
bash: createBashTool(cwd),
edit: createEditTool(cwd),
write: createWriteTool(cwd),

View file

@ -3,6 +3,7 @@ import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access, readFile } from "fs/promises";
import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js";
import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
import { resolveReadPath } from "./path-utils.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js";
@ -17,7 +18,13 @@ export interface ReadToolDetails {
truncation?: TruncationResult;
}
export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
export interface ReadToolOptions {
/** Whether to auto-resize images to 2000x2000 max. Default: true */
autoResizeImages?: boolean;
}
export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool<typeof readSchema> {
const autoResizeImages = options?.autoResizeImages ?? true;
return {
name: "read",
label: "read",
@ -72,10 +79,26 @@ export function createReadTool(cwd: string): AgentTool<typeof readSchema> {
const buffer = await readFile(absolutePath);
const base64 = buffer.toString("base64");
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
if (autoResizeImages) {
// Resize image if needed
const resized = await resizeImage({ type: "image", data: base64, mimeType });
const dimensionNote = formatDimensionNote(resized);
let textNote = `Read image file [${resized.mimeType}]`;
if (dimensionNote) {
textNote += `\n${dimensionNote}`;
}
content = [
{ type: "text", text: textNote },
{ type: "image", data: resized.data, mimeType: resized.mimeType },
];
} else {
content = [
{ type: "text", text: `Read image file [${mimeType}]` },
{ type: "image", data: base64, mimeType },
];
}
} else {
// Read as text
const textContent = await readFile(absolutePath, "utf-8");

View file

@ -30,20 +30,22 @@ export {
generateSummary,
getLastAssistantUsage,
prepareBranchEntries,
serializeConversation,
shouldCompact,
} from "./core/compaction/index.js";
// Custom tools
export type {
AgentToolUpdateCallback,
CustomAgentTool,
CustomTool,
CustomToolAPI,
CustomToolContext,
CustomToolFactory,
CustomToolSessionEvent,
CustomToolsLoadResult,
CustomToolUIContext,
ExecResult,
LoadedCustomTool,
RenderResultOptions,
SessionEvent as ToolSessionEvent,
ToolAPI,
ToolUIContext,
} from "./core/custom-tools/index.js";
export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js";
export type * from "./core/hooks/index.js";
@ -86,6 +88,10 @@ export {
discoverSkills,
discoverSlashCommands,
type FileSlashCommand,
// Hook types
type HookAPI,
type HookContext,
type HookFactory,
loadSettings,
// Pre-built tools (use process.cwd())
readOnlyTools,
@ -101,6 +107,7 @@ export {
getLatestCompactionEntry,
type ModelChangeEntry,
migrateSessionEntries,
type NewSessionOptions,
parseSessionEntries,
type SessionContext,
type SessionEntry,
@ -113,6 +120,7 @@ export {
} from "./core/session-manager.js";
export {
type CompactionSettings,
type ImageSettings,
type RetrySettings,
type Settings,
SettingsManager,
@ -142,11 +150,15 @@ export {
type LsToolDetails,
lsTool,
type ReadToolDetails,
type ReadToolOptions,
readTool,
type ToolsOptions,
type TruncationResult,
writeTool,
} from "./core/tools/index.js";
// Main entry point
export { main } from "./main.js";
// Theme utilities for custom tools
export { getMarkdownTheme } from "./modes/interactive/theme/theme.js";
// UI components for hooks
export { BorderedLoader } from "./modes/interactive/components/bordered-loader.js";
// Theme utilities for custom tools and hooks
export { getMarkdownTheme, Theme, type ThemeColor } from "./modes/interactive/theme/theme.js";

View file

@ -17,7 +17,7 @@ import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.j
import type { AgentSession } from "./core/agent-session.js";
import type { LoadedCustomTool } from "./core/custom-tools/index.js";
import { exportFromFile } from "./core/export-html.js";
import { exportFromFile } from "./core/export-html/index.js";
import type { HookUIContext } from "./core/index.js";
import type { ModelRegistry } from "./core/model-registry.js";
import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js";
@ -119,7 +119,10 @@ async function runInteractiveMode(
}
}
async function prepareInitialMessage(parsed: Args): Promise<{
async function prepareInitialMessage(
parsed: Args,
autoResizeImages: boolean,
): Promise<{
initialMessage?: string;
initialImages?: ImageContent[];
}> {
@ -127,7 +130,7 @@ async function prepareInitialMessage(parsed: Args): Promise<{
return {};
}
const { text, images } = await processFileArguments(parsed.fileArgs);
const { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages });
let initialMessage: string;
if (parsed.messages.length > 0) {
@ -329,13 +332,12 @@ export async function main(args: string[]) {
}
const cwd = process.cwd();
const { initialMessage, initialImages } = await prepareInitialMessage(parsed);
const settingsManager = SettingsManager.create(cwd);
time("SettingsManager.create");
const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
time("prepareInitialMessage");
const isInteractive = !parsed.print && parsed.mode === undefined;
const mode = parsed.mode || "text";
const settingsManager = SettingsManager.create(cwd);
time("SettingsManager.create");
initTheme(settingsManager.getTheme(), isInteractive);
time("initTheme");

View file

@ -53,16 +53,15 @@ export class AssistantMessageComponent extends Container {
if (this.hideThinkingBlock) {
// Show static "Thinking..." label when hidden
this.contentContainer.addChild(new Text(theme.fg("muted", "Thinking..."), 1, 0));
this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0));
if (hasTextAfter) {
this.contentContainer.addChild(new Spacer(1));
}
} else {
// Thinking traces in muted color, italic
// Use Markdown component with default text style for consistent styling
// Thinking traces in thinkingText color, italic
this.contentContainer.addChild(
new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), {
color: (text: string) => theme.fg("muted", text),
color: (text: string) => theme.fg("thinkingText", text),
italic: true,
}),
);

View file

@ -0,0 +1,41 @@
import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import type { Theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
/** Loader wrapped with borders for hook UI */
export class BorderedLoader extends Container {
private loader: CancellableLoader;
constructor(tui: TUI, theme: Theme, message: string) {
super();
const borderColor = (s: string) => theme.fg("border", s);
this.addChild(new DynamicBorder(borderColor));
this.loader = new CancellableLoader(
tui,
(s) => theme.fg("accent", s),
(s) => theme.fg("muted", s),
message,
);
this.addChild(this.loader);
this.addChild(new Spacer(1));
this.addChild(new Text(theme.fg("muted", "esc cancel"), 1, 0));
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder(borderColor));
}
get signal(): AbortSignal {
return this.loader.signal;
}
set onAbort(fn: (() => void) | undefined) {
this.loader.onAbort = fn;
}
handleInput(data: string): void {
this.loader.handleInput(data);
}
dispose(): void {
this.loader.dispose();
}
}

View file

@ -1,5 +1,6 @@
import {
Editor,
isAltEnter,
isCtrlC,
isCtrlD,
isCtrlG,
@ -28,8 +29,14 @@ export class CustomEditor extends Editor {
public onCtrlT?: () => void;
public onCtrlG?: () => void;
public onCtrlZ?: () => void;
public onAltEnter?: () => void;
handleInput(data: string): void {
// Intercept Alt+Enter for follow-up messages
if (isAltEnter(data) && this.onAltEnter) {
this.onAltEnter();
return;
}
// Intercept Ctrl+G for external editor
if (isCtrlG(data) && this.onCtrlG) {
this.onCtrlG();

View file

@ -2,7 +2,11 @@ import type { Component } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
/**
* Dynamic border component that adjusts to viewport width
* Dynamic border component that adjusts to viewport width.
*
* Note: When used from hooks loaded via jiti, the global `theme` may be undefined
* because jiti creates a separate module cache. Always pass an explicit color
* function when using DynamicBorder in components exported for hook use.
*/
export class DynamicBorder implements Component {
private color: (str: string) => string;

View file

@ -1,11 +1,22 @@
import type { AgentState } from "@mariozechner/pi-agent-core";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { type Component, visibleWidth } from "@mariozechner/pi-tui";
import { type Component, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { existsSync, type FSWatcher, readFileSync, watch } from "fs";
import { dirname, join } from "path";
import type { ModelRegistry } from "../../../core/model-registry.js";
import type { AgentSession } from "../../../core/agent-session.js";
import { theme } from "../theme/theme.js";
/**
* Sanitize text for display in a single-line status.
* Removes newlines, tabs, carriage returns, and other control characters.
*/
function sanitizeStatusText(text: string): string {
// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces
return text
.replace(/[\r\n\t]/g, " ")
.replace(/ +/g, " ")
.trim();
}
/**
* Find the git root directory by walking up from cwd.
* Returns the path to .git/HEAD if found, null otherwise.
@ -30,22 +41,36 @@ function findGitHeadPath(): string | null {
* Footer component that shows pwd, token stats, and context usage
*/
export class FooterComponent implements Component {
private state: AgentState;
private modelRegistry: ModelRegistry;
private session: AgentSession;
private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
private gitWatcher: FSWatcher | null = null;
private onBranchChange: (() => void) | null = null;
private autoCompactEnabled: boolean = true;
private hookStatuses: Map<string, string> = new Map();
constructor(state: AgentState, modelRegistry: ModelRegistry) {
this.state = state;
this.modelRegistry = modelRegistry;
constructor(session: AgentSession) {
this.session = session;
}
setAutoCompactEnabled(enabled: boolean): void {
this.autoCompactEnabled = enabled;
}
/**
* Set hook status text to display in the footer.
* Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width.
* ANSI escape codes for styling are preserved.
* @param key - Unique key to identify this status
* @param text - Status text, or undefined to clear
*/
setHookStatus(key: string, text: string | undefined): void {
if (text === undefined) {
this.hookStatuses.delete(key);
} else {
this.hookStatuses.set(key, text);
}
}
/**
* Set up a file watcher on .git/HEAD to detect branch changes.
* Call the provided callback when branch changes.
@ -89,10 +114,6 @@ export class FooterComponent implements Component {
}
}
updateState(state: AgentState): void {
this.state = state;
}
invalidate(): void {
// Invalidate cached branch so it gets re-read on next render
this.cachedBranch = undefined;
@ -132,26 +153,27 @@ export class FooterComponent implements Component {
}
render(width: number): string[] {
// Calculate cumulative usage from all assistant messages
const state = this.session.state;
// Calculate cumulative usage from ALL session entries (not just post-compaction messages)
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheWrite = 0;
let totalCost = 0;
for (const message of this.state.messages) {
if (message.role === "assistant") {
const assistantMsg = message as AssistantMessage;
totalInput += assistantMsg.usage.input;
totalOutput += assistantMsg.usage.output;
totalCacheRead += assistantMsg.usage.cacheRead;
totalCacheWrite += assistantMsg.usage.cacheWrite;
totalCost += assistantMsg.usage.cost.total;
for (const entry of this.session.sessionManager.getEntries()) {
if (entry.type === "message" && entry.message.role === "assistant") {
totalInput += entry.message.usage.input;
totalOutput += entry.message.usage.output;
totalCacheRead += entry.message.usage.cacheRead;
totalCacheWrite += entry.message.usage.cacheWrite;
totalCost += entry.message.usage.cost.total;
}
}
// Get last assistant message for context percentage calculation (skip aborted messages)
const lastAssistantMessage = this.state.messages
const lastAssistantMessage = state.messages
.slice()
.reverse()
.find((m) => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined;
@ -163,7 +185,7 @@ export class FooterComponent implements Component {
lastAssistantMessage.usage.cacheRead +
lastAssistantMessage.usage.cacheWrite
: 0;
const contextWindow = this.state.model?.contextWindow || 0;
const contextWindow = state.model?.contextWindow || 0;
const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
const contextPercent = contextPercentValue.toFixed(1);
@ -209,7 +231,7 @@ export class FooterComponent implements Component {
if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
// Show cost with "(sub)" indicator if using OAuth subscription
const usingSubscription = this.state.model ? this.modelRegistry.isUsingOAuth(this.state.model) : false;
const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
if (totalCost || usingSubscription) {
const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
statsParts.push(costStr);
@ -231,12 +253,12 @@ export class FooterComponent implements Component {
let statsLeft = statsParts.join(" ");
// Add model name on the right side, plus thinking level if model supports it
const modelName = this.state.model?.id || "no-model";
const modelName = state.model?.id || "no-model";
// Add thinking level hint if model supports reasoning and thinking is enabled
let rightSide = modelName;
if (this.state.model?.reasoning) {
const thinkingLevel = this.state.thinkingLevel || "off";
if (state.model?.reasoning) {
const thinkingLevel = state.thinkingLevel || "off";
if (thinkingLevel !== "off") {
rightSide = `${modelName}${thinkingLevel}`;
}
@ -285,6 +307,18 @@ export class FooterComponent implements Component {
const remainder = statsLine.slice(statsLeft.length); // padding + rightSide
const dimRemainder = theme.fg("dim", remainder);
return [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
// Add hook statuses on a single line, sorted by key alphabetically
if (this.hookStatuses.size > 0) {
const sortedStatuses = Array.from(this.hookStatuses.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, text]) => sanitizeStatusText(text));
const statusLine = sortedStatuses.join(" ");
// Truncate to terminal width with dim ellipsis for consistency with footer style
lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
}
return lines;
}
}

View file

@ -0,0 +1,118 @@
/**
* Multi-line editor component for hooks.
* Supports Ctrl+G for external editor.
*/
import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Container, Editor, isCtrlC, isCtrlG, isEscape, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import { getEditorTheme, theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
export class HookEditorComponent extends Container {
private editor: Editor;
private onSubmitCallback: (value: string) => void;
private onCancelCallback: () => void;
private tui: TUI;
constructor(
tui: TUI,
title: string,
prefill: string | undefined,
onSubmit: (value: string) => void,
onCancel: () => void,
) {
super();
this.tui = tui;
this.onSubmitCallback = onSubmit;
this.onCancelCallback = onCancel;
// 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.addChild(new Spacer(1));
// Create editor
this.editor = new Editor(getEditorTheme());
if (prefill) {
this.editor.setText(prefill);
}
this.addChild(this.editor);
this.addChild(new Spacer(1));
// Add hint
const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR);
const hint = hasExternalEditor
? "ctrl+enter submit esc cancel ctrl+g external editor"
: "ctrl+enter submit esc cancel";
this.addChild(new Text(theme.fg("dim", hint), 1, 0));
this.addChild(new Spacer(1));
// Add bottom border
this.addChild(new DynamicBorder());
}
handleInput(keyData: string): void {
// Ctrl+Enter to submit
if (keyData === "\x1b[13;5u" || keyData === "\x1b[27;5;13~") {
this.onSubmitCallback(this.editor.getText());
return;
}
// Escape or Ctrl+C to cancel
if (isEscape(keyData) || isCtrlC(keyData)) {
this.onCancelCallback();
return;
}
// Ctrl+G for external editor
if (isCtrlG(keyData)) {
this.openExternalEditor();
return;
}
// Forward to editor
this.editor.handleInput(keyData);
}
private openExternalEditor(): void {
const editorCmd = process.env.VISUAL || process.env.EDITOR;
if (!editorCmd) {
return;
}
const currentText = this.editor.getText();
const tmpFile = path.join(os.tmpdir(), `pi-hook-editor-${Date.now()}.md`);
try {
fs.writeFileSync(tmpFile, currentText, "utf-8");
this.tui.stop();
const [editor, ...editorArgs] = editorCmd.split(" ");
const result = spawnSync(editor, [...editorArgs, tmpFile], {
stdio: "inherit",
});
if (result.status === 0) {
const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
this.editor.setText(newContent);
}
} finally {
try {
fs.unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
this.tui.start();
this.tui.requestRender();
}
}
}

View file

@ -2,7 +2,7 @@
* Simple text input component for hooks.
*/
import { Container, Input, isEnter, isEscape, Spacer, Text } from "@mariozechner/pi-tui";
import { Container, Input, isCtrlC, isEnter, isEscape, Spacer, Text } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
@ -52,8 +52,8 @@ export class HookInputComponent extends Container {
return;
}
// Escape to cancel
if (isEscape(keyData)) {
// Escape or Ctrl+C to cancel
if (isEscape(keyData) || isCtrlC(keyData)) {
this.onCancelCallback();
return;
}

View file

@ -3,7 +3,7 @@
* Displays a list of string options with keyboard navigation.
*/
import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, Text } from "@mariozechner/pi-tui";
import { Container, isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape, Spacer, Text } from "@mariozechner/pi-tui";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
@ -83,8 +83,8 @@ export class HookSelectorComponent extends Container {
this.onSelectCallback(selected);
}
}
// Escape
else if (isEscape(keyData)) {
// Escape or Ctrl+C
else if (isEscape(keyData) || isCtrlC(keyData)) {
this.onCancelCallback();
}
}

View file

@ -4,6 +4,7 @@ import {
Input,
isArrowDown,
isArrowUp,
isCtrlC,
isEnter,
isEscape,
Spacer,
@ -152,6 +153,7 @@ export class ModelSelectorComponent extends Container {
this.allModels = models;
this.filteredModels = models;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, models.length - 1));
}
private filterModels(query: string): void {
@ -216,11 +218,13 @@ export class ModelSelectorComponent extends Container {
handleInput(keyData: string): void {
// Up arrow - wrap to bottom when at top
if (isArrowUp(keyData)) {
if (this.filteredModels.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;
this.updateList();
}
// Down arrow - wrap to top when at bottom
else if (isArrowDown(keyData)) {
if (this.filteredModels.length === 0) return;
this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;
this.updateList();
}
@ -231,8 +235,8 @@ export class ModelSelectorComponent extends Container {
this.handleSelect(selectedModel.model);
}
}
// Escape
else if (isEscape(keyData)) {
// Escape or Ctrl+C
else if (isEscape(keyData) || isCtrlC(keyData)) {
this.onCancelCallback();
}
// Pass everything else to search input

View file

@ -1,5 +1,14 @@
import { getOAuthProviders, type OAuthProviderInfo } from "@mariozechner/pi-ai";
import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, TruncatedText } from "@mariozechner/pi-tui";
import {
Container,
isArrowDown,
isArrowUp,
isCtrlC,
isEnter,
isEscape,
Spacer,
TruncatedText,
} from "@mariozechner/pi-tui";
import type { AuthStorage } from "../../../core/auth-storage.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
@ -112,8 +121,8 @@ export class OAuthSelectorComponent extends Container {
this.onSelectCallback(selectedProvider.id);
}
}
// Escape
else if (isEscape(keyData)) {
// Escape or Ctrl+C
else if (isEscape(keyData) || isCtrlC(keyData)) {
this.onCancelCallback();
}
}

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