feat(natives): backported fixes from pi-mono (82d7da878..9ce00079)

This commit is contained in:
can1357
2026-02-06 01:55:42 +01:00
parent cdb8653f90
commit bfe175fc01
77 changed files with 2503 additions and 369 deletions
+68 -2
View File
@@ -227,7 +227,64 @@ pub fn matches_kitty_sequence(
return false;
}
parsed.codepoint == expected_codepoint || parsed.base_layout_key == Some(expected_codepoint)
if parsed.codepoint == expected_codepoint {
return true;
}
// Only fall back to base layout key when the codepoint is NOT already a
// recognized Latin letter (a-z) or symbol. This prevents remapped layouts
// (Dvorak, Colemak) from causing false matches.
if let Some(base) = parsed.base_layout_key
&& base == expected_codepoint
{
let cp = parsed.codepoint;
let is_latin_letter = (97..=122).contains(&cp); // a-z
let is_known_symbol = is_symbol_key(cp);
if !is_latin_letter && !is_known_symbol {
return true;
}
}
false
}
/// Check if a codepoint corresponds to a known symbol key.
#[inline]
const fn is_symbol_key(cp: i32) -> bool {
matches!(
cp,
96 | // `
45 | // -
61 | // =
91 | // [
93 | // ]
92 | // \
59 | // ;
39 | // '
44 | // ,
46 | // .
47 | // /
33 | // !
64 | // @
35 | // #
36 | // $
37 | // %
94 | // ^
38 | // &
42 | // *
40 | // (
41 | // )
95 | // _
43 | // +
124 | // |
126 | // ~
123 | // {
125 | // }
58 | // :
60 | // <
62 | // >
63 // ?
)
}
/// Parse terminal input and return a normalized key identifier.
@@ -1104,7 +1161,16 @@ fn parse_functional(bytes: &[u8]) -> Option<ParsedKittySequence> {
fn format_kitty_key(parsed: &ParsedKittySequence) -> Option<Cow<'static, str>> {
let effective_mod = parsed.modifier & !LOCK_MASK;
let effective_codepoint = parsed.base_layout_key.unwrap_or(parsed.codepoint);
let effective_codepoint = {
let cp = parsed.codepoint;
let is_latin_letter = (97..=122).contains(&cp); // a-z
let is_known_symbol = is_symbol_key(cp);
if is_latin_letter || is_known_symbol {
cp
} else {
parsed.base_layout_key.unwrap_or(cp)
}
};
// No modifiers - return static string
if effective_mod == 0 {
+2 -2
View File
@@ -5,8 +5,8 @@ Use it for any merge: single file, feature branch, or full release sync.
## Last Sync Point
**Commit:** `82d7da878`
**Date:** 2026-01-30
**Commit:** `9ce00079`
**Date:** 2026-02-15
Update this section after each sync; do not reuse the previous range.
+7
View File
@@ -1,6 +1,13 @@
# Changelog
## [Unreleased]
### Added
- Added `maxRetryDelayMs` option to AgentOptions to cap server-requested retry delays, allowing higher-level retry logic to handle long waits with user visibility
### Changed
- Updated ThinkingLevel documentation to include support for gpt-5.3 and gpt-5.3-codex models with 'xhigh' thinking level
## [11.2.0] - 2026-02-05
### Fixed
+26
View File
@@ -96,6 +96,14 @@ export interface AgentOptions {
*/
thinkingBudgets?: ThinkingBudgets;
/**
* Maximum delay in milliseconds to wait for a retry when the server requests a long wait.
* If the server's requested delay exceeds this value, the request fails immediately,
* allowing higher-level retry logic to handle it with user visibility.
* Default: 60000 (60 seconds). Set to 0 to disable the cap.
*/
maxRetryDelayMs?: number;
/**
* Provides tool execution context, resolved per tool call.
* Use for late-bound UI or session state access.
@@ -148,6 +156,7 @@ export class Agent {
public streamFn: StreamFn;
private _sessionId?: string;
private _thinkingBudgets?: ThinkingBudgets;
private _maxRetryDelayMs?: number;
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
private getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined;
private cursorExecHandlers?: CursorExecHandlers;
@@ -169,6 +178,7 @@ export class Agent {
this.streamFn = opts.streamFn || streamSimple;
this._sessionId = opts.sessionId;
this._thinkingBudgets = opts.thinkingBudgets;
this._maxRetryDelayMs = opts.maxRetryDelayMs;
this.getApiKey = opts.getApiKey;
this.getToolContext = opts.getToolContext;
this.cursorExecHandlers = opts.cursorExecHandlers;
@@ -205,6 +215,21 @@ export class Agent {
this._thinkingBudgets = value;
}
/**
* Get the current max retry delay in milliseconds.
*/
get maxRetryDelayMs(): number | undefined {
return this._maxRetryDelayMs;
}
/**
* Set the maximum delay to wait for server-requested retries.
* Set to 0 to disable the cap.
*/
set maxRetryDelayMs(value: number | undefined) {
this._maxRetryDelayMs = value;
}
get state(): AgentState {
return this._state;
}
@@ -493,6 +518,7 @@ export class Agent {
interruptMode: this.interruptMode,
sessionId: this._sessionId,
thinkingBudgets: this._thinkingBudgets,
maxRetryDelayMs: this._maxRetryDelayMs,
kimiApiFormat: this.kimiApiFormat,
toolChoice: options?.toolChoice,
convertToLlm: this.convertToLlm,
+1 -1
View File
@@ -121,7 +121,7 @@ export interface ToolCallContext {
/**
* Thinking/reasoning level for models that support it.
* Note: "xhigh" is only supported by OpenAI gpt-5.1-codex-max, gpt-5.2, and gpt-5.2-codex models.
* Note: "xhigh" is only supported by OpenAI gpt-5.1-codex-max, gpt-5.2, gpt-5.2-codex, gpt-5.3, and gpt-5.3-codex models.
*/
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
+15
View File
@@ -1,13 +1,28 @@
# Changelog
## [Unreleased]
### Added
- Added `cacheRetention` option to control prompt cache retention preference ('none', 'short', 'long') across providers
- Added `maxRetryDelayMs` option to cap server-requested retry delays and fail fast when delays exceed the limit
- Added `effort` option for Anthropic Opus 4.6+ models to control adaptive thinking effort levels ('low', 'medium', 'high', 'max')
- Added support for Anthropic Opus 4.6+ adaptive thinking mode that lets Claude decide when and how much to think
- Added `PI_AI_ANTIGRAVITY_VERSION` environment variable to customize Antigravity sandbox endpoint version
- Exported `convertAnthropicMessages` function for converting message formats to Anthropic API
- Automatic fallback for Anthropic assistant-prefill requests: appends synthetic user "Continue." message when conversation ends with assistant turn to maintain API compatibility
### Changed
- Changed Anthropic thinking mode to use adaptive thinking for Opus 4.6+ models instead of budget-based thinking
- Changed `supportsXhigh()` to support GPT-5.2/5.3 and Anthropic Opus 4.6+ models with adaptive thinking
- Changed prompt caching to respect `cacheRetention` option and support TTL configuration for Anthropic
- Changed OpenAI tool definitions to conditionally include `strict` field only when provider supports it
- Changed Qwen model support to use `enable_thinking` boolean parameter instead of OpenAI-style reasoning_effort
### Fixed
- Fixed indentation and formatting in `convertAnthropicMessages` function
- Fixed handling of conversations ending with assistant messages on Anthropic-routed models that reject assistant prefill requests
## [11.2.3] - 2026-02-05
+13 -5
View File
@@ -47,15 +47,23 @@ export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage
return usage.cost;
}
/** Models that support xhigh thinking level */
const XHIGH_MODELS = new Set(["gpt-5.1-codex-max", "gpt-5.2", "gpt-5.2-codex"]);
/**
* Check if a model supports xhigh thinking level.
* Currently only certain OpenAI Codex models support this.
*
* Supported today:
* - GPT-5.2 / GPT-5.3 model families
* - Anthropic Messages API Opus 4.6 models (xhigh maps to adaptive effort "max")
*/
export function supportsXhigh<TApi extends Api>(model: Model<TApi>): boolean {
return XHIGH_MODELS.has(model.id) || model.api === "anthropic-messages";
if (model.id.includes("gpt-5.2") || model.id.includes("gpt-5.3")) {
return true;
}
if (model.api === "anthropic-messages") {
return model.id.includes("opus-4-6") || model.id.includes("opus-4.6");
}
return false;
}
/**
+113 -31
View File
@@ -9,6 +9,7 @@ import { getEnvApiKey, OUTPUT_FALLBACK_BUFFER } from "../stream";
import type {
Api,
AssistantMessage,
CacheRetention,
Context,
ImageContent,
Message,
@@ -28,6 +29,35 @@ import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
import { sanitizeSurrogates } from "../utils/sanitize-unicode";
import { transformMessages } from "./transform-messages";
/**
* Resolve cache retention preference.
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
*/
function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
if (cacheRetention) {
return cacheRetention;
}
if (typeof process !== "undefined" && process.env.PI_CACHE_RETENTION === "long") {
return "long";
}
return "short";
}
function getCacheControl(
baseUrl: string,
cacheRetention?: CacheRetention,
): { retention: CacheRetention; cacheControl?: { type: "ephemeral"; ttl?: "1h" } } {
const retention = resolveCacheRetention(cacheRetention);
if (retention === "none") {
return { retention };
}
const ttl = retention === "long" && baseUrl.includes("api.anthropic.com") ? "1h" : undefined;
return {
retention,
cacheControl: { type: "ephemeral", ...(ttl && { ttl }) },
};
}
// Stealth mode: Mimic Claude Code headers and tool prefixing.
export const claudeCodeVersion = "1.0.83";
export const claudeToolPrefix = "proxy_";
@@ -123,9 +153,30 @@ function convertContentBlocks(content: (TextContent | ImageContent)[]):
return blocks;
}
export type AnthropicEffort = "low" | "medium" | "high" | "max";
export interface AnthropicOptions extends StreamOptions {
/**
* Enable extended thinking.
* For Opus 4.6+: uses adaptive thinking (Claude decides when/how much to think).
* For older models: uses budget-based thinking with thinkingBudgetTokens.
*/
thinkingEnabled?: boolean;
/**
* Token budget for extended thinking (older models only).
* Ignored for Opus 4.6+ which uses adaptive thinking.
*/
thinkingBudgetTokens?: number;
/**
* Effort level for adaptive thinking (Opus 4.6+ only).
* Controls how much thinking Claude allocates:
* - "max": Always thinks with no constraints
* - "high": Always thinks, deep reasoning (default)
* - "medium": Moderate thinking, may skip for simple queries
* - "low": Minimal thinking, skips for simple tasks
* Ignored for older models.
*/
effort?: AnthropicEffort;
interleavedThinking?: boolean;
toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string };
betas?: string[] | string;
@@ -487,11 +538,11 @@ function createClient(
export type AnthropicSystemBlock = {
type: "text";
text: string;
cache_control?: { type: "ephemeral" };
cache_control?: { type: "ephemeral"; ttl?: "1h" };
};
type CacheControlBlock = {
cache_control?: { type: "ephemeral" } | null;
cache_control?: { type: "ephemeral"; ttl?: "1h" | "5m" } | null;
};
const cacheControlEphemeral = { type: "ephemeral" as const };
@@ -544,6 +595,14 @@ function disableThinkingIfToolChoiceForced(params: MessageCreateParamsStreaming)
}
}
/**
* Check if a model supports adaptive thinking (Opus 4.6+)
*/
function supportsAdaptiveThinking(modelId: string): boolean {
// Opus 4.6 model IDs (with or without date suffix)
return modelId.includes("opus-4-6") || modelId.includes("opus-4.6");
}
function ensureMaxTokensForThinking(params: MessageCreateParamsStreaming, model: Model<"anthropic-messages">): void {
const thinking = params.thinking;
if (!thinking || thinking.type !== "enabled") return;
@@ -566,7 +625,7 @@ function buildParams(
): MessageCreateParamsStreaming {
const params: MessageCreateParamsStreaming = {
model: model.id,
messages: convertAnthropicMessages(context.messages, model, isOAuthToken),
messages: convertAnthropicMessages(context.messages, model, isOAuthToken),
max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0,
stream: true,
};
@@ -587,11 +646,21 @@ function buildParams(
params.tools = convertTools(context.tools, isOAuthToken);
}
// Configure thinking mode: adaptive (Opus 4.6+) or budget-based (older models)
if (options?.thinkingEnabled && model.reasoning) {
params.thinking = {
type: "enabled",
budget_tokens: options.thinkingBudgetTokens || 1024,
};
if (supportsAdaptiveThinking(model.id)) {
// Adaptive thinking: Claude decides when and how much to think
(params as any).thinking = { type: "adaptive" };
if (options.effort) {
(params as any).output_config = { effort: options.effort };
}
} else {
// Budget-based thinking for older models
params.thinking = {
type: "enabled",
budget_tokens: options.thinkingBudgetTokens || 1024,
};
}
}
if (options?.toolChoice) {
@@ -611,7 +680,10 @@ function buildParams(
ensureMaxTokensForThinking(params, model);
}
applyPromptCaching(params);
const { retention, cacheControl } = getCacheControl(model.baseUrl, options?.cacheRetention);
if (retention !== "none") {
applyPromptCaching(params, cacheControl);
}
return params;
}
@@ -630,24 +702,33 @@ function stripCacheControl<T extends CacheControlBlock>(blocks: T[]): void {
}
}
function applyCacheControlToLastBlock<T extends CacheControlBlock>(blocks: T[]): void {
function applyCacheControlToLastBlock<T extends CacheControlBlock>(
blocks: T[],
cc: CacheControlBlock["cache_control"] = cacheControlEphemeral,
): void {
if (blocks.length === 0) return;
const lastIndex = blocks.length - 1;
blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cacheControlEphemeral };
blocks[lastIndex] = { ...blocks[lastIndex], cache_control: cc };
}
function applyCacheControlToLastTextBlock(blocks: Array<ContentBlockParam & CacheControlBlock>): void {
function applyCacheControlToLastTextBlock(
blocks: Array<ContentBlockParam & CacheControlBlock>,
cc: CacheControlBlock["cache_control"] = cacheControlEphemeral,
): void {
if (blocks.length === 0) return;
for (let i = blocks.length - 1; i >= 0; i--) {
if (blocks[i].type === "text") {
blocks[i] = { ...blocks[i], cache_control: cacheControlEphemeral };
blocks[i] = { ...blocks[i], cache_control: cc };
return;
}
}
applyCacheControlToLastBlock(blocks);
applyCacheControlToLastBlock(blocks, cc);
}
function applyPromptCaching(params: MessageCreateParamsStreaming): void {
function applyPromptCaching(
params: MessageCreateParamsStreaming,
cc: CacheControlBlock["cache_control"] = cacheControlEphemeral,
): void {
// Anthropic allows max 4 cache breakpoints
const MAX_CACHE_BREAKPOINTS = 4;
@@ -675,7 +756,7 @@ function applyPromptCaching(params: MessageCreateParamsStreaming): void {
// 1. Cache tools - place breakpoint on last tool definition
if (params.tools && params.tools.length > 0) {
applyCacheControlToLastBlock(params.tools as Array<CacheControlBlock>);
applyCacheControlToLastBlock(params.tools as Array<CacheControlBlock>, cc);
cacheBreakpointsUsed++;
}
@@ -683,7 +764,7 @@ function applyPromptCaching(params: MessageCreateParamsStreaming): void {
// 2. Cache system prompt
if (params.system && Array.isArray(params.system) && params.system.length > 0) {
applyCacheControlToLastBlock(params.system);
applyCacheControlToLastBlock(params.system, cc);
cacheBreakpointsUsed++;
}
@@ -699,12 +780,13 @@ function applyPromptCaching(params: MessageCreateParamsStreaming): void {
const penultimateUser = params.messages[penultimateUserIndex];
if (penultimateUser) {
if (typeof penultimateUser.content === "string") {
penultimateUser.content = [
{ type: "text", text: penultimateUser.content, cache_control: cacheControlEphemeral },
];
penultimateUser.content = [{ type: "text", text: penultimateUser.content, cache_control: cc }];
cacheBreakpointsUsed++;
} else if (Array.isArray(penultimateUser.content) && penultimateUser.content.length > 0) {
applyCacheControlToLastTextBlock(penultimateUser.content as Array<ContentBlockParam & CacheControlBlock>);
applyCacheControlToLastTextBlock(
penultimateUser.content as Array<ContentBlockParam & CacheControlBlock>,
cc,
);
cacheBreakpointsUsed++;
}
}
@@ -718,9 +800,9 @@ function applyPromptCaching(params: MessageCreateParamsStreaming): void {
const lastUser = params.messages[lastUserIndex];
if (lastUser) {
if (typeof lastUser.content === "string") {
lastUser.content = [{ type: "text", text: lastUser.content, cache_control: cacheControlEphemeral }];
lastUser.content = [{ type: "text", text: lastUser.content, cache_control: cc }];
} else if (Array.isArray(lastUser.content) && lastUser.content.length > 0) {
applyCacheControlToLastTextBlock(lastUser.content as Array<ContentBlockParam & CacheControlBlock>);
applyCacheControlToLastTextBlock(lastUser.content as Array<ContentBlockParam & CacheControlBlock>, cc);
}
}
}
@@ -893,15 +975,15 @@ export function convertAnthropicMessages(
}
}
// If the conversation ends with an assistant message, Anthropic treats that
// as an assistant prefill. Many Anthropic-routed models reject this and
// require the conversation to end with a user message.
//
// Best-effort fallback: append a synthetic user "Continue." turn so the model
// can continue generation from the prior assistant content.
if (params.length > 0 && params[params.length - 1]?.role === "assistant") {
params.push({ role: "user", content: "Continue." });
}
// If the conversation ends with an assistant message, Anthropic treats that
// as an assistant prefill. Many Anthropic-routed models reject this and
// require the conversation to end with a user message.
//
// Best-effort fallback: append a synthetic user "Continue." turn so the model
// can continue generation from the prior assistant content.
if (params.length > 0 && params[params.length - 1]?.role === "assistant") {
params.push({ role: "user", content: "Continue." });
}
// Final validation: filter out any messages with invalid content
return params.filter(msg => {
+25 -10
View File
@@ -69,15 +69,20 @@ const GEMINI_CLI_HEADERS = {
};
// Headers for Antigravity (sandbox endpoint) - requires specific User-Agent
const ANTIGRAVITY_HEADERS = {
"User-Agent": "antigravity/1.15.8 darwin/arm64",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
const DEFAULT_ANTIGRAVITY_VERSION = "1.15.8";
function getAntigravityHeaders() {
const version = process.env.PI_AI_ANTIGRAVITY_VERSION || DEFAULT_ANTIGRAVITY_VERSION;
return {
"User-Agent": `antigravity/${version} darwin/arm64`,
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
}
// Antigravity system instruction (ported from CLIProxyAPI v6.6.89).
const ANTIGRAVITY_SYSTEM_INSTRUCTION = `<identity>
@@ -413,7 +418,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
const requestBody = buildRequest(model, context, projectId, options, isAntigravity);
options?.onPayload?.(requestBody);
const headers = isAntigravity ? ANTIGRAVITY_HEADERS : GEMINI_CLI_HEADERS;
const headers = isAntigravity ? getAntigravityHeaders() : GEMINI_CLI_HEADERS;
const requestHeaders = {
Authorization: `Bearer ${accessToken}`,
@@ -456,6 +461,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
// Use server-provided delay or exponential backoff
const serverDelay = extractRetryDelay(errorText, response);
const delayMs = serverDelay ?? BASE_DELAY_MS * 2 ** attempt;
// Check if server delay exceeds max allowed (default: 60s)
const maxDelayMs = options?.maxRetryDelayMs ?? 60000;
if (maxDelayMs > 0 && serverDelay && serverDelay > maxDelayMs) {
const delaySeconds = Math.ceil(serverDelay / 1000);
throw new Error(
`Server requested ${delaySeconds}s retry delay (max: ${Math.ceil(maxDelayMs / 1000)}s). ${extractErrorMessage(errorText)}`,
);
}
await abortableSleep(delayMs, options?.signal);
continue;
}
@@ -432,7 +432,7 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
}
if (context.tools) {
params.tools = convertTools(context.tools);
params.tools = convertTools(context.tools, compat);
} else if (hasToolHistory(context.messages)) {
// Anthropic (via LiteLLM/proxy) requires tools param when conversation has tool_calls/tool_results
params.tools = [];
@@ -446,6 +446,9 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
// Z.ai uses binary thinking: { type: "enabled" | "disabled" }
// Must explicitly disable since z.ai defaults to thinking enabled
(params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" };
} else if (compat.thinkingFormat === "qwen" && model.reasoning) {
// Qwen uses enable_thinking: boolean
(params as any).enable_thinking = !!options?.reasoningEffort;
} else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) {
// OpenAI-style reasoning_effort
params.reasoning_effort = options.reasoningEffort;
@@ -757,14 +760,15 @@ export function convertMessages(
return params;
}
function convertTools(tools: Tool[]): OpenAI.Chat.Completions.ChatCompletionTool[] {
function convertTools(tools: Tool[], compat: ResolvedOpenAICompat): OpenAI.Chat.Completions.ChatCompletionTool[] {
return tools.map(tool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters as any, // TypeBox already generates JSON Schema
strict: false, // Disable strict mode to allow optional parameters without null unions
// Only include strict if provider supports it. Some reject unknown fields.
...(compat.supportsStrictMode !== false && { strict: false }),
},
}));
}
@@ -836,6 +840,7 @@ function detectCompat(model: Model<"openai-completions">): ResolvedOpenAICompat
requiresAssistantContentForToolCalls: isOpenRouterKimi,
openRouterRouting: undefined,
vercelGatewayRouting: undefined,
supportsStrictMode: true,
};
}
@@ -867,5 +872,6 @@ function getCompat(model: Model<"openai-completions">): ResolvedOpenAICompat {
model.compat.requiresAssistantContentForToolCalls ?? detected.requiresAssistantContentForToolCalls,
openRouterRouting: model.compat.openRouterRouting ?? detected.openRouterRouting,
vercelGatewayRouting: model.compat.vercelGatewayRouting ?? detected.vercelGatewayRouting,
supportsStrictMode: model.compat.supportsStrictMode ?? detected.supportsStrictMode,
};
}
+25 -7
View File
@@ -16,6 +16,7 @@ import { getEnvApiKey } from "../stream";
import type {
Api,
AssistantMessage,
CacheRetention,
Context,
Model,
StopReason,
@@ -35,12 +36,28 @@ import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
import { transformMessages } from "./transform-messages";
/**
* Get prompt cache retention based on PI_CACHE_RETENTION env var.
* Only applies to direct OpenAI API calls (api.openai.com).
* Returns '24h' for long retention, undefined for default (in-memory).
* Resolve cache retention preference.
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
*/
function getPromptCacheRetention(baseUrl: string): "24h" | undefined {
if ($env.PI_CACHE_RETENTION === "long" && baseUrl.includes("api.openai.com")) {
function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
if (cacheRetention) {
return cacheRetention;
}
if ($env.PI_CACHE_RETENTION === "long") {
return "long";
}
return "short";
}
/**
* Get prompt cache retention based on cacheRetention and base URL.
* Only applies to direct OpenAI API calls (api.openai.com).
*/
function getPromptCacheRetention(baseUrl: string, cacheRetention: CacheRetention): "24h" | undefined {
if (cacheRetention !== "long") {
return undefined;
}
if (baseUrl.includes("api.openai.com")) {
return "24h";
}
return undefined;
@@ -403,12 +420,13 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
const strictResponsesPairing = options?.strictResponsesPairing ?? isAzureOpenAIBaseUrl(model.baseUrl ?? "");
const messages = convertMessages(model, context, strictResponsesPairing);
const cacheRetention = resolveCacheRetention(options?.cacheRetention);
const params: ResponseCreateParamsStreaming = {
model: model.id,
input: messages,
stream: true,
prompt_cache_key: options?.sessionId,
prompt_cache_retention: getPromptCacheRetention(model.baseUrl),
prompt_cache_key: cacheRetention === "none" ? undefined : options?.sessionId,
prompt_cache_retention: getPromptCacheRetention(model.baseUrl, cacheRetention),
};
if (options?.maxTokens) {
+34
View File
@@ -280,6 +280,26 @@ function mapAnthropicToolChoice(choice?: ToolChoice): AnthropicOptions["toolChoi
return undefined;
}
/**
* Map ThinkingLevel to Anthropic effort levels for adaptive thinking (Opus 4.6+)
*/
function mapThinkingLevelToAnthropicEffort(level: ThinkingLevel): AnthropicOptions["effort"] {
switch (level) {
case "minimal":
return "low";
case "low":
return "low";
case "medium":
return "medium";
case "high":
return "high";
case "xhigh":
return "max";
default:
return "high";
}
}
function mapGoogleToolChoice(
choice?: ToolChoice,
): GoogleOptions["toolChoice"] | GoogleGeminiCliOptions["toolChoice"] | GoogleVertexOptions["toolChoice"] {
@@ -319,7 +339,9 @@ function mapOptionsForApi<TApi extends Api>(
maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32000),
signal: options?.signal,
apiKey: apiKey || options?.apiKey,
cacheRetention: options?.cacheRetention,
headers: options?.headers,
maxRetryDelayMs: options?.maxRetryDelayMs,
sessionId: options?.sessionId,
onPayload: options?.onPayload,
execHandlers: options?.execHandlers,
@@ -349,6 +371,18 @@ function mapOptionsForApi<TApi extends Api>(
} as OptionsForApi<TApi>;
}
// For Opus 4.6+: use adaptive thinking with effort level
// For older models: use budget-based thinking
if (model.id.includes("opus-4-6") || model.id.includes("opus-4.6")) {
const effort = mapThinkingLevelToAnthropicEffort(reasoning);
return {
...base,
thinkingEnabled: true,
effort,
toolChoice: mapAnthropicToolChoice(options?.toolChoice),
} as OptionsForApi<TApi>;
}
if (ANTHROPIC_USE_INTERLEAVED_THINKING) {
return {
...base,
+19 -1
View File
@@ -105,16 +105,31 @@ export type ToolChoice =
| { type: "tool"; name: string };
// Base options all providers share
export type CacheRetention = "none" | "short" | "long";
export interface StreamOptions {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
apiKey?: string;
/**
* Prompt cache retention preference. Providers map this to their supported values.
* Default: "short".
*/
cacheRetention?: CacheRetention;
/**
* Additional headers to include in provider requests.
* These are merged on top of model-defined headers.
*/
headers?: Record<string, string>;
/**
* Maximum delay in milliseconds to wait for a retry when the server requests a long wait.
* If the server's requested delay exceeds this value, the request fails immediately
* with an error containing the requested delay, allowing higher-level retry logic
* to handle it with user visibility.
* Default: 60000 (60 seconds). Set to 0 to disable the cap.
*/
maxRetryDelayMs?: number;
/**
* Optional session identifier for providers that support session-based caching.
* Providers can use this to enable prompt caching, request routing, or other
@@ -309,7 +324,8 @@ export interface OpenAICompat {
/** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */
requiresMistralToolIds?: boolean;
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }. Default: "openai". */
thinkingFormat?: "openai" | "zai";
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }, "qwen" uses enable_thinking: boolean. Default: "openai". */
thinkingFormat?: "openai" | "zai" | "qwen";
/** Which reasoning content field to emit on assistant messages. Default: auto-detected. */
reasoningContentField?: "reasoning_content" | "reasoning" | "reasoning_text";
/** Whether assistant tool-call messages must include reasoning content. Default: false. */
@@ -322,6 +338,8 @@ export interface OpenAICompat {
openRouterRouting?: OpenRouterRouting;
/** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */
vercelGatewayRouting?: VercelGatewayRouting;
/** Whether the provider supports the `strict` field in tool definitions. Default: true. */
supportsStrictMode?: boolean;
}
/**
+1
View File
@@ -1,6 +1,7 @@
// ============================================================================
// High-level API
// ============================================================================
import { refreshAnthropicToken } from "./anthropic";
import { refreshCursorToken } from "./cursor";
import { refreshGitHubCopilotToken } from "./github-copilot";
@@ -29,6 +29,7 @@ const compat: Required<OpenAICompat> = {
requiresAssistantContentForToolCalls: false,
openRouterRouting: {},
vercelGatewayRouting: {},
supportsStrictMode: true,
};
function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessage {
+45
View File
@@ -1,8 +1,28 @@
# Changelog
## [Unreleased]
### Added
- Support for `PI_PACKAGE_DIR` environment variable to override package directory (useful for Nix/Guix store paths)
- New keybindings for session management: `toggleSessionNamedFilter` (Ctrl+N), `newSession`, `tree`, `fork`, and `resume` actions
- Support for shell command execution in configuration values (API keys, headers) using `!` prefix, with result caching
- New `clearOnShrink` display setting to control whether empty rows are cleared when content shrinks
- New `SlashCommandInfo`, `SlashCommandLocation`, and `SlashCommandSource` types for extension slash command discovery
- New `getCommands()` method in ExtensionAPI to retrieve available slash commands
- New `switchSession()` action in ExtensionCommandContext to switch between sessions
- New `SwitchSessionHandler` type for extension session switching handlers
- New `getSystemPrompt()` method in ExtensionUIContext to access current system prompt
- New `getToolsExpanded()` and `setToolsExpanded()` methods in ExtensionUIContext for tool output expansion control
- New `WriteToolCallEvent` type for write tool call events
- New `isToolCallEventType()` type guard for tool call events
- Support for image content in RPC `steer` and `followUp` commands
- New `GitSource` type and `parseGitUrl()` function for parsing git URLs in plugin system
- Tool input types exported: `BashToolInput`, `FindToolInput`, `GrepToolInput`, `ReadToolInput`, `WriteToolInput`
- Support for `@` prefix normalization in file paths (strips leading `@` character)
- New `parentSessionPath` field in SessionInfo to track forked session origins
- Skill file relative path resolution against skill directory in system prompt
- Support for Termux/Android package installation guidance for missing tools
- Support for puppeteer query handlers (aria/, text/, xpath/, pierce/) in selector parameters across all browser actions
- Automatic normalization of legacy p- prefixed selectors (p-aria/, p-text/, p-xpath/, p-pierce/) to modern puppeteer query handler syntax
- Improved click action with intelligent element selection that prioritizes visible, actionable candidates and retries until timeout
@@ -10,15 +30,40 @@
### Changed
- Updated default model IDs across providers: Claude Sonnet 4.5 → Claude Opus 4.6, Gemini 2.5 Pro → Gemini 3 Pro variants, and others
- Made model definition fields optional with sensible defaults for local models (Ollama, LM Studio, etc.)
- Modified custom tool execute signature to reorder parameters: `(toolCallId, params, signal, onUpdate, ctx)` instead of `(toolCallId, params, onUpdate, ctx, signal)`
- Changed `--version` and `--list-models` flags to exit with `process.exit(0)` instead of returning
- Improved `--export` flag to exit with `process.exit(0)` on success
- Enhanced tree selector to preserve last selected ID across filter changes
- Modified tree navigation to use real leaf ID instead of skipping metadata entries
- Improved footer path truncation logic to prevent invalid truncation at boundary
- Enhanced model selector to display selected model name when no matches found
- Improved RPC client `steer()` and `followUp()` methods to accept optional image content
- Updated extension loader to check for explicit extension entries in root directory before discovering subdirectories
- Removed line limiting in custom message component when collapsed
- Improved API key resolution to support shell command execution via `resolveConfigValue()`
- Enhanced session branching to preserve parent session path reference
- Updated selector parameter descriptions to document support for CSS selectors and puppeteer query handlers
- Modified viewport handling in headless mode to respect custom viewport parameters while disabling viewport in headed mode for better window management
- Improved click action to use specialized text query handler logic with retry mechanism for better reliability with dynamic content
### Fixed
- Fixed HTML export template to safely handle invalid argument types in tool rendering
- Fixed path shortening in HTML export to handle non-string paths
- Fixed custom message rendering to properly display full content without artificial line limits
- Fixed tree navigation to only restore editor text when editor is empty
- Fixed session creation to properly track parent session when forking
- Fixed thinking level initialization to only append change entry for new sessions without existing thinking entries
- Fixed tool expansion state management to properly propagate through UI context
- Fixed click action to properly handle text/ query handlers with timeout and retry logic instead of failing immediately
- Fixed viewport application to only apply when in headless mode or when explicitly requested, preventing conflicts in headed browser mode
### Security
- Added support for shell command execution in configuration values with caching to enable secure credential resolution patterns
## [11.2.1] - 2026-02-05
### Fixed
+1 -1
View File
@@ -87,9 +87,9 @@
"test": "bun test"
},
"dependencies": {
"@mozilla/readability": "0.6.0",
"@oclif/core": "^4.8.0",
"@oclif/plugin-autocomplete": "^3.2.40",
"@mozilla/readability": "0.6.0",
"@oh-my-pi/omp-stats": "workspace:*",
"@oh-my-pi/pi-agent-core": "workspace:*",
"@oh-my-pi/pi-ai": "workspace:*",
+1
View File
@@ -214,6 +214,7 @@ export function getExtraHelpText(): string {
${chalk.dim("# Configuration")}
PI_CODING_AGENT_DIR - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent)
PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths)
PI_SMOL_MODEL - Override smol/fast model (see --smol)
PI_SLOW_MODEL - Override slow/reasoning model (see --slow)
PI_PLAN_MODEL - Override planning model (see --plan)
+8
View File
@@ -35,6 +35,14 @@ const priorityList = [
* Walk up from import.meta.dir until we find package.json, or fall back to cwd.
*/
export function getPackageDir(): string {
// Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly)
const envDir = process.env.PI_PACKAGE_DIR;
if (envDir) {
if (envDir === "~") return os.homedir();
if (envDir.startsWith("~/")) return os.homedir() + envDir.slice(1);
return envDir;
}
let dir = import.meta.dir;
while (dir !== path.dirname(dir)) {
if (fs.existsSync(path.join(dir, "package.json"))) {
@@ -26,11 +26,16 @@ export type AppAction =
| "togglePlanMode"
| "expandTools"
| "toggleThinking"
| "toggleSessionNamedFilter"
| "externalEditor"
| "historySearch"
| "followUp"
| "dequeue"
| "pasteImage";
| "pasteImage"
| "newSession"
| "tree"
| "fork"
| "resume";
/**
* All configurable actions.
@@ -60,10 +65,15 @@ export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
historySearch: "ctrl+r",
expandTools: "ctrl+o",
toggleThinking: "ctrl+t",
toggleSessionNamedFilter: "ctrl+n",
externalEditor: "ctrl+g",
followUp: "ctrl+enter",
dequeue: "alt+up",
pasteImage: "ctrl+v",
newSession: [],
tree: [],
fork: [],
resume: [],
};
/**
@@ -88,10 +98,15 @@ const APP_ACTIONS: AppAction[] = [
"historySearch",
"expandTools",
"toggleThinking",
"toggleSessionNamedFilter",
"externalEditor",
"followUp",
"dequeue",
"pasteImage",
"newSession",
"tree",
"fork",
"resume",
];
function isAppAction(action: string): action is AppAction {
@@ -54,9 +54,10 @@ const OpenAICompatSchema = Type.Object({
});
// Schema for custom model definition
// Most fields are optional with sensible defaults for local models (Ollama, LM Studio, etc.)
const ModelDefinitionSchema = Type.Object({
id: Type.String({ minLength: 1 }),
name: Type.String({ minLength: 1 }),
name: Type.Optional(Type.String({ minLength: 1 })),
api: Type.Optional(
Type.Union([
Type.Literal("openai-completions"),
@@ -68,16 +69,18 @@ const ModelDefinitionSchema = Type.Object({
Type.Literal("google-vertex"),
]),
),
reasoning: Type.Boolean(),
input: Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")])),
cost: Type.Object({
input: Type.Number(),
output: Type.Number(),
cacheRead: Type.Number(),
cacheWrite: Type.Number(),
}),
contextWindow: Type.Number(),
maxTokens: Type.Number(),
reasoning: Type.Optional(Type.Boolean()),
input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))),
cost: Type.Optional(
Type.Object({
input: Type.Number(),
output: Type.Number(),
cacheRead: Type.Number(),
cacheWrite: Type.Number(),
}),
),
contextWindow: Type.Optional(Type.Number()),
maxTokens: Type.Optional(Type.Number()),
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
compat: Type.Optional(OpenAICompatSchema),
});
@@ -141,10 +144,10 @@ export const ModelsConfigFile = new ConfigFile<ModelsConfig>("models", ModelsCon
}
if (!modelDef.id) throw new Error(`Provider ${providerName}: model missing "id"`);
if (!modelDef.name) throw new Error(`Provider ${providerName}: model missing "name"`);
if (modelDef.contextWindow <= 0)
// Validate contextWindow/maxTokens only if provided (they have defaults)
if (modelDef.contextWindow !== undefined && modelDef.contextWindow <= 0)
throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid contextWindow`);
if (modelDef.maxTokens <= 0)
if (modelDef.maxTokens !== undefined && modelDef.maxTokens <= 0)
throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid maxTokens`);
}
}
@@ -377,17 +380,19 @@ export class ModelRegistry {
}
// baseUrl is validated to exist for providers with models
// Apply defaults for optional fields
const defaultCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
models.push({
id: modelDef.id,
name: modelDef.name,
name: modelDef.name ?? modelDef.id,
api: api as Api,
provider: providerName,
baseUrl: providerConfig.baseUrl!,
reasoning: modelDef.reasoning,
input: modelDef.input as ("text" | "image")[],
cost: modelDef.cost,
contextWindow: modelDef.contextWindow,
maxTokens: modelDef.maxTokens,
reasoning: modelDef.reasoning ?? false,
input: (modelDef.input ?? ["text"]) as ("text" | "image")[],
cost: modelDef.cost ?? defaultCost,
contextWindow: modelDef.contextWindow ?? 128000,
maxTokens: modelDef.maxTokens ?? 16384,
headers,
compat: modelDef.compat,
} as Model<Api>);
@@ -11,25 +11,25 @@ import type { Settings } from "./settings";
/** Default model IDs for each known provider */
export const defaultModelPerProvider: Record<KnownProvider, string> = {
"amazon-bedrock": "us.anthropic.claude-sonnet-4-5-20250514-v1:0",
anthropic: "claude-sonnet-4-5",
"amazon-bedrock": "us.anthropic.claude-opus-4-6-v1",
anthropic: "claude-opus-4-6",
openai: "gpt-5.1-codex",
"openai-codex": "codex-max",
"openai-codex": "gpt-5.3-codex",
google: "gemini-2.5-pro",
"google-gemini-cli": "gemini-2.5-pro",
"google-antigravity": "gemini-3-pro-high",
"google-vertex": "gemini-2.5-pro",
"google-vertex": "gemini-3-pro-preview",
"github-copilot": "gpt-4o",
cursor: "claude-sonnet-4-5",
cursor: "claude-opus-4-6",
openrouter: "openai/gpt-5.1-codex",
"vercel-ai-gateway": "claude-sonnet-4-5",
"vercel-ai-gateway": "anthropic/claude-opus-4-6",
xai: "grok-4-fast-non-reasoning",
groq: "openai/gpt-oss-120b",
cerebras: "zai-glm-4.6",
zai: "glm-4.6",
mistral: "devstral-medium-latest",
minimax: "MiniMax-M2",
opencode: "claude-opus-4-5",
minimax: "MiniMax-M2.1",
opencode: "claude-opus-4-6",
"kimi-code": "kimi-k2.5",
};
@@ -0,0 +1,64 @@
/**
* Resolve configuration values that may be shell commands, environment variables, or literals.
* Used by auth-storage.ts and model-registry.ts.
*/
import * as childProcess from "node:child_process";
/** Cache for shell command results (persists for process lifetime) */
const commandResultCache = new Map<string, string | undefined>();
/**
* Resolve a config value (API key, header value, etc.) to an actual value.
* - If starts with "!", executes the rest as a shell command and uses stdout (cached)
* - Otherwise checks environment variable first, then treats as literal (not cached)
*/
export function resolveConfigValue(config: string): string | undefined {
if (config.startsWith("!")) {
return executeCommand(config);
}
const envValue = process.env[config];
return envValue || config;
}
function executeCommand(commandConfig: string): string | undefined {
if (commandResultCache.has(commandConfig)) {
return commandResultCache.get(commandConfig);
}
const command = commandConfig.slice(1);
let result: string | undefined;
try {
const output = childProcess.execSync(command, {
encoding: "utf-8",
timeout: 10000,
stdio: ["ignore", "pipe", "ignore"],
});
result = output.trim() || undefined;
} catch {
result = undefined;
}
commandResultCache.set(commandConfig, result);
return result;
}
/**
* Resolve all header values using the same resolution logic as API keys.
*/
export function resolveHeaders(headers: Record<string, string> | undefined): Record<string, string> | undefined {
if (!headers) return undefined;
const resolved: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
const resolvedValue = resolveConfigValue(value);
if (resolvedValue) {
resolved[key] = resolvedValue;
}
}
return Object.keys(resolved).length > 0 ? resolved : undefined;
}
/** Clear the config value command cache. Exported for testing. */
export function clearConfigValueCache(): void {
commandResultCache.clear();
}
@@ -242,6 +242,15 @@ export const SETTINGS_SCHEMA = {
default: true, // will be computed based on platform if undefined
ui: { tab: "display", label: "Hardware cursor", description: "Show terminal cursor for IME support" },
},
clearOnShrink: {
type: "boolean",
default: false,
ui: {
tab: "display",
label: "Clear on shrink",
description: "Clear empty rows when content shrinks (may cause flicker)",
},
},
extensions: { type: "array", default: [] as string[] },
enabledModels: { type: "array", default: [] as string[] },
disabledProviders: { type: "array", default: [] as string[] },
@@ -473,6 +473,10 @@
display: block;
}
.ansi-line {
white-space: pre-wrap;
}
.tool-images {
}
@@ -666,6 +670,9 @@
color: var(--error);
padding: 0 var(--line-height);
}
.tool-error {
color: var(--error);
}
/* Images */
.message-images {
@@ -376,6 +376,7 @@
// ============================================================
function shortenPath(p) {
if (typeof p !== 'string') return '';
if (p.startsWith('/Users/')) {
const parts = p.split('/');
if (parts.length > 2) return '~' + p.slice(('/Users/' + parts[2]).length);
@@ -603,6 +604,13 @@
return text.replace(/\t/g, ' ');
}
/** Safely coerce value to string for display. Returns null if invalid type. */
function str(value) {
if (typeof value === 'string') return value;
if (value == null) return '';
return null;
}
function getLanguageFromPath(filePath) {
const ext = filePath.split('.').pop()?.toLowerCase();
const extToLang = {
@@ -712,10 +720,13 @@
const args = call.arguments || {};
const name = call.name;
const invalidArg = '<span class="tool-error">[invalid arg]</span>';
switch (name) {
case 'bash': {
const command = args.command || '';
html += `<div class="tool-command">$ ${escapeHtml(command)}</div>`;
const command = str(args.command);
const cmdDisplay = command === null ? invalidArg : escapeHtml(command || '...');
html += `<div class="tool-command">$ ${cmdDisplay}</div>`;
if (result) {
const output = getResultText().trim();
if (output) html += formatExpandableOutput(output, 5);
@@ -723,13 +734,12 @@
break;
}
case 'read': {
const filePath = args.file_path || args.path || '';
const filePath = str(args.file_path ?? args.path);
const offset = args.offset;
const limit = args.limit;
const lang = getLanguageFromPath(filePath);
let pathHtml = escapeHtml(shortenPath(filePath));
if (offset !== undefined || limit !== undefined) {
let pathHtml = filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || ''));
if (filePath !== null && (offset !== undefined || limit !== undefined)) {
const startLine = offset ?? 1;
const endLine = limit !== undefined ? startLine + limit - 1 : '';
pathHtml += `<span class="line-numbers">:${startLine}${endLine ? '-' + endLine : ''}</span>`;
@@ -739,21 +749,28 @@
if (result) {
html += renderResultImages();
const output = getResultText();
const lang = filePath ? getLanguageFromPath(filePath) : null;
if (output) html += formatExpandableOutput(output, 10, lang);
}
break;
}
case 'write': {
const filePath = args.file_path || args.path || '';
const content = args.content || '';
const lines = content.split('\n');
const lang = getLanguageFromPath(filePath);
const filePath = str(args.file_path ?? args.path);
const content = str(args.content);
html += `<div class="tool-header"><span class="tool-name">write</span> <span class="tool-path">${escapeHtml(shortenPath(filePath))}</span>`;
if (lines.length > 10) html += ` <span class="line-count">(${lines.length} lines)</span>`;
html += `<div class="tool-header"><span class="tool-name">write</span> <span class="tool-path">${filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || ''))}</span>`;
if (content !== null && content) {
const lines = content.split('\n');
if (lines.length > 10) html += ` <span class="line-count">(${lines.length} lines)</span>`;
}
html += '</div>';
if (content) html += formatExpandableOutput(content, 10, lang);
if (content === null) {
html += `<div class="tool-error">[invalid content arg - expected string]</div>`;
} else if (content) {
const lang = filePath ? getLanguageFromPath(filePath) : null;
html += formatExpandableOutput(content, 10, lang);
}
if (result) {
const output = getResultText().trim();
if (output) html += `<div class="tool-output"><div>${escapeHtml(output)}</div></div>`;
@@ -761,8 +778,8 @@
break;
}
case 'edit': {
const filePath = args.file_path || args.path || '';
html += `<div class="tool-header"><span class="tool-name">edit</span> <span class="tool-path">${escapeHtml(shortenPath(filePath))}</span></div>`;
const filePath = str(args.file_path ?? args.path);
html += `<div class="tool-header"><span class="tool-name">edit</span> <span class="tool-path">${filePath === null ? invalidArg : escapeHtml(shortenPath(filePath || ''))}</span></div>`;
if (result?.details?.diff) {
const diffLines = result.details.diff.split('\n');
@@ -2,6 +2,7 @@
* Extension system for lifecycle events and custom tools.
*/
export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from "../slash-commands";
export { discoverAndLoadExtensions, ExtensionRuntime, loadExtensionFromFactory, loadExtensions } from "./loader";
export type {
BranchHandler,
@@ -9,6 +10,7 @@ export type {
NavigateTreeHandler,
NewSessionHandler,
ShutdownHandler,
SwitchSessionHandler,
} from "./runner";
export { ExtensionRunner } from "./runner";
export type {
@@ -19,6 +21,8 @@ export type {
AgentToolUpdateCallback,
AppAction,
AppendEntryHandler,
// Events - Tool (ToolCallEvent types)
BashToolCallEvent,
BashToolResultEvent,
BeforeAgentStartEvent,
BeforeAgentStartEventResult,
@@ -26,7 +30,10 @@ export type {
ContextEvent,
// Event Results
ContextEventResult,
ContextUsage,
CustomToolCallEvent,
CustomToolResultEvent,
EditToolCallEvent,
EditToolResultEvent,
ExecOptions,
ExecResult,
@@ -47,11 +54,15 @@ export type {
ExtensionShortcut,
ExtensionUIContext,
ExtensionUIDialogOptions,
FindToolCallEvent,
FindToolResultEvent,
GetActiveToolsHandler,
GetAllToolsHandler,
GetCommandsHandler,
GetThinkingLevelHandler,
GrepToolCallEvent,
GrepToolResultEvent,
// Events - Input
InputEvent,
InputEventResult,
KeybindingsManager,
@@ -59,10 +70,14 @@ export type {
// Message Rendering
MessageRenderer,
MessageRenderOptions,
ReadToolCallEvent,
ReadToolResultEvent,
// Commands
RegisteredCommand,
RegisteredTool,
// Events - Resources
ResourcesDiscoverEvent,
ResourcesDiscoverResult,
SendMessageHandler,
SendUserMessageHandler,
SessionBeforeBranchEvent,
@@ -101,6 +116,9 @@ export type {
UserBashEventResult,
UserPythonEvent,
UserPythonEventResult,
WriteToolCallEvent,
WriteToolResultEvent,
} from "./types";
// Type guards
export { isToolCallEventType } from "./types";
export { ExtensionToolWrapper, RegisteredToolAdapter, wrapRegisteredTool, wrapRegisteredTools } from "./wrapper";
@@ -81,6 +81,10 @@ export class ExtensionRuntime implements IExtensionRuntime {
throw new ExtensionRuntimeNotInitializedError();
}
getCommands(): never {
throw new ExtensionRuntimeNotInitializedError();
}
setModel(): Promise<boolean> {
throw new ExtensionRuntimeNotInitializedError();
}
@@ -203,6 +207,10 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
return this.runtime.setActiveTools(toolNames);
}
getCommands() {
return this.runtime.getCommands();
}
setModel(model: Model): Promise<boolean> {
return this.runtime.setModel(model);
}
@@ -396,6 +404,13 @@ async function resolveExtensionEntries(dir: string): Promise<string[] | null> {
async function discoverExtensionsInDir(dir: string): Promise<string[]> {
const discovered: string[] = [];
// First check if this directory itself has explicit extension entries (package.json or index)
const rootEntries = await resolveExtensionEntries(dir);
if (rootEntries) {
return rootEntries;
}
// Otherwise, discover extensions from directory contents
let entries: fs1.Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
@@ -32,6 +32,8 @@ import type {
MessageRenderer,
RegisteredCommand,
RegisteredTool,
ResourcesDiscoverEvent,
ResourcesDiscoverResult,
SessionBeforeCompactResult,
SessionBeforeTreeResult,
SessionCompactingResult,
@@ -64,6 +66,8 @@ export type NavigateTreeHandler = (
options?: { summarize?: boolean },
) => Promise<{ cancelled: boolean }>;
export type SwitchSessionHandler = (sessionPath: string) => Promise<{ cancelled: boolean }>;
export type ShutdownHandler = () => void;
/**
@@ -102,6 +106,8 @@ const noOpUIContext: ExtensionUIContext = {
getAllThemes: () => Promise.resolve([]),
getTheme: () => Promise.resolve(undefined),
setTheme: (_theme: string | Theme) => Promise.resolve({ success: false, error: "UI not available" }),
getToolsExpanded: () => false,
setToolsExpanded: () => {},
};
export class ExtensionRunner {
@@ -114,10 +120,13 @@ export class ExtensionRunner {
private hasPendingMessagesFn: () => boolean = () => false;
private getContextUsageFn: () => ContextUsage | undefined = () => undefined;
private compactFn: (instructionsOrOptions?: string | CompactOptions) => Promise<void> = async () => {};
private getSystemPromptFn: () => string = () => "";
private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
private branchHandler: BranchHandler = async () => ({ cancelled: false });
private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
private switchSessionHandler: SwitchSessionHandler = async () => ({ cancelled: false });
private shutdownHandler: ShutdownHandler = () => {};
private commandDiagnostics: Array<{ type: string; message: string; path: string }> = [];
constructor(
private readonly extensions: Extension[],
@@ -142,6 +151,7 @@ export class ExtensionRunner {
this.runtime.getActiveTools = actions.getActiveTools;
this.runtime.getAllTools = actions.getAllTools;
this.runtime.setActiveTools = actions.setActiveTools;
this.runtime.getCommands = actions.getCommands;
this.runtime.setModel = actions.setModel;
this.runtime.getThinkingLevel = actions.getThinkingLevel;
this.runtime.setThinkingLevel = actions.setThinkingLevel;
@@ -152,6 +162,7 @@ export class ExtensionRunner {
this.abortFn = contextActions.abort;
this.hasPendingMessagesFn = contextActions.hasPendingMessages;
this.shutdownHandler = contextActions.shutdown;
this.getSystemPromptFn = contextActions.getSystemPrompt;
// Command context actions (optional, only for interactive mode)
if (commandContextActions) {
@@ -159,6 +170,7 @@ export class ExtensionRunner {
this.newSessionHandler = commandContextActions.newSession;
this.branchHandler = commandContextActions.branch;
this.navigateTreeHandler = commandContextActions.navigateTree;
this.switchSessionHandler = commandContextActions.switchSession;
this.getContextUsageFn = commandContextActions.getContextUsage;
this.compactFn = commandContextActions.compact;
}
@@ -279,16 +291,31 @@ export class ExtensionRunner {
return undefined;
}
getRegisteredCommands(): RegisteredCommand[] {
getRegisteredCommands(reserved?: Set<string>): RegisteredCommand[] {
this.commandDiagnostics = [];
const commands: RegisteredCommand[] = [];
for (const ext of this.extensions) {
for (const command of ext.commands.values()) {
if (reserved?.has(command.name)) {
const message = `Extension command '${command.name}' from ${ext.path} conflicts with built-in commands. Skipping.`;
this.commandDiagnostics.push({ type: "warning", message, path: ext.path });
if (!this.hasUI()) {
logger.warn(message);
}
continue;
}
commands.push(command);
}
}
return commands;
}
getCommandDiagnostics(): Array<{ type: string; message: string; path: string }> {
return this.commandDiagnostics;
}
getCommand(name: string): RegisteredCommand | undefined {
for (const ext of this.extensions) {
const command = ext.commands.get(name);
@@ -316,6 +343,7 @@ export class ExtensionRunner {
abort: () => this.abortFn(),
hasPendingMessages: () => this.hasPendingMessagesFn(),
shutdown: () => this.shutdownHandler(),
getSystemPrompt: () => this.getSystemPromptFn(),
hasQueuedMessages: () => this.hasPendingMessagesFn(), // deprecated alias
};
}
@@ -335,6 +363,7 @@ export class ExtensionRunner {
newSession: options => this.newSessionHandler(options),
branch: entryId => this.branchHandler(entryId),
navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options),
switchSession: sessionPath => this.switchSessionHandler(sessionPath),
compact: instructionsOrOptions => this.compactFn(instructionsOrOptions),
};
}
@@ -493,6 +522,54 @@ export class ExtensionRunner {
return undefined;
}
async emitResourcesDiscover(
cwd: string,
reason: ResourcesDiscoverEvent["reason"],
): Promise<{
skillPaths: Array<{ path: string; extensionPath: string }>;
promptPaths: Array<{ path: string; extensionPath: string }>;
themePaths: Array<{ path: string; extensionPath: string }>;
}> {
const ctx = this.createContext();
const skillPaths: Array<{ path: string; extensionPath: string }> = [];
const promptPaths: Array<{ path: string; extensionPath: string }> = [];
const themePaths: Array<{ path: string; extensionPath: string }> = [];
for (const ext of this.extensions) {
const handlers = ext.handlers.get("resources_discover");
if (!handlers || handlers.length === 0) continue;
for (const handler of handlers) {
try {
const event: ResourcesDiscoverEvent = { type: "resources_discover", cwd, reason };
const handlerResult = await handler(event, ctx);
const result = handlerResult as ResourcesDiscoverResult | undefined;
if (result?.skillPaths?.length) {
skillPaths.push(...result.skillPaths.map(path => ({ path, extensionPath: ext.path })));
}
if (result?.promptPaths?.length) {
promptPaths.push(...result.promptPaths.map(path => ({ path, extensionPath: ext.path })));
}
if (result?.themePaths?.length) {
themePaths.push(...result.themePaths.map(path => ({ path, extensionPath: ext.path })));
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
this.emitError({
extensionPath: ext.path,
event: "resources_discover",
error: message,
stack,
});
}
}
}
return { skillPaths, promptPaths, themePaths };
}
/** Emit input event. Transforms chain, "handled" short-circuits. */
async emitInput(
text: string,
@@ -28,8 +28,19 @@ import type {
SessionEntry,
SessionManager,
} from "../../session/session-manager";
import type { BashToolDetails, FindToolDetails, GrepToolDetails, ReadToolDetails } from "../../tools";
import type {
BashToolDetails,
BashToolInput,
FindToolDetails,
FindToolInput,
GrepToolDetails,
GrepToolInput,
ReadToolDetails,
ReadToolInput,
WriteToolInput,
} from "../../tools";
import type { EventBus } from "../../utils/event-bus";
import type { SlashCommandInfo } from "../slash-commands";
export type { AppAction, KeybindingsManager } from "../../config/keybindings";
export type { ExecOptions, ExecResult } from "../../exec/exec";
@@ -123,6 +134,12 @@ export interface ExtensionUIContext {
/** Set the current theme by name or Theme object. */
setTheme(theme: string | Theme): Promise<{ success: boolean; error?: string }>;
/** Get current tool output expansion state. */
getToolsExpanded(): boolean;
/** Set tool output expansion state. */
setToolsExpanded(expanded: boolean): void;
}
// ============================================================================
@@ -171,6 +188,8 @@ export interface ExtensionContext {
hasPendingMessages(): boolean;
/** Gracefully shutdown and exit. */
shutdown(): void;
/** Get the current effective system prompt. */
getSystemPrompt(): string;
/** @deprecated Use hasPendingMessages() instead */
hasQueuedMessages(): boolean;
}
@@ -198,6 +217,9 @@ export interface ExtensionCommandContext extends ExtensionContext {
/** Navigate to a different point in the session tree. */
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>;
/** Switch to a different session file. */
switchSession(sessionPath: string): Promise<{ cancelled: boolean }>;
/** Compact the session context (interactive mode shows UI). */
compact(instructionsOrOptions?: string | CompactOptions): Promise<void>;
}
@@ -243,9 +265,9 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
execute(
toolCallId: string,
params: Static<TParams>,
signal: AbortSignal | undefined,
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
ctx: ExtensionContext,
signal?: AbortSignal,
): Promise<AgentToolResult<TDetails>>;
/** Called on session lifecycle events - use to reconstruct state or cleanup resources */
@@ -263,6 +285,24 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
) => Component;
}
// ============================================================================
// Resource Events
// ============================================================================
/** Fired after session_start to allow extensions to provide additional resource paths. */
export interface ResourcesDiscoverEvent {
type: "resources_discover";
cwd: string;
reason: "startup" | "reload";
}
/** Result from resources_discover event handler */
export interface ResourcesDiscoverResult {
skillPaths?: string[];
promptPaths?: string[];
themePaths?: string[];
}
// ============================================================================
// Session Events
// ============================================================================
@@ -454,14 +494,56 @@ export interface InputEvent {
// Tool Events
// ============================================================================
/** Fired before a tool executes. Can block. */
export interface ToolCallEvent {
interface ToolCallEventBase {
type: "tool_call";
toolName: string;
toolCallId: string;
}
export interface BashToolCallEvent extends ToolCallEventBase {
toolName: "bash";
input: BashToolInput;
}
export interface ReadToolCallEvent extends ToolCallEventBase {
toolName: "read";
input: ReadToolInput;
}
export interface EditToolCallEvent extends ToolCallEventBase {
toolName: "edit";
input: Record<string, unknown>;
}
export interface WriteToolCallEvent extends ToolCallEventBase {
toolName: "write";
input: WriteToolInput;
}
export interface GrepToolCallEvent extends ToolCallEventBase {
toolName: "grep";
input: GrepToolInput;
}
export interface FindToolCallEvent extends ToolCallEventBase {
toolName: "find";
input: FindToolInput;
}
export interface CustomToolCallEvent extends ToolCallEventBase {
toolName: string;
input: Record<string, unknown>;
}
/** Fired before a tool executes. Can block. */
export type ToolCallEvent =
| BashToolCallEvent
| ReadToolCallEvent
| EditToolCallEvent
| WriteToolCallEvent
| GrepToolCallEvent
| FindToolCallEvent
| CustomToolCallEvent;
interface ToolResultEventBase {
type: "tool_result";
toolCallId: string;
@@ -515,8 +597,43 @@ export type ToolResultEvent =
| FindToolResultEvent
| CustomToolResultEvent;
/**
* Type guard for narrowing ToolCallEvent by tool name.
*
* Built-in tools narrow automatically (no type params needed):
* ```ts
* if (isToolCallEventType("bash", event)) {
* event.input.command; // string
* }
* ```
*
* Custom tools require explicit type parameters:
* ```ts
* if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) {
* event.input.action; // typed
* }
* ```
*
* Note: Direct narrowing via `event.toolName === "bash"` doesn't work because
* CustomToolCallEvent.toolName is `string` which overlaps with all literals.
*/
export function isToolCallEventType(toolName: "bash", event: ToolCallEvent): event is BashToolCallEvent;
export function isToolCallEventType(toolName: "read", event: ToolCallEvent): event is ReadToolCallEvent;
export function isToolCallEventType(toolName: "edit", event: ToolCallEvent): event is EditToolCallEvent;
export function isToolCallEventType(toolName: "write", event: ToolCallEvent): event is WriteToolCallEvent;
export function isToolCallEventType(toolName: "grep", event: ToolCallEvent): event is GrepToolCallEvent;
export function isToolCallEventType(toolName: "find", event: ToolCallEvent): event is FindToolCallEvent;
export function isToolCallEventType<TName extends string, TInput extends Record<string, unknown>>(
toolName: TName,
event: ToolCallEvent,
): event is ToolCallEvent & { toolName: TName; input: TInput };
export function isToolCallEventType(toolName: string, event: ToolCallEvent): boolean {
return event.toolName === toolName;
}
/** Union of all event types */
export type ExtensionEvent =
| ResourcesDiscoverEvent
| SessionEvent
| ContextEvent
| BeforeAgentStartEvent
@@ -659,6 +776,7 @@ export interface ExtensionAPI {
// Event Subscription
// =========================================================================
on(event: "resources_discover", handler: ExtensionHandler<ResourcesDiscoverEvent, ResourcesDiscoverResult>): void;
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
on(
event: "session_before_switch",
@@ -775,6 +893,9 @@ export interface ExtensionAPI {
/** Set the active tools by name. */
setActiveTools(toolNames: string[]): Promise<void>;
/** Get available slash commands in the current session. */
getCommands(): SlashCommandInfo[];
/** Set the current model. Returns false if no API key available. */
setModel(model: Model): Promise<boolean>;
@@ -833,6 +954,8 @@ export type GetActiveToolsHandler = () => string[];
export type GetAllToolsHandler = () => string[];
export type GetCommandsHandler = () => SlashCommandInfo[];
export type SetActiveToolsHandler = (toolNames: string[]) => Promise<void>;
export type SetModelHandler = (model: Model) => Promise<boolean>;
@@ -855,6 +978,7 @@ export interface ExtensionActions {
getActiveTools: GetActiveToolsHandler;
getAllTools: GetAllToolsHandler;
setActiveTools: SetActiveToolsHandler;
getCommands: GetCommandsHandler;
setModel: SetModelHandler;
getThinkingLevel: GetThinkingLevelHandler;
setThinkingLevel: SetThinkingLevelHandler;
@@ -869,6 +993,7 @@ export interface ExtensionContextActions {
shutdown: () => void;
getContextUsage: () => ContextUsage | undefined;
compact: (instructionsOrOptions?: string | CompactOptions) => Promise<void>;
getSystemPrompt: () => string;
}
/** Actions for ExtensionCommandContext (ctx.* in command handlers). */
@@ -882,6 +1007,7 @@ export interface ExtensionCommandContextActions {
branch: (entryId: string) => Promise<{ cancelled: boolean }>;
navigateTree: (targetId: string, options?: { summarize?: boolean }) => Promise<{ cancelled: boolean }>;
compact: (instructionsOrOptions?: string | CompactOptions) => Promise<void>;
switchSession: (sessionPath: string) => Promise<{ cancelled: boolean }>;
}
/** Full runtime = state + actions. */
@@ -32,7 +32,7 @@ export class RegisteredToolAdapter implements AgentTool<any, any, any> {
onUpdate?: AgentToolUpdateCallback<any>,
_context?: AgentToolContext,
) {
return this.registeredTool.definition.execute(toolCallId, params, onUpdate, this.runner.createContext(), signal);
return this.registeredTool.definition.execute(toolCallId, params, signal, onUpdate, this.runner.createContext());
}
renderCall?(args: any, theme: any) {
@@ -0,0 +1,218 @@
/**
* Parsed git URL information.
*/
export type GitSource = {
/** Always "git" for git sources */
type: "git";
/** Clone URL (always valid for git clone, without ref suffix) */
repo: string;
/** Git host domain (e.g., "github.com") */
host: string;
/** Repository path (e.g., "user/repo") */
path: string;
/** Git ref (branch, tag, commit) if specified */
ref?: string;
/** True if ref was specified (package won't be auto-updated) */
pinned: boolean;
};
/** Known git hosts and their URL extraction logic. */
const KNOWN_HOSTS: Record<string, (pathname: string, hash: string) => { user: string; project: string } | null> = {
"github.com": extractStandard,
"gitlab.com": extractGitLab,
"bitbucket.org": extractStandard,
"git.sr.ht": extractStandard,
"codeberg.org": extractStandard,
};
function extractStandard(pathname: string, _hash: string): { user: string; project: string } | null {
const [, user, project] = pathname.split("/", 3);
if (!user || !project) return null;
return { user, project: project.replace(/\.git$/, "") };
}
function extractGitLab(pathname: string, _hash: string): { user: string; project: string } | null {
const path = pathname.startsWith("/") ? pathname.slice(1) : pathname;
if (path.includes("/-/") || path.includes("/archive.tar.gz")) return null;
const segments = path.split("/");
let project = segments.pop();
if (!project) return null;
project = project.replace(/\.git$/, "");
const user = segments.join("/");
if (!user || !project) return null;
return { user, project };
}
/**
* Try to parse a URL against known git hosts.
* Returns `{ domain, user, project, committish }` or null.
*/
function tryKnownHost(candidate: string): { domain: string; user: string; project: string; committish: string } | null {
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return null;
}
const hostname = parsed.hostname.startsWith("www.") ? parsed.hostname.slice(4) : parsed.hostname;
const extractor = KNOWN_HOSTS[hostname];
if (!extractor) return null;
const segments = extractor(parsed.pathname, parsed.hash);
if (!segments) return null;
return {
domain: hostname,
user: segments.user,
project: segments.project,
committish: parsed.hash ? decodeURIComponent(parsed.hash.slice(1)) : "",
};
}
function splitRef(url: string): { repo: string; ref?: string } {
const scpLikeMatch = url.match(/^git@([^:]+):(.+)$/);
if (scpLikeMatch) {
const pathWithMaybeRef = scpLikeMatch[2] ?? "";
const refSeparator = pathWithMaybeRef.indexOf("@");
if (refSeparator < 0) return { repo: url };
const repoPath = pathWithMaybeRef.slice(0, refSeparator);
const ref = pathWithMaybeRef.slice(refSeparator + 1);
if (!repoPath || !ref) return { repo: url };
return {
repo: `git@${scpLikeMatch[1] ?? ""}:${repoPath}`,
ref,
};
}
if (url.includes("://")) {
try {
const parsed = new URL(url);
const pathWithMaybeRef = parsed.pathname.replace(/^\/+/, "");
const refSeparator = pathWithMaybeRef.indexOf("@");
if (refSeparator < 0) return { repo: url };
const repoPath = pathWithMaybeRef.slice(0, refSeparator);
const ref = pathWithMaybeRef.slice(refSeparator + 1);
if (!repoPath || !ref) return { repo: url };
parsed.pathname = `/${repoPath}`;
return {
repo: parsed.toString().replace(/\/$/, ""),
ref,
};
} catch {
return { repo: url };
}
}
const slashIndex = url.indexOf("/");
if (slashIndex < 0) return { repo: url };
const host = url.slice(0, slashIndex);
const pathWithMaybeRef = url.slice(slashIndex + 1);
const refSeparator = pathWithMaybeRef.indexOf("@");
if (refSeparator < 0) return { repo: url };
const repoPath = pathWithMaybeRef.slice(0, refSeparator);
const ref = pathWithMaybeRef.slice(refSeparator + 1);
if (!repoPath || !ref) return { repo: url };
return { repo: `${host}/${repoPath}`, ref };
}
/** Try known-host parsing and build a GitSource from the result. */
function tryKnownHostSource(
split: { repo: string; ref?: string },
candidate: string,
repoUrl: string,
): GitSource | null {
const info = tryKnownHost(candidate);
if (!info) return null;
if (split.ref && info.project.includes("@")) return null;
return {
type: "git",
repo: repoUrl,
host: info.domain,
path: `${info.user}/${info.project}`.replace(/\.git$/, ""),
ref: info.committish || split.ref || undefined,
pinned: Boolean(info.committish || split.ref),
};
}
function parseGenericGitUrl(url: string): GitSource | null {
const { repo: repoWithoutRef, ref } = splitRef(url);
let repo = repoWithoutRef;
let host = "";
let repoPath = "";
const scpLikeMatch = repoWithoutRef.match(/^git@([^:]+):(.+)$/);
if (scpLikeMatch) {
host = scpLikeMatch[1] ?? "";
repoPath = scpLikeMatch[2] ?? "";
} else if (/^https?:\/\/|^ssh:\/\//.test(repoWithoutRef)) {
try {
const parsed = new URL(repoWithoutRef);
host = parsed.hostname;
repoPath = parsed.pathname.replace(/^\/+/, "");
} catch {
return null;
}
} else {
const slashIndex = repoWithoutRef.indexOf("/");
if (slashIndex < 0) return null;
host = repoWithoutRef.slice(0, slashIndex);
repoPath = repoWithoutRef.slice(slashIndex + 1);
if (!host.includes(".") && host !== "localhost") return null;
repo = `https://${repoWithoutRef}`;
}
const normalizedPath = repoPath.replace(/\.git$/, "").replace(/^\/+/, "");
if (!host || !normalizedPath || normalizedPath.split("/").length < 2) return null;
return { type: "git", repo, host, path: normalizedPath, ref, pinned: Boolean(ref) };
}
/**
* Parse any git URL (SSH or HTTPS) into a GitSource.
*
* Handles:
* - `git:` prefixed URLs (`git:github.com/user/repo`)
* - SSH SCP-like URLs (`git@github.com:user/repo`)
* - HTTPS/HTTP/SSH protocol URLs
* - Bare `host/user/repo` shorthand
* - Ref pinning via `@ref` suffix
*
* Recognizes GitHub, GitLab, Bitbucket, Sourcehut, and Codeberg natively.
* Falls back to generic URL parsing for other hosts.
*/
export function parseGitUrl(source: string): GitSource | null {
const url = source.startsWith("git:") ? source.slice(4).trim() : source;
const split = splitRef(url);
// SCP-like SSH URLs (git@host:user/repo) — convert to https for host matching
const scpMatch = split.repo.match(/^git@([^:]+):(.+)$/);
// Try known hosts with the repo URL directly
const directCandidates: string[] = [];
if (scpMatch) {
directCandidates.push(`https://${scpMatch[1]}/${scpMatch[2]}`);
} else if (/^https?:\/\/|^ssh:\/\//.test(split.repo)) {
directCandidates.push(split.repo);
}
for (const candidate of directCandidates) {
const withRef = split.ref ? `${candidate.replace(/#.*$/, "")}#${split.ref}` : candidate;
const needsHttps =
!split.repo.startsWith("http://") &&
!split.repo.startsWith("https://") &&
!split.repo.startsWith("ssh://") &&
!split.repo.startsWith("git@");
const result = tryKnownHostSource(split, withRef, needsHttps ? `https://${split.repo}` : split.repo);
if (result) return result;
}
// Try with https:// prefix for bare host/user/repo shorthand
if (!split.repo.includes("://") && !split.repo.startsWith("git@")) {
const httpsCandidate = split.ref ? `https://${split.repo}#${split.ref}` : `https://${url}`;
const result = tryKnownHostSource(split, httpsCandidate, `https://${split.repo}`);
if (result) return result;
}
return parseGenericGitUrl(url);
}
@@ -1,5 +1,7 @@
// Plugin system exports
export { formatDoctorResults, runDoctorChecks } from "./doctor";
export { type GitSource, parseGitUrl } from "./git-url";
export {
getAllPluginCommandPaths,
getAllPluginHookPaths,
@@ -1,3 +1,48 @@
export type SlashCommandSource = "extension" | "prompt" | "skill";
export type SlashCommandLocation = "user" | "project" | "path";
export interface SlashCommandInfo {
name: string;
description?: string;
source: SlashCommandSource;
location?: SlashCommandLocation;
path?: string;
}
export interface BuiltinSlashCommand {
name: string;
description: string;
}
export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
{ name: "settings", description: "Open settings menu" },
{ name: "plan", description: "Toggle plan mode (agent plans before executing)" },
{ name: "model", description: "Select model (opens selector UI)" },
{ name: "export", description: "Export session to HTML file" },
{ name: "dump", description: "Copy session transcript to clipboard" },
{ name: "share", description: "Share session as a secret GitHub gist" },
{ name: "browser", description: "Toggle browser headless vs visible mode" },
{ name: "copy", description: "Copy last agent message to clipboard" },
{ name: "session", description: "Show session info and stats" },
{ name: "usage", description: "Show provider usage and limits" },
{ name: "changelog", description: "Show changelog entries" },
{ name: "hotkeys", description: "Show all keyboard shortcuts" },
{ name: "extensions", description: "Open Extension Control Center dashboard" },
{ name: "branch", description: "Create a new branch from a previous message" },
{ name: "fork", description: "Create a new fork from a previous message" },
{ name: "tree", description: "Navigate session tree (switch branches)" },
{ name: "login", description: "Login with OAuth provider" },
{ name: "logout", description: "Logout from OAuth provider" },
{ name: "new", description: "Start a new session" },
{ name: "compact", description: "Manually compact the session context" },
{ name: "handoff", description: "Hand off session context to a new session" },
{ name: "resume", description: "Resume a different session" },
{ name: "background", description: "Detach UI and continue running in background" },
{ name: "debug", description: "Write debug log (TUI state and messages)" },
{ name: "exit", description: "Exit the application" },
];
import { slashCommandCapability } from "../capability/slash-command";
import { renderPromptTemplate } from "../config/prompt-templates";
import type { SlashCommand } from "../discovery";
+14
View File
@@ -40,6 +40,9 @@ export type {
export { CustomToolLoader, discoverAndLoadCustomTools, loadCustomTools } from "./extensibility/custom-tools";
export type {
AppAction,
BashToolCallEvent,
CustomToolCallEvent,
EditToolCallEvent,
Extension,
ExtensionActions,
ExtensionAPI,
@@ -55,13 +58,17 @@ export type {
ExtensionShortcut,
ExtensionUIContext,
ExtensionUIDialogOptions,
FindToolCallEvent,
GrepToolCallEvent,
InputEvent,
InputEventResult,
KeybindingsManager,
LoadExtensionsResult,
MessageRenderer,
MessageRenderOptions,
ReadToolCallEvent,
RegisteredCommand,
ToolCallEvent,
ToolResultEvent,
TurnEndEvent,
TurnStartEvent,
@@ -69,12 +76,14 @@ export type {
UserBashEventResult,
UserPythonEvent,
UserPythonEventResult,
WriteToolCallEvent,
} from "./extensibility/extensions";
// Extension types and utilities
export {
discoverAndLoadExtensions,
ExtensionRunner,
ExtensionRuntime,
isToolCallEventType,
} from "./extensibility/extensions";
// Hook system types (legacy re-export)
export type * from "./extensibility/hooks";
@@ -238,22 +247,27 @@ export {
// Tools (detail types and utilities)
export {
type BashToolDetails,
type BashToolInput,
type BrowserToolDetails,
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
type FindOperations,
type FindToolDetails,
type FindToolInput,
type FindToolOptions,
formatSize,
type GrepOperations,
type GrepToolDetails,
type GrepToolInput,
type GrepToolOptions,
type PythonToolDetails,
type ReadToolDetails,
type ReadToolInput,
type TruncationOptions,
type TruncationResult,
truncateHead,
truncateLine,
truncateTail,
type WriteToolDetails,
type WriteToolInput,
} from "./tools";
+6 -5
View File
@@ -495,26 +495,27 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
if (parsedArgs.version) {
writeStdout(VERSION);
return;
process.exit(0);
}
if (parsedArgs.listModels !== undefined) {
const searchPattern = typeof parsedArgs.listModels === "string" ? parsedArgs.listModels : undefined;
await listModels(modelRegistry, searchPattern);
return;
process.exit(0);
}
if (parsedArgs.export) {
let result: string;
try {
const outputPath = parsedArgs.messages.length > 0 ? parsedArgs.messages[0] : undefined;
const result = await exportFromFile(parsedArgs.export, outputPath);
writeStdout(`Exported to: ${result}`);
return;
result = await exportFromFile(parsedArgs.export, outputPath);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Failed to export session";
writeStderr(chalk.red(`Error: ${message}`));
process.exit(1);
}
writeStdout(`Exported to: ${result}`);
process.exit(0);
}
if (parsedArgs.mode === "rpc" && parsedArgs.fileArgs.length > 0) {
@@ -82,14 +82,6 @@ export class CustomMessageComponent extends Container {
.join("\n");
}
// Limit lines when collapsed
if (!this._expanded) {
const lines = text.split("\n");
if (lines.length > 5) {
text = `${lines.slice(0, 5).join("\n")}\n…`;
}
}
this.box.addChild(
new Markdown(text, 0, 0, getMarkdownTheme(), {
color: (value: string) => theme.fg("customMessageText", value),
@@ -212,7 +212,7 @@ export class FooterComponent implements Component {
// Truncate path if too long to fit width
if (pwd.length > width) {
const half = Math.floor(width / 2) - 2;
if (half > 0) {
if (half > 1) {
const start = pwd.slice(0, half);
const end = pwd.slice(-(half - 1));
pwd = `${start}...${end}`;
@@ -398,6 +398,10 @@ export class ModelSelectorComponent extends Container {
}
} else if (this.filteredModels.length === 0) {
this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0));
} else {
const selected = this.filteredModels[this.selectedIndex];
this.listContainer.addChild(new Spacer(1));
this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0));
}
}
@@ -55,6 +55,7 @@ class TreeList implements Component {
private toolCallMap: Map<string, ToolCallInfo> = new Map();
private multipleRoots = false;
private activePathIds: Set<string> = new Set();
private lastSelectedId: string | null = null;
public onSelect?: (entryId: string) => void;
public onCancel?: () => void;
@@ -64,19 +65,17 @@ class TreeList implements Component {
tree: SessionTreeNode[],
private readonly currentLeafId: string | null,
private readonly maxVisibleLines: number,
initialSelectedId?: string,
) {
this.multipleRoots = tree.length > 1;
this.flatNodes = this.flattenTree(tree);
this.buildActivePath();
this.applyFilter();
// Start with current leaf selected
const leafIndex = this.filteredNodes.findIndex(n => n.node.entry.id === currentLeafId);
if (leafIndex !== -1) {
this.selectedIndex = leafIndex;
} else {
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
}
// Start with initialSelectedId if provided, otherwise current leaf
const targetId = initialSelectedId ?? currentLeafId;
this.selectedIndex = this.findNearestVisibleIndex(targetId);
this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? null;
}
/** Build the set of entry IDs on the path from root to current leaf */
@@ -100,6 +99,36 @@ class TreeList implements Component {
}
}
/**
* Find the index of the nearest visible entry, walking up the parent chain if needed.
* Returns the index in filteredNodes, or the last index as fallback.
*/
private findNearestVisibleIndex(entryId: string | null): number {
if (this.filteredNodes.length === 0) return 0;
// Build a map for parent lookup
const entryMap = new Map<string, FlatNode>();
for (const flatNode of this.flatNodes) {
entryMap.set(flatNode.node.entry.id, flatNode);
}
// Build a map of visible entry IDs to their indices in filteredNodes
const visibleIdToIndex = new Map<string, number>(this.filteredNodes.map((node, i) => [node.node.entry.id, i]));
// Walk from entryId up to root, looking for a visible entry
let currentId = entryId;
while (currentId !== null) {
const index = visibleIdToIndex.get(currentId);
if (index !== undefined) return index;
const node = entryMap.get(currentId);
if (!node) break;
currentId = node.node.entry.parentId ?? null;
}
// Fallback: last visible entry
return this.filteredNodes.length - 1;
}
private flattenTree(roots: SessionTreeNode[]): FlatNode[] {
const result: FlatNode[] = [];
this.toolCallMap.clear();
@@ -231,8 +260,11 @@ class TreeList implements Component {
}
private applyFilter(): void {
// Remember currently selected node to preserve cursor position
const previouslySelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id;
// Update lastSelectedId only when we have a valid selection (non-empty list)
// This preserves the selection when switching through empty filter results
if (this.filteredNodes.length > 0) {
this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId;
}
const searchTokens = this.searchQuery.toLowerCase().split(/\s+/).filter(Boolean);
@@ -295,18 +327,17 @@ class TreeList implements Component {
return true;
});
// Try to preserve cursor on the same node after filtering
if (previouslySelectedId) {
const newIndex = this.filteredNodes.findIndex(n => n.node.entry.id === previouslySelectedId);
if (newIndex !== -1) {
this.selectedIndex = newIndex;
return;
}
// Try to preserve cursor on the same node, or find nearest visible ancestor
if (this.lastSelectedId) {
this.selectedIndex = this.findNearestVisibleIndex(this.lastSelectedId);
} else if (this.selectedIndex >= this.filteredNodes.length) {
// Clamp index if out of bounds
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
}
// Fall back: clamp index if out of bounds
if (this.selectedIndex >= this.filteredNodes.length) {
this.selectedIndex = Math.max(0, this.filteredNodes.length - 1);
// Update lastSelectedId to the actual selection (may have changed due to parent walk)
if (this.filteredNodes.length > 0) {
this.lastSelectedId = this.filteredNodes[this.selectedIndex]?.node.entry.id ?? this.lastSelectedId;
}
}
@@ -64,6 +64,8 @@ export class ExtensionUiController {
setFooter: () => {},
setHeader: () => {},
setEditorComponent: () => {},
getToolsExpanded: () => this.ctx.toolOutputExpanded,
setToolsExpanded: expanded => this.ctx.setToolsExpanded(expanded),
};
this.ctx.setToolUIContext(uiContext, true);
@@ -114,6 +116,7 @@ export class ExtensionUiController {
},
getThinkingLevel: () => this.ctx.session.thinkingLevel,
setThinkingLevel: level => this.ctx.session.setThinkingLevel(level),
getCommands: () => [],
};
const contextActions: ExtensionContextActions = {
getModel: () => this.ctx.session.model,
@@ -130,6 +133,7 @@ export class ExtensionUiController {
instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
await this.ctx.session.compact(instructions, options);
},
getSystemPrompt: () => this.ctx.session.systemPrompt,
};
const commandActions: ExtensionCommandContextActions = {
getContextUsage: () => this.ctx.session.getContextUsage(),
@@ -195,7 +199,7 @@ export class ExtensionUiController {
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
if (result.editorText) {
if (result.editorText && !this.ctx.editor.getText().trim()) {
this.ctx.editor.setText(result.editorText);
}
this.ctx.showStatus("Navigated to selected point");
@@ -212,6 +216,16 @@ export class ExtensionUiController {
}
await this.ctx.executeCompaction(instructionsOrOptions, false);
},
switchSession: async sessionPath => {
const result = await this.ctx.session.switchSession(sessionPath);
if (!result) {
return { cancelled: true };
}
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
return { cancelled: false };
},
};
extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
@@ -283,6 +297,7 @@ export class ExtensionUiController {
},
getThinkingLevel: () => this.ctx.session.thinkingLevel,
setThinkingLevel: (level, persist) => this.ctx.session.setThinkingLevel(level, persist),
getCommands: () => [],
};
const contextActions: ExtensionContextActions = {
getModel: () => this.ctx.session.model,
@@ -299,6 +314,7 @@ export class ExtensionUiController {
instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
await this.ctx.session.compact(instructions, options);
},
getSystemPrompt: () => this.ctx.session.systemPrompt,
};
const commandActions: ExtensionCommandContextActions = {
getContextUsage: () => this.ctx.session.getContextUsage(),
@@ -373,7 +389,7 @@ export class ExtensionUiController {
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
if (result.editorText) {
if (result.editorText && !this.ctx.editor.getText().trim()) {
this.ctx.editor.setText(result.editorText);
}
this.ctx.showStatus("Navigated to selected point");
@@ -390,6 +406,19 @@ export class ExtensionUiController {
}
await this.ctx.executeCompaction(instructionsOrOptions, false);
},
switchSession: async sessionPath => {
if (this.ctx.isBackgrounded) {
return { cancelled: true };
}
const result = await this.ctx.session.switchSession(sessionPath);
if (!result) {
return { cancelled: true };
}
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
return { cancelled: false };
},
};
extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
@@ -418,6 +447,8 @@ export class ExtensionUiController {
setFooter: () => {},
setHeader: () => {},
setEditorComponent: () => {},
getToolsExpanded: () => false,
setToolsExpanded: () => {},
};
}
@@ -461,6 +492,7 @@ export class ExtensionUiController {
shutdown: () => {
// Signal shutdown request
},
getSystemPrompt: () => this.ctx.session.systemPrompt,
});
} catch (err) {
this.showToolError(registeredTool.definition.name, err instanceof Error ? err.message : String(err));
@@ -97,6 +97,19 @@ export class InputController {
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand());
}
for (const key of this.ctx.keybindings.getKeys("newSession")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.handleClearCommand());
}
for (const key of this.ctx.keybindings.getKeys("tree")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showTreeSelector());
}
for (const key of this.ctx.keybindings.getKeys("fork")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showUserMessageSelector());
}
for (const key of this.ctx.keybindings.getKeys("resume")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionSelector());
}
this.ctx.editor.onChange = (text: string) => {
const wasBashMode = this.ctx.isBashMode;
const wasPythonMode = this.ctx.isPythonMode;
@@ -681,10 +694,14 @@ export class InputController {
}
toggleToolOutputExpansion(): void {
this.ctx.toolOutputExpanded = !this.ctx.toolOutputExpanded;
this.setToolsExpanded(!this.ctx.toolOutputExpanded);
}
setToolsExpanded(expanded: boolean): void {
this.ctx.toolOutputExpanded = expanded;
for (const child of this.ctx.chatContainer.children) {
if (isExpandable(child)) {
child.setExpanded(this.ctx.toolOutputExpanded);
child.setExpanded(expanded);
}
}
this.ctx.ui.requestRender();
@@ -185,6 +185,10 @@ export class SelectorController {
this.ctx.updateEditorBorderColor();
break;
case "clearOnShrink":
this.ctx.ui.setClearOnShrink(value as boolean);
break;
// Settings with UI side effects
case "showImages":
for (const child of this.ctx.chatContainer.children) {
@@ -354,15 +358,6 @@ export class SelectorController {
const tree = this.ctx.sessionManager.getTree();
const realLeafId = this.ctx.sessionManager.getLeafId();
// Find the visible leaf for display (skip metadata entries like labels)
let visibleLeafId = realLeafId;
while (visibleLeafId) {
const entry = this.ctx.sessionManager.getEntry(visibleLeafId);
if (!entry) break;
if (entry.type !== "label" && entry.type !== "custom") break;
visibleLeafId = entry.parentId ?? null;
}
if (tree.length === 0) {
this.ctx.showStatus("No entries in session");
return;
@@ -371,11 +366,11 @@ export class SelectorController {
this.showSelector(done => {
const selector = new TreeSelectorComponent(
tree,
visibleLeafId,
realLeafId,
this.ctx.ui.terminal.rows,
async entryId => {
// Selecting the visible leaf is a no-op (already there)
if (entryId === visibleLeafId) {
// Selecting the current leaf is a no-op (already there)
if (entryId === realLeafId) {
done();
this.ctx.showStatus("Already at this point");
return;
@@ -458,7 +453,7 @@ export class SelectorController {
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages();
await this.ctx.reloadTodos();
if (result.editorText) {
if (result.editorText && !this.ctx.editor.getText().trim()) {
this.ctx.editor.setText(result.editorText);
}
this.ctx.showStatus("Navigated to selected point");
@@ -22,7 +22,7 @@ import { renderPromptTemplate } from "../config/prompt-templates";
import { type Settings, settings } from "../config/settings";
import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../extensibility/extensions";
import type { CompactOptions } from "../extensibility/extensions/types";
import { loadSlashCommands } from "../extensibility/slash-commands";
import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands";
import { resolvePlanUrlToPath } from "../internal-urls";
import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" };
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
@@ -173,6 +173,7 @@ export class InteractiveMode implements InteractiveModeContext {
this.mcpManager = mcpManager;
this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
this.ui.setClearOnShrink(settings.get("clearOnShrink"));
setMermaidRenderCallback(() => this.ui.requestRender());
this.chatContainer = new Container();
this.pendingMessagesContainer = new Container();
@@ -199,39 +200,10 @@ export class InteractiveMode implements InteractiveModeContext {
this.hideThinkingBlock = settings.get("hideThinkingBlock");
// Define slash commands for autocomplete
const slashCommands: SlashCommand[] = [
{ name: "settings", description: "Open settings menu" },
{ name: "plan", description: "Toggle plan mode (agent plans before executing)" },
{ name: "model", description: "Select model (opens selector UI)" },
{ name: "export", description: "Export session to HTML file" },
{ name: "dump", description: "Copy session transcript to clipboard" },
{ name: "share", description: "Share session as a secret GitHub gist" },
{ name: "browser", description: "Toggle browser headless vs visible mode" },
{ name: "copy", description: "Copy last agent message to clipboard" },
{ name: "session", description: "Show session info and stats" },
{ name: "usage", description: "Show provider usage and limits" },
{ name: "extensions", description: "Open Extension Control Center dashboard" },
{ name: "status", description: "Alias for /extensions" },
{ name: "changelog", description: "Show changelog entries" },
{ name: "hotkeys", description: "Show all keyboard shortcuts" },
{ name: "branch", description: "Create a new branch from a previous message" },
{ name: "tree", description: "Navigate session tree (switch branches)" },
{ name: "login", description: "Login with OAuth provider" },
{ name: "logout", description: "Logout from OAuth provider" },
{ name: "new", description: "Start a new session" },
{ name: "fork", description: "Duplicate current session into a new session" },
{ name: "compact", description: "Manually compact the session context" },
{ name: "handoff", description: "Hand off the session context to a new session" },
{ name: "background", description: "Detach UI and continue running in background" },
{ name: "bg", description: "Alias for /background" },
{ name: "resume", description: "Resume a different session" },
{ name: "debug", description: "Write debug log (TUI state and messages)" },
{ name: "exit", description: "Exit the application" },
];
// Convert hook commands to SlashCommand format
const hookCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map(cmd => ({
const builtinCommandNames = new Set(BUILTIN_SLASH_COMMANDS.map(c => c.name));
const hookCommands: SlashCommand[] = (
this.session.extensionRunner?.getRegisteredCommands(builtinCommandNames) ?? []
).map(cmd => ({
name: cmd.name,
description: cmd.description ?? "(hook command)",
getArgumentCompletions: cmd.getArgumentCompletions,
@@ -254,7 +226,7 @@ export class InteractiveMode implements InteractiveModeContext {
}
// Store pending commands for init() where file commands are loaded async
this.pendingSlashCommands = [...slashCommands, ...hookCommands, ...customCommands, ...skillCommandList];
this.pendingSlashCommands = [...BUILTIN_SLASH_COMMANDS, ...hookCommands, ...customCommands, ...skillCommandList];
this.uiHelpers = new UiHelpers(this);
this.extensionUiController = new ExtensionUiController(this);
@@ -738,6 +710,10 @@ export class InteractiveMode implements InteractiveModeContext {
await this.ui.waitForRender();
}
// Drain any in-flight Kitty key release events before stopping.
// This prevents escape sequences from leaking to the parent shell over slow SSH.
await this.ui.terminal.drainInput(1000);
this.stop();
await postmortem.quit(0);
}
@@ -1011,6 +987,10 @@ export class InteractiveMode implements InteractiveModeContext {
this.inputController.toggleToolOutputExpansion();
}
setToolsExpanded(expanded: boolean): void {
this.inputController.setToolsExpanded(expanded);
}
toggleThinkingBlockVisibility(): void {
this.inputController.toggleThinkingBlockVisibility();
}
@@ -63,6 +63,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllToolNames(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
getCommands: () => [],
setModel: async model => {
const key = await session.modelRegistry.getApiKey(model);
if (!key) return false;
@@ -80,6 +81,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
hasPendingMessages: () => session.queuedMessageCount > 0,
shutdown: () => {},
getContextUsage: () => session.getContextUsage(),
getSystemPrompt: () => session.systemPrompt,
compact: async instructionsOrOptions => {
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
const options =
@@ -108,6 +110,10 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
const result = await session.navigateTree(targetId, { summarize: options?.summarize });
return { cancelled: result.cancelled };
},
switchSession: async sessionPath => {
const success = await session.switchSession(sessionPath);
return { cancelled: !success };
},
compact: async instructionsOrOptions => {
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
const options =
@@ -189,15 +189,15 @@ export class RpcClient {
/**
* Queue a steering message to interrupt the agent mid-run.
*/
async steer(message: string): Promise<void> {
await this.send({ type: "steer", message });
async steer(message: string, images?: ImageContent[]): Promise<void> {
await this.send({ type: "steer", message, images });
}
/**
* Queue a follow-up message to be processed after the agent finishes.
*/
async followUp(message: string): Promise<void> {
await this.send({ type: "follow_up", message });
async followUp(message: string, images?: ImageContent[]): Promise<void> {
await this.send({ type: "follow_up", message, images });
}
/**
@@ -286,6 +286,15 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return Promise.resolve({ success: false, error: "Theme switching not supported in RPC mode" });
}
getToolsExpanded() {
// Tool expansion not supported in RPC mode - no TUI
return false;
}
setToolsExpanded(_expanded: boolean) {
// Tool expansion not supported in RPC mode - no TUI
}
setEditorComponent(): void {
// Custom editor components not supported in RPC mode
}
@@ -316,6 +325,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllToolNames(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
getCommands: () => [],
setModel: async model => {
const key = await session.modelRegistry.getApiKey(model);
if (!key) return false;
@@ -335,6 +345,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
shutdownState.requested = true;
},
getContextUsage: () => session.getContextUsage(),
getSystemPrompt: () => session.systemPrompt,
compact: async instructionsOrOptions => {
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
const options =
@@ -364,6 +375,10 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
const result = await session.navigateTree(targetId, { summarize: options?.summarize });
return { cancelled: result.cancelled };
},
switchSession: async sessionPath => {
const success = await session.switchSession(sessionPath);
return { cancelled: !success };
},
compact: async instructionsOrOptions => {
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
const options =
@@ -412,12 +427,12 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
}
case "steer": {
await session.steer(command.message);
await session.steer(command.message, command.images);
return success(id, "steer");
}
case "follow_up": {
await session.followUp(command.message);
await session.followUp(command.message, command.images);
return success(id, "follow_up");
}
@@ -17,8 +17,8 @@ import type { CompactionResult } from "../../session/compaction";
export type RpcCommand =
// Prompting
| { id?: string; type: "prompt"; message: string; images?: ImageContent[]; streamingBehavior?: "steer" | "followUp" }
| { id?: string; type: "steer"; message: string }
| { id?: string; type: "follow_up"; message: string }
| { id?: string; type: "steer"; message: string; images?: ImageContent[] }
| { id?: string; type: "follow_up"; message: string; images?: ImageContent[] }
| { id?: string; type: "abort" }
| { id?: string; type: "new_session"; parentSession?: string }
+1
View File
@@ -176,6 +176,7 @@ export interface InteractiveModeContext {
cycleThinkingLevel(): void;
cycleRoleModel(options?: { temporary?: boolean }): Promise<void>;
toggleToolOutputExpansion(): void;
setToolsExpanded(expanded: boolean): void;
toggleThinkingBlockVisibility(): void;
openExternalEditor(): void;
registerExtensionShortcuts(): void;
@@ -221,6 +221,7 @@ Main branch: {{git.mainBranch}}
{{#if skills.length}}
<skills>
Scan descriptions vs domain. Skill covers output? Read `skill://<name>` first.
When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.
{{#list skills join="\n"}}
<skill name="{{name}}">
+13 -9
View File
@@ -387,7 +387,7 @@ function customToolToDefinition(tool: CustomTool): ToolDefinition {
label: tool.label,
description: tool.description,
parameters: tool.parameters,
execute: (toolCallId, params, onUpdate, ctx, signal) =>
execute: (toolCallId, params, signal, onUpdate, ctx) =>
tool.execute(toolCallId, params, onUpdate, createCustomToolContext(ctx), signal),
onSession: tool.onSession ? (event, ctx) => tool.onSession?.(event, createCustomToolContext(ctx)) : undefined,
renderCall: tool.renderCall,
@@ -506,6 +506,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const existingSession = sessionManager.buildSessionContext();
time("loadSession");
const hasExistingSession = existingSession.messages.length > 0;
const hasThinkingEntry = sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
const hasExplicitModel = options.model !== undefined;
let model = options.model;
@@ -563,7 +564,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// If session has data, restore thinking level from it
if (thinkingLevel === undefined && hasExistingSession) {
thinkingLevel = existingSession.thinkingLevel as ThinkingLevel;
thinkingLevel = hasThinkingEntry
? (existingSession.thinkingLevel as ThinkingLevel)
: ((settingsInstance.get("defaultThinkingLevel") ?? "off") as ThinkingLevel);
}
// Fall back to settings default
@@ -1016,14 +1019,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
thinkingBudgets: settingsInstance.getGroup("thinkingBudgets"),
kimiApiFormat: settingsInstance.get("providers.kimiApiFormat") ?? "anthropic",
getToolContext: tc => toolContextStore.getContext(tc),
getApiKey: async () => {
const currentModel = agent.state.model;
if (!currentModel) {
throw new Error("No model selected");
}
const key = await modelRegistry.getApiKey(currentModel, sessionId);
getApiKey: async provider => {
// Use the provider argument from the in-flight request;
// agent.state.model may already be switched mid-turn.
const key = await modelRegistry.getApiKeyForProvider(provider, sessionId);
if (!key) {
throw new Error(`No API key found for provider "${currentModel.provider}"`);
throw new Error(`No API key found for provider "${provider}"`);
}
return key;
},
@@ -1036,6 +1037,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Restore messages if session has existing data
if (hasExistingSession) {
agent.replaceMessages(existingSession.messages);
if (!hasThinkingEntry) {
sessionManager.appendThinkingLevelChange(thinkingLevel);
}
} else {
// Save initial model and thinking level for new sessions so they can be restored on resume
if (model) {
@@ -237,6 +237,8 @@ const noOpUIContext: ExtensionUIContext = {
setFooter: () => {},
setHeader: () => {},
setEditorComponent: () => {},
getToolsExpanded: () => false,
setToolsExpanded: () => {},
};
async function cleanupSshResources(): Promise<void> {
@@ -906,6 +908,11 @@ export class AgentSession {
return this.agent.state.isStreaming || this._promptInFlight;
}
/** Current effective system prompt (includes any per-turn extension modifications) */
get systemPrompt(): string {
return this.agent.state.systemPrompt;
}
/** Current retry attempt (0 if not retrying) */
get retryAttempt(): number {
return this._retryAttempt;
@@ -1426,6 +1433,11 @@ export class AgentSession {
instructionsOrOptions && typeof instructionsOrOptions === "object" ? instructionsOrOptions : undefined;
await this.compact(instructions, options);
},
switchSession: async sessionPath => {
const success = await this.switchSession(sessionPath);
return { cancelled: !success };
},
getSystemPrompt: () => this.systemPrompt,
};
}
@@ -1477,25 +1489,25 @@ export class AgentSession {
/**
* Queue a steering message to interrupt the agent mid-run.
*/
async steer(text: string): Promise<void> {
async steer(text: string, images?: ImageContent[]): Promise<void> {
if (text.startsWith("/")) {
this._throwIfExtensionCommand(text);
}
const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
await this._queueSteer(expandedText);
await this._queueSteer(expandedText, images);
}
/**
* Queue a follow-up message to process after the agent would otherwise stop.
*/
async followUp(text: string): Promise<void> {
async followUp(text: string, images?: ImageContent[]): Promise<void> {
if (text.startsWith("/")) {
this._throwIfExtensionCommand(text);
}
const expandedText = expandPromptTemplate(text, [...this._promptTemplates]);
await this._queueFollowUp(expandedText);
await this._queueFollowUp(expandedText, images);
}
/**
@@ -1733,6 +1745,9 @@ export class AgentSession {
this._steeringMessages = [];
this._followUpMessages = [];
this._pendingNextTurnMessages = [];
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
this._todoReminderCount = 0;
this._planReferenceSent = false;
this._reconnectToAgent();
@@ -1934,22 +1949,39 @@ export class AgentSession {
return { model: next.model, thinkingLevel: this.thinkingLevel, role: next.role };
}
private async _getScopedModelsWithApiKey(): Promise<Array<{ model: Model; thinkingLevel: ThinkingLevel }>> {
const apiKeysByProvider = new Map<string, string | undefined>();
const result: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];
for (const scoped of this._scopedModels) {
const provider = scoped.model.provider;
let apiKey: string | undefined;
if (apiKeysByProvider.has(provider)) {
apiKey = apiKeysByProvider.get(provider);
} else {
apiKey = await this._modelRegistry.getApiKeyForProvider(provider, this.sessionId);
apiKeysByProvider.set(provider, apiKey);
}
if (apiKey) {
result.push(scoped);
}
}
return result;
}
private async _cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
if (this._scopedModels.length <= 1) return undefined;
const scopedModels = await this._getScopedModelsWithApiKey();
if (scopedModels.length <= 1) return undefined;
const currentModel = this.model;
let currentIndex = this._scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel));
let currentIndex = scopedModels.findIndex(sm => modelsAreEqual(sm.model, currentModel));
if (currentIndex === -1) currentIndex = 0;
const len = this._scopedModels.length;
const len = scopedModels.length;
const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
const next = this._scopedModels[nextIndex];
// Validate API key
const apiKey = await this._modelRegistry.getApiKey(next.model, this.sessionId);
if (!apiKey) {
throw new Error(`No API key for ${next.model.provider}/${next.model.id}`);
}
const next = scopedModels[nextIndex];
// Apply model
this.agent.setModel(next.model);
@@ -2005,15 +2037,22 @@ export class AgentSession {
/**
* Set thinking level.
* Clamps to model capabilities based on available thinking levels.
* Saves to session, with optional persistence to settings.
* Saves to session and settings only if the level actually changes.
*/
setThinkingLevel(level: ThinkingLevel, persist: boolean = false): void {
const availableLevels = this.getAvailableThinkingLevels();
const effectiveLevel = availableLevels.includes(level) ? level : this._clampThinkingLevel(level, availableLevels);
// Only persist if actually changing
const isChanging = effectiveLevel !== this.agent.state.thinkingLevel;
this.agent.setThinkingLevel(effectiveLevel);
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
if (persist) {
this.settings.set("defaultThinkingLevel", effectiveLevel);
if (isChanging) {
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
if (persist) {
this.settings.set("defaultThinkingLevel", effectiveLevel);
}
}
}
@@ -2903,8 +2942,8 @@ Be thorough - include exact file paths, function names, error messages, and tech
}
private _isRetryableErrorMessage(errorMessage: string): boolean {
// Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed
return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|fetch failed/i.test(
// Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error, fetch failed, retry delay exceeded
return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|fetch failed|retry delay/i.test(
errorMessage,
);
}
@@ -3354,9 +3393,19 @@ Be thorough - include exact file paths, function names, error messages, and tech
}
}
// Restore thinking level if saved (setThinkingLevel clamps to model capabilities)
if (sessionContext.thinkingLevel) {
const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
const defaultThinkingLevel = (this.settings.get("defaultThinkingLevel") ?? "off") as ThinkingLevel;
if (hasThinkingEntry) {
// Restore thinking level if saved (setThinkingLevel clamps to model capabilities)
this.setThinkingLevel(sessionContext.thinkingLevel as ThinkingLevel);
} else {
const availableLevels = this.getAvailableThinkingLevels();
const effectiveLevel = availableLevels.includes(defaultThinkingLevel)
? defaultThinkingLevel
: this._clampThinkingLevel(defaultThinkingLevel, availableLevels);
this.agent.setThinkingLevel(effectiveLevel);
this.sessionManager.appendThinkingLevelChange(effectiveLevel);
}
this._reconnectToAgent();
@@ -3404,7 +3453,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
await this.sessionManager.flush();
if (!selectedEntry.parentId) {
this.sessionManager.newSession();
this.sessionManager.newSession({ parentSession: previousSessionFile });
} else {
this.sessionManager.createBranchedSession(selectedEntry.parentId);
}
@@ -34,6 +34,7 @@ import {
} from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import { getAgentDbPath } from "../config";
import { resolveConfigValue } from "../config/resolve-config-value";
import { AgentStorage } from "./agent-storage";
export type ApiKeyCredential = {
@@ -1294,7 +1295,7 @@ export class AuthStorage {
const apiKeySelection = this.selectCredentialByType(provider, "api_key", sessionId);
if (apiKeySelection) {
this.recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
return apiKeySelection.credential.key;
return resolveConfigValue(apiKeySelection.credential.key);
}
const oauthKey = await this.resolveOAuthApiKey(provider, sessionId, options);
@@ -184,6 +184,8 @@ export interface SessionInfo {
/** Working directory where the session was started. Empty string for old sessions. */
cwd: string;
title?: string;
/** Path to the parent session (if this session was forked). */
parentSessionPath?: string;
created: Date;
modified: Date;
messageCount: number;
@@ -933,6 +935,7 @@ async function collectSessionsFromFiles(files: string[], storage: SessionStorage
id: header.id,
cwd: typeof header.cwd === "string" ? header.cwd : "",
title: header.title ?? shortSummary,
parentSessionPath: (header as SessionHeader).parentSession,
created: new Date(header.timestamp),
modified: stats.mtime,
messageCount,
@@ -1294,7 +1297,11 @@ export class SessionManager {
if (this.persistError) throw this.persistError;
const hasAssistant = this.fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
if (!hasAssistant && !this.flushed) return;
if (!hasAssistant) {
// Mark as not flushed so when assistant arrives, all entries get written
this.flushed = false;
return;
}
if (!this.flushed) {
this.flushed = true;
@@ -1776,6 +1783,7 @@ export class SessionManager {
* Returns the new session file path, or undefined if not persisting.
*/
createBranchedSession(leafId: string): string | undefined {
const previousSessionFile = this.sessionFile;
const branchPath = this.getBranch(leafId);
if (branchPath.length === 0) {
throw new Error(`Entry ${leafId} not found`);
@@ -1795,7 +1803,7 @@ export class SessionManager {
id: newSessionId,
timestamp,
cwd: this.cwd,
parentSession: this.persist ? this.sessionFile : undefined,
parentSession: this.persist ? previousSessionFile : undefined,
};
// Collect labels for entries in the path
@@ -1834,6 +1842,8 @@ export class SessionManager {
this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
this.sessionId = newSessionId;
this.sessionFile = newSessionFile;
this.flushed = true;
this._buildIndex();
return newSessionFile;
}
@@ -940,6 +940,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
getActiveTools: () => session.getActiveToolNames(),
getAllTools: () => session.getAllToolNames(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
getCommands: () => [],
setModel: async model => {
const key = await session.modelRegistry.getApiKey(model);
if (!key) return false;
@@ -956,6 +957,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
hasPendingMessages: () => session.queuedMessageCount > 0,
shutdown: () => {},
getContextUsage: () => session.getContextUsage(),
getSystemPrompt: () => session.systemPrompt,
compact: async instructionsOrOptions => {
const instructions = typeof instructionsOrOptions === "string" ? instructionsOrOptions : undefined;
const options =
+3 -1
View File
@@ -2,7 +2,7 @@ import * as path from "node:path";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { Type } from "@sinclair/typebox";
import { type Static, Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "../config/prompt-templates";
import { type BashExecutorOptions, executeBash } from "../exec/bash-executor";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -31,6 +31,8 @@ const bashSchema = Type.Object({
tail: Type.Optional(Type.Number({ description: "Return only last N lines of output" })),
});
export type BashToolInput = Static<typeof bashSchema>;
export interface BashToolDetails {
meta?: OutputMeta;
}
+2
View File
@@ -27,6 +27,8 @@ const findSchema = Type.Object({
limit: Type.Optional(Type.Number({ description: "Max results (default: 1000)" })),
});
export type FindToolInput = Static<typeof findSchema>;
const DEFAULT_LIMIT = 1000;
const GLOB_TIMEOUT_MS = 5000;
+2
View File
@@ -32,6 +32,8 @@ const grepSchema = Type.Object({
offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit (default: 0)" })),
});
export type GrepToolInput = Static<typeof grepSchema>;
const DEFAULT_MATCH_LIMIT = 100;
export interface GrepToolDetails {
+5 -5
View File
@@ -67,17 +67,17 @@ export {
webSearchLinkedinTool,
} from "../web/search";
export { AskTool, type AskToolDetails } from "./ask";
export { BashTool, type BashToolDetails, type BashToolOptions } from "./bash";
export { BashTool, type BashToolDetails, type BashToolInput, type BashToolOptions } from "./bash";
export { BrowserTool, type BrowserToolDetails } from "./browser";
export { CalculatorTool, type CalculatorToolDetails } from "./calculator";
export { type ExitPlanModeDetails, ExitPlanModeTool } from "./exit-plan-mode";
export { FetchTool, type FetchToolDetails } from "./fetch";
export { type FindOperations, FindTool, type FindToolDetails, type FindToolOptions } from "./find";
export { type FindOperations, FindTool, type FindToolDetails, type FindToolInput, type FindToolOptions } from "./find";
export { setPreferredImageProvider } from "./gemini-image";
export { type GrepOperations, GrepTool, type GrepToolDetails, type GrepToolOptions } from "./grep";
export { type GrepOperations, GrepTool, type GrepToolDetails, type GrepToolInput, type GrepToolOptions } from "./grep";
export { NotebookTool, type NotebookToolDetails } from "./notebook";
export { PythonTool, type PythonToolDetails, type PythonToolOptions } from "./python";
export { ReadTool, type ReadToolDetails } from "./read";
export { ReadTool, type ReadToolDetails, type ReadToolInput } from "./read";
export { reportFindingTool, type SubmitReviewDetails } from "./review";
export { loadSshTool, type SSHToolDetails, SshTool } from "./ssh";
export { SubmitResultTool } from "./submit-result";
@@ -92,7 +92,7 @@ export {
truncateLine,
truncateTail,
} from "./truncate";
export { WriteTool, type WriteToolDetails } from "./write";
export { WriteTool, type WriteToolDetails, type WriteToolInput } from "./write";
/** Tool type (AgentTool from pi-ai) */
export type Tool = AgentTool<any, any, any>;
@@ -33,8 +33,12 @@ function fileExists(filePath: string): boolean {
}
}
function normalizeAtPrefix(filePath: string): string {
return filePath.startsWith("@") ? filePath.slice(1) : filePath;
}
export function expandPath(filePath: string): string {
const normalized = normalizeUnicodeSpaces(filePath);
const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath));
if (normalized === "~") {
return os.homedir();
}
+3 -1
View File
@@ -7,7 +7,7 @@ import { FileType, glob } from "@oh-my-pi/pi-natives";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { ptree, untilAborted } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import { type Static, Type } from "@sinclair/typebox";
import { CONFIG_DIR_NAME } from "../config";
import { renderPromptTemplate } from "../config/prompt-templates";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -516,6 +516,8 @@ const readSchema = Type.Object({
lines: Type.Optional(Type.Boolean({ description: "Prepend line numbers to output (default: false)" })),
});
export type ReadToolInput = Static<typeof readSchema>;
export interface ReadToolDetails {
truncation?: TruncationResult;
isDirectory?: boolean;
+3 -1
View File
@@ -8,7 +8,7 @@ import type {
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { untilAborted } from "@oh-my-pi/pi-utils";
import { Type } from "@sinclair/typebox";
import { type Static, Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "../config/prompt-templates";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp";
@@ -33,6 +33,8 @@ const writeSchema = Type.Object({
content: Type.String({ description: "Content to write to the file" }),
});
export type WriteToolInput = Static<typeof writeSchema>;
/** Details returned by the write tool for TUI rendering */
export interface WriteToolDetails {
diagnostics?: FileDiagnosticsResult;
@@ -269,6 +269,12 @@ async function installPythonPackage(pkg: string, signal?: AbortSignal): Promise<
return false;
}
// Termux package names for tools
const TERMUX_PACKAGES: Partial<Record<ToolName, string>> = {
sd: "sd",
sg: "ast-grep",
};
// Ensure a tool is available, downloading if necessary
// Returns the path to the tool, or null if unavailable
type EnsureToolOptions = {
@@ -284,6 +290,16 @@ export async function ensureTool(tool: ToolName, silentOrOptions?: EnsureToolOpt
return existingPath;
}
// On Android/Termux, Linux binaries don't work due to Bionic libc incompatibility.
// Users must install via pkg.
if (os.platform() === "android") {
const pkgName = TERMUX_PACKAGES[tool] ?? tool;
if (!silent) {
logger.warn(`${TOOLS[tool]?.name ?? tool} not found. Install with: pkg install ${pkgName}`);
}
return undefined;
}
// Handle Python tools
const pythonConfig = PYTHON_TOOLS[tool];
if (pythonConfig) {
+11
View File
@@ -1,6 +1,17 @@
# Changelog
## [Unreleased]
### Added
- OSC 52 fallback for clipboard operations over SSH/mosh connections
- Termux support with `termux-clipboard-set` integration
- Headless environment guards to prevent clipboard errors when no display server is available
- Async clipboard API with improved error handling and fallback strategies
### Changed
- Clipboard functions now return promises for better async handling
- Native clipboard operations are now best-effort with graceful degradation
## [11.0.0] - 2026-02-05
### Removed
+60 -1
View File
@@ -1,9 +1,68 @@
/**
* Clipboard helpers backed by native arboard bindings.
*
* Adds OSC 52 fallback for SSH/mosh, Termux support, and headless guards
* on top of the native arboard layer.
*/
import { execSync } from "node:child_process";
import { native } from "../native";
import type { ClipboardImage } from "./types";
export type { ClipboardImage } from "./types";
export const { copyToClipboard, readImageFromClipboard } = native;
/** Whether a display server is available on Linux. */
const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
/**
* Copy text to the system clipboard.
*
* Always emits OSC 52 first (works over SSH/mosh, harmless locally),
* then attempts native clipboard copy as best-effort for local sessions.
* On Termux, tries `termux-clipboard-set` before native.
*
* @param text - UTF-8 text to place on the clipboard.
*/
export async function copyToClipboard(text: string): Promise<void> {
// Always emit OSC 52 — works over SSH/mosh, harmless locally
const encoded = Buffer.from(text).toString("base64");
process.stdout.write(`\x1b]52;c;${encoded}\x07`);
// Also try native tools (best effort for local sessions)
try {
if (process.env.TERMUX_VERSION) {
try {
execSync("termux-clipboard-set", { input: text, timeout: 5000 });
return;
} catch {
// Fall through to native
}
}
await native.copyToClipboard(text);
} catch {
// Ignore — OSC 52 already emitted as fallback
}
}
/**
* Read an image from the system clipboard.
*
* Returns null on Termux (no image clipboard support) or when no display
* server is available (headless/SSH without forwarding).
*
* @returns PNG payload or null when no image is available.
*/
export async function readImageFromClipboard(): Promise<ClipboardImage | null> {
if (process.env.TERMUX_VERSION) {
return null;
}
if (!hasDisplay) {
return null;
}
return native.readImageFromClipboard();
}
+29
View File
@@ -1,6 +1,35 @@
# Changelog
## [Unreleased]
### Added
- Added sticky column behavior for vertical cursor movement, preserving target column when navigating through lines of varying lengths
- Added `drainInput()` method to Terminal interface to prevent Kitty key release events from leaking to parent shell over slow SSH connections
- Added `setClearOnShrink()` method to control whether full re-render occurs when content shrinks below working area
- Added support for hidden paths (e.g., `.pi`, `.github`) in autocomplete while excluding `.git` directories
### Changed
- Changed autocomplete to include hidden paths but filter out `.git` and its contents
- Changed Input component to properly handle surrogate pairs in Unicode text, preventing cursor display corruption with emoji and multi-byte characters
- Changed Editor to use `setCursorCol()` for all cursor column updates, enabling sticky column tracking
- Changed Editor's vertical navigation to implement sticky column logic via `moveToVisualLine()` and `computeVerticalMoveColumn()`
- Changed Editor's Enter key handling to extract submit logic into `submitValue()` method for better code organization
- Changed SettingsList to truncate long lines to viewport width, preventing text overflow
- Changed Terminal's `stop()` method to drain stdin before restoring raw mode, fixing race condition where Ctrl+D could close parent shell over SSH
- Changed TUI rendering to add `clearOnShrink` option (controlled by `PI_CLEAR_ON_SHRINK` env var) for reducing redraws on slower terminals
- Changed TUI rendering to detect when extra lines exceed viewport height and trigger full re-render instead of incremental updates
### Fixed
- Fixed cursor position corruption in Input component when displaying text with emoji and combining characters
- Fixed `.git` directory appearing in autocomplete suggestions
- Fixed race condition where Kitty key release events could leak to parent shell after TUI exit over slow SSH connections
- Fixed Editor's word movement (Ctrl+Left/Right) to properly reset sticky column for subsequent vertical navigation
- Fixed Editor's undo operation to reset sticky column state when restoring cursor position
- Fixed Editor's right arrow key at end of last line to set sticky column for subsequent up/down navigation
- Fixed TUI rendering to correctly detect viewport changes and avoid false full-redraws after content shrinks
- Fixed Kitty protocol key parsing to prefer codepoint over base layout for Latin letters and symbols, fixing keyboard layout issues (e.g., Dvorak)
## [11.0.0] - 2026-02-05
+7 -2
View File
@@ -627,11 +627,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
query,
path: this.basePath,
maxResults: 100,
hidden: false,
hidden: true,
gitignore: true,
});
const scoredEntries = result.matches
const filteredMatches = result.matches.filter(entry => {
const p = entry.path.endsWith("/") ? entry.path.slice(0, -1) : entry.path;
return p !== ".git" && !p.startsWith(".git/") && !p.includes("/.git/");
});
const scoredEntries = filteredMatches
.map(entry => ({
path: entry.path,
isDirectory: entry.isDirectory,
+166 -91
View File
@@ -306,6 +306,9 @@ export class Editor implements Component, Focusable {
// Character jump mode
private jumpMode: "forward" | "backward" | null = null;
// Preferred visual column for vertical cursor movement (sticky column)
private preferredVisualCol: number | null = null;
// Border color (can be changed dynamically)
public borderColor: (str: string) => string;
@@ -455,7 +458,7 @@ export class Editor implements Component, Focusable {
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
this.state.lines = lines.length === 0 ? [""] : lines;
this.state.cursorLine = this.state.lines.length - 1;
this.state.cursorCol = this.state.lines[this.state.cursorLine]?.length || 0;
this.setCursorCol(this.state.lines[this.state.cursorLine]?.length || 0);
if (this.onChange) {
this.onChange(this.getText());
@@ -768,7 +771,7 @@ export class Editor implements Component, Focusable {
this.state.lines = result.lines;
this.state.cursorLine = result.cursorLine;
this.state.cursorCol = result.cursorCol;
this.setCursorCol(result.cursorCol);
this.cancelAutocomplete();
@@ -803,7 +806,7 @@ export class Editor implements Component, Focusable {
this.state.lines = result.lines;
this.state.cursorLine = result.cursorLine;
this.state.cursorCol = result.cursorCol;
this.setCursorCol(result.cursorCol);
}
this.cancelAutocomplete();
}
@@ -823,7 +826,7 @@ export class Editor implements Component, Focusable {
this.state.lines = result.lines;
this.state.cursorLine = result.cursorLine;
this.state.cursorCol = result.cursorCol;
this.setCursorCol(result.cursorCol);
this.cancelAutocomplete();
@@ -885,7 +888,7 @@ export class Editor implements Component, Focusable {
this.addNewLine();
}
}
// New line shortcuts (but not plain LF/CR which should be submit)
// New line
else if (
(data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
data === "\x1b[13;5u" || // Ctrl+Enter (Kitty protocol)
@@ -896,7 +899,11 @@ export class Editor implements Component, Focusable {
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
(data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
) {
// Modifier + Enter = new line
if (this.shouldSubmitOnBackslashEnter(data, kb)) {
this.handleBackspace();
this.submitValue();
return;
}
this.addNewLine();
}
// Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits)
@@ -906,46 +913,7 @@ export class Editor implements Component, Focusable {
return;
}
// Workaround for terminals without Shift+Enter support:
// If char before cursor is \, delete it and insert newline instead of submitting.
const currentLine = this.state.lines[this.state.cursorLine] || "";
if (this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\") {
this.handleBackspace();
this.addNewLine();
return;
}
this.resetKillSequence();
// Get text and substitute paste markers with actual content
let result = this.state.lines.join("\n").trim();
// Replace all [paste #N +xxx lines] or [paste #N xxx chars] markers with actual paste content
for (const [pasteId, pasteContent] of this.pastes) {
// Match formats: [paste #N], [paste #N +xxx lines], or [paste #N xxx chars]
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
result = result.replace(markerRegex, pasteContent);
}
// Reset editor and clear pastes
this.state = {
lines: [""],
cursorLine: 0,
cursorCol: 0,
};
this.clearUndoStack();
this.pastes.clear();
this.pasteCounter = 0;
this.historyIndex = -1; // Exit history browsing mode
// Notify that editor is now empty
if (this.onChange) {
this.onChange("");
}
if (this.onSubmit) {
this.onSubmit(result);
}
this.submitValue();
}
// Backspace (including Shift+Backspace)
else if (matchesKey(data, "backspace") || matchesKey(data, "shift+backspace")) {
@@ -1155,7 +1123,7 @@ export class Editor implements Component, Focusable {
const after = line.slice(this.state.cursorCol);
this.state.lines[this.state.cursorLine] = before + text + after;
this.state.cursorCol += text.length;
this.setCursorCol(this.state.cursorCol + text.length);
if (this.onChange) {
this.onChange(this.getText());
@@ -1174,7 +1142,7 @@ export class Editor implements Component, Focusable {
const after = line.slice(this.state.cursorCol);
this.state.lines[this.state.cursorLine] = before + char + after;
this.state.cursorCol += char.length; // Fix: increment by the length of the inserted string
this.setCursorCol(this.state.cursorCol + char.length);
if (this.onChange) {
this.onChange(this.getText());
@@ -1292,13 +1260,44 @@ export class Editor implements Component, Focusable {
// Move cursor to start of new line
this.state.cursorLine++;
this.state.cursorCol = 0;
this.setCursorCol(0);
if (this.onChange) {
this.onChange(this.getText());
}
}
private shouldSubmitOnBackslashEnter(data: string, kb: ReturnType<typeof getEditorKeybindings>): boolean {
if (this.disableSubmit) return false;
if (!matchesKey(data, "enter")) return false;
const submitKeys = kb.getKeys("submit");
const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
if (!hasShiftEnter) return false;
const currentLine = this.state.lines[this.state.cursorLine] || "";
return this.state.cursorCol > 0 && currentLine[this.state.cursorCol - 1] === "\\";
}
private submitValue(): void {
this.resetKillSequence();
let result = this.state.lines.join("\n").trim();
for (const [pasteId, pasteContent] of this.pastes) {
const markerRegex = new RegExp(`\\[paste #${pasteId}( (\\+\\d+ lines|\\d+ chars))?\\]`, "g");
result = result.replace(markerRegex, pasteContent);
}
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
this.pastes.clear();
this.pasteCounter = 0;
this.historyIndex = -1;
this.scrollOffset = 0;
this.undoStack.length = 0;
if (this.onChange) this.onChange("");
if (this.onSubmit) this.onSubmit(result);
}
private handleBackspace(): void {
this.historyIndex = -1; // Exit history browsing mode
this.resetKillSequence();
@@ -1318,7 +1317,7 @@ export class Editor implements Component, Focusable {
const after = line.slice(this.state.cursorCol);
this.state.lines[this.state.cursorLine] = before + after;
this.state.cursorCol -= graphemeLength;
this.setCursorCol(this.state.cursorCol - graphemeLength);
} else if (this.state.cursorLine > 0) {
// Merge with previous line
const currentLine = this.state.lines[this.state.cursorLine] || "";
@@ -1328,7 +1327,7 @@ export class Editor implements Component, Focusable {
this.state.lines.splice(this.state.cursorLine, 1);
this.state.cursorLine--;
this.state.cursorCol = previousLine.length;
this.setCursorCol(previousLine.length);
}
if (this.onChange) {
@@ -1353,15 +1352,96 @@ export class Editor implements Component, Focusable {
}
}
/**
* Set cursor column and clear preferredVisualCol.
* Use this for all non-vertical cursor movements to reset sticky column behavior.
*/
private setCursorCol(col: number): void {
this.state.cursorCol = col;
this.preferredVisualCol = null;
}
/**
* Move cursor to a target visual line, applying sticky column logic.
* Shared by moveCursor() and pageScroll().
*/
private moveToVisualLine(
visualLines: Array<{ logicalLine: number; startCol: number; length: number }>,
currentVisualLine: number,
targetVisualLine: number,
): void {
const currentVL = visualLines[currentVisualLine];
const targetVL = visualLines[targetVisualLine];
if (currentVL && targetVL) {
const currentVisualCol = this.state.cursorCol - currentVL.startCol;
// For non-last segments, clamp to length-1 to stay within the segment
const isLastSourceSegment =
currentVisualLine === visualLines.length - 1 ||
visualLines[currentVisualLine + 1]?.logicalLine !== currentVL.logicalLine;
const sourceMaxVisualCol = isLastSourceSegment ? currentVL.length : Math.max(0, currentVL.length - 1);
const isLastTargetSegment =
targetVisualLine === visualLines.length - 1 ||
visualLines[targetVisualLine + 1]?.logicalLine !== targetVL.logicalLine;
const targetMaxVisualCol = isLastTargetSegment ? targetVL.length : Math.max(0, targetVL.length - 1);
const moveToVisualCol = this.computeVerticalMoveColumn(
currentVisualCol,
sourceMaxVisualCol,
targetMaxVisualCol,
);
// Set cursor position
this.state.cursorLine = targetVL.logicalLine;
const targetCol = targetVL.startCol + moveToVisualCol;
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
}
}
/**
* Compute the target visual column for vertical cursor movement.
* Implements the sticky column decision table.
*/
private computeVerticalMoveColumn(
currentVisualCol: number,
sourceMaxVisualCol: number,
targetMaxVisualCol: number,
): number {
const hasPreferred = this.preferredVisualCol !== null;
const cursorInMiddle = currentVisualCol < sourceMaxVisualCol;
const targetTooShort = targetMaxVisualCol < currentVisualCol;
if (!hasPreferred || cursorInMiddle) {
if (targetTooShort) {
this.preferredVisualCol = currentVisualCol;
return targetMaxVisualCol;
}
this.preferredVisualCol = null;
return currentVisualCol;
}
const targetCantFitPreferred = targetMaxVisualCol < this.preferredVisualCol!;
if (targetTooShort || targetCantFitPreferred) {
return targetMaxVisualCol;
}
const result = this.preferredVisualCol!;
this.preferredVisualCol = null;
return result;
}
private moveToLineStart(): void {
this.resetKillSequence();
this.state.cursorCol = 0;
this.setCursorCol(0);
}
private moveToLineEnd(): void {
this.resetKillSequence();
const currentLine = this.state.lines[this.state.cursorLine] || "";
this.state.cursorCol = currentLine.length;
this.setCursorCol(currentLine.length);
}
private resetKillSequence(): void {
@@ -1412,6 +1492,7 @@ export class Editor implements Component, Focusable {
this.historyIndex = -1;
this.resetKillSequence();
this.preferredVisualCol = null;
this.state = {
lines: [...snapshot.lines],
cursorLine: snapshot.cursorLine,
@@ -1462,7 +1543,7 @@ export class Editor implements Component, Focusable {
const before = line.slice(0, this.state.cursorCol);
const after = line.slice(this.state.cursorCol);
this.state.lines[this.state.cursorLine] = before + normalized + after;
this.state.cursorCol += normalized.length;
this.setCursorCol(this.state.cursorCol + normalized.length);
} else {
const currentLine = this.state.lines[this.state.cursorLine] || "";
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
@@ -1485,7 +1566,7 @@ export class Editor implements Component, Focusable {
this.state.lines = newLines;
this.state.cursorLine += lines.length - 1;
this.state.cursorCol = (lines[lines.length - 1] || "").length;
this.setCursorCol((lines[lines.length - 1] || "").length);
}
if (this.onChange) {
@@ -1509,7 +1590,7 @@ export class Editor implements Component, Focusable {
// Delete from start of line up to cursor
deletedText = currentLine.slice(0, this.state.cursorCol);
this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
this.state.cursorCol = 0;
this.setCursorCol(0);
} else if (this.state.cursorLine > 0) {
// At start of line - merge with previous line
deletedText = "\n";
@@ -1517,7 +1598,7 @@ export class Editor implements Component, Focusable {
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
this.state.lines.splice(this.state.cursorLine, 1);
this.state.cursorLine--;
this.state.cursorCol = previousLine.length;
this.setCursorCol(previousLine.length);
}
this.recordKill(deletedText, "backward");
@@ -1567,18 +1648,18 @@ export class Editor implements Component, Focusable {
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
this.state.lines.splice(this.state.cursorLine, 1);
this.state.cursorLine--;
this.state.cursorCol = previousLine.length;
this.setCursorCol(previousLine.length);
}
} else {
const oldCursorCol = this.state.cursorCol;
this.moveWordBackwards();
const deleteFrom = this.state.cursorCol;
this.state.cursorCol = oldCursorCol;
this.setCursorCol(oldCursorCol);
const deletedText = currentLine.slice(deleteFrom, oldCursorCol);
this.state.lines[this.state.cursorLine] =
currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol);
this.state.cursorCol = deleteFrom;
this.setCursorCol(deleteFrom);
this.recordKill(deletedText, "backward");
}
@@ -1604,7 +1685,7 @@ export class Editor implements Component, Focusable {
const oldCursorCol = this.state.cursorCol;
this.moveWordForwards();
const deleteTo = this.state.cursorCol;
this.state.cursorCol = oldCursorCol;
this.setCursorCol(oldCursorCol);
const deletedText = currentLine.slice(oldCursorCol, deleteTo);
this.state.lines[this.state.cursorLine] = currentLine.slice(0, oldCursorCol) + currentLine.slice(deleteTo);
@@ -1723,29 +1804,14 @@ export class Editor implements Component, Focusable {
private moveCursor(deltaLine: number, deltaCol: number): void {
this.resetKillSequence();
const contentWidth = this.lastLayoutWidth;
const visualLines = this.buildVisualLineMap(this.lastLayoutWidth);
const currentVisualLine = this.findCurrentVisualLine(visualLines);
if (deltaLine !== 0) {
// Build visual line map for navigation
const visualLines = this.buildVisualLineMap(contentWidth);
const currentVisualLine = this.findCurrentVisualLine(visualLines);
// Calculate column position within current visual line
const currentVL = visualLines[currentVisualLine];
const visualCol = currentVL ? this.state.cursorCol - currentVL.startCol : 0;
// Move to target visual line
const targetVisualLine = currentVisualLine + deltaLine;
if (targetVisualLine >= 0 && targetVisualLine < visualLines.length) {
const targetVL = visualLines[targetVisualLine];
if (targetVL) {
this.state.cursorLine = targetVL.logicalLine;
// Try to maintain visual column position, clamped to line length
const targetCol = targetVL.startCol + Math.min(visualCol, targetVL.length);
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
}
this.moveToVisualLine(visualLines, currentVisualLine, targetVisualLine);
}
}
@@ -1758,11 +1824,17 @@ export class Editor implements Component, Focusable {
const afterCursor = currentLine.slice(this.state.cursorCol);
const graphemes = [...segmenter.segment(afterCursor)];
const firstGrapheme = graphemes[0];
this.state.cursorCol += firstGrapheme ? firstGrapheme.segment.length : 1;
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
} else if (this.state.cursorLine < this.state.lines.length - 1) {
// Wrap to start of next logical line
this.state.cursorLine++;
this.state.cursorCol = 0;
this.setCursorCol(0);
} else {
// At end of last line - can't move, but set preferredVisualCol for up/down navigation
const currentVL = visualLines[currentVisualLine];
if (currentVL) {
this.preferredVisualCol = this.state.cursorCol - currentVL.startCol;
}
}
} else {
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
@@ -1770,12 +1842,12 @@ export class Editor implements Component, Focusable {
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
const graphemes = [...segmenter.segment(beforeCursor)];
const lastGrapheme = graphemes[graphemes.length - 1];
this.state.cursorCol -= lastGrapheme ? lastGrapheme.segment.length : 1;
this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
} else if (this.state.cursorLine > 0) {
// Wrap to end of previous logical line
this.state.cursorLine--;
const prevLine = this.state.lines[this.state.cursorLine] || "";
this.state.cursorCol = prevLine.length;
this.setCursorCol(prevLine.length);
}
}
}
@@ -1789,7 +1861,7 @@ export class Editor implements Component, Focusable {
if (this.state.cursorLine > 0) {
this.state.cursorLine--;
const prevLine = this.state.lines[this.state.cursorLine] || "";
this.state.cursorCol = prevLine.length;
this.setCursorCol(prevLine.length);
}
return;
}
@@ -1822,7 +1894,7 @@ export class Editor implements Component, Focusable {
}
}
this.state.cursorCol = newCol;
this.setCursorCol(newCol);
}
/**
@@ -1852,7 +1924,7 @@ export class Editor implements Component, Focusable {
if (idx !== -1) {
this.state.cursorLine = lineIdx;
this.state.cursorCol = idx;
this.setCursorCol(idx);
return;
}
}
@@ -1866,7 +1938,7 @@ export class Editor implements Component, Focusable {
if (this.state.cursorCol >= currentLine.length) {
if (this.state.cursorLine < this.state.lines.length - 1) {
this.state.cursorLine++;
this.state.cursorCol = 0;
this.setCursorCol(0);
}
return;
}
@@ -1875,10 +1947,11 @@ export class Editor implements Component, Focusable {
const segments = segmenter.segment(textAfterCursor);
const iterator = segments[Symbol.iterator]();
let next = iterator.next();
let newCol = this.state.cursorCol;
// Skip leading whitespace
while (!next.done && isWhitespaceChar(next.value.segment)) {
this.state.cursorCol += next.value.segment.length;
newCol += next.value.segment.length;
next = iterator.next();
}
@@ -1887,17 +1960,19 @@ export class Editor implements Component, Focusable {
if (isPunctuationChar(firstGrapheme)) {
// Skip punctuation run
while (!next.done && isPunctuationChar(next.value.segment)) {
this.state.cursorCol += next.value.segment.length;
newCol += next.value.segment.length;
next = iterator.next();
}
} else {
// Skip word run
while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
this.state.cursorCol += next.value.segment.length;
newCol += next.value.segment.length;
next = iterator.next();
}
}
}
this.setCursorCol(newCol);
}
// Helper method to check if cursor is at start of message (for slash command detection)
@@ -2000,7 +2075,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
this.state.lines = result.lines;
this.state.cursorLine = result.cursorLine;
this.state.cursorCol = result.cursorCol;
this.setCursorCol(result.cursorCol);
if (this.onChange) {
this.onChange(this.getText());
+37 -7
View File
@@ -307,27 +307,57 @@ export class Input implements Component, Focusable {
const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth;
const halfWidth = Math.floor(scrollWidth / 2);
const findValidStart = (start: number) => {
while (start < this.value.length) {
const charCode = this.value.charCodeAt(start);
// this is low surrogate, not a valid start
if (charCode >= 0xdc00 && charCode < 0xe000) {
start++;
continue;
}
break;
}
return start;
};
const findValidEnd = (end: number) => {
while (end > 0) {
const charCode = this.value.charCodeAt(end - 1);
// this is high surrogate, might be split.
if (charCode >= 0xd800 && charCode < 0xdc00) {
end--;
continue;
}
break;
}
return end;
};
if (this.cursor < halfWidth) {
// Cursor near start
visibleText = this.value.slice(0, scrollWidth);
visibleText = this.value.slice(0, findValidEnd(scrollWidth));
cursorDisplay = this.cursor;
} else if (this.cursor > this.value.length - halfWidth) {
// Cursor near end
visibleText = this.value.slice(this.value.length - scrollWidth);
cursorDisplay = scrollWidth - (this.value.length - this.cursor);
const start = findValidStart(this.value.length - scrollWidth);
visibleText = this.value.slice(start);
cursorDisplay = this.cursor - start;
} else {
// Cursor in middle
const start = this.cursor - halfWidth;
visibleText = this.value.slice(start, start + scrollWidth);
const start = findValidStart(this.cursor - halfWidth);
visibleText = this.value.slice(start, findValidEnd(start + scrollWidth));
cursorDisplay = halfWidth;
}
}
// Build line with fake cursor
// Insert cursor character at cursor position
const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))];
const cursorGrapheme = graphemes[0];
const beforeCursor = visibleText.slice(0, cursorDisplay);
const atCursor = visibleText[cursorDisplay] || " "; // Character at cursor, or space if at end
const afterCursor = visibleText.slice(cursorDisplay + 1);
const atCursor = cursorGrapheme?.segment ?? " ";
const afterCursor = visibleText.slice(cursorDisplay + atCursor.length);
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
const marker = this.focused ? CURSOR_MARKER : "";
+2 -2
View File
@@ -113,7 +113,7 @@ export class SettingsList implements Component {
isSelected,
);
lines.push(prefix + labelText + separator + valueText);
lines.push(truncateToWidth(prefix + labelText + separator + valueText, width));
}
// Add scroll indicator if needed
@@ -134,7 +134,7 @@ export class SettingsList implements Component {
// Add hint
lines.push("");
lines.push(this.theme.hint("Enter/Space to change · Esc to cancel"));
lines.push(truncateToWidth(this.theme.hint(" Enter/Space to change · Esc to cancel"), width));
return lines;
}
+47 -6
View File
@@ -45,6 +45,14 @@ export interface Terminal {
// Stop the terminal and restore state
stop(): void;
/**
* Drain stdin before exiting to prevent Kitty key release events from
* leaking to the parent shell over slow SSH connections.
* @param maxMs - Maximum time to drain (default: 1000ms)
* @param idleMs - Exit early if no input arrives within this time (default: 50ms)
*/
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
// Write output to terminal
write(data: string): void;
@@ -187,6 +195,40 @@ export class ProcessTerminal implements Terminal {
this.safeWrite("\x1b[?u");
}
async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
if (this._kittyProtocolActive) {
// Disable Kitty keyboard protocol first so any late key releases
// do not generate new Kitty escape sequences.
process.stdout.write("\x1b[<u");
this._kittyProtocolActive = false;
setKittyProtocolActive(false);
}
const previousHandler = this.inputHandler;
this.inputHandler = undefined;
let lastDataTime = Date.now();
const onData = () => {
lastDataTime = Date.now();
};
process.stdin.on("data", onData);
const endTime = Date.now() + maxMs;
try {
while (true) {
const now = Date.now();
const timeLeft = endTime - now;
if (timeLeft <= 0) break;
if (now - lastDataTime >= idleMs) break;
await new Promise(resolve => setTimeout(resolve, Math.min(idleMs, timeLeft)));
}
} finally {
process.stdin.removeListener("data", onData);
this.inputHandler = previousHandler;
}
}
stop(): void {
// Unregister from emergency cleanup
if (activeTerminal === this) {
@@ -196,7 +238,7 @@ export class ProcessTerminal implements Terminal {
// Disable bracketed paste mode
this.safeWrite("\x1b[?2004l");
// Disable Kitty keyboard protocol (pop the flags we pushed) - only if we enabled it
// Disable Kitty keyboard protocol if not already done by drainInput()
if (this._kittyProtocolActive) {
this.safeWrite("\x1b[<u");
this._kittyProtocolActive = false;
@@ -220,13 +262,12 @@ export class ProcessTerminal implements Terminal {
this.resizeHandler = undefined;
}
// Restore raw mode state
// Pause stdin so Node.js stops reading from the terminal input queue.
// Without this, process.stdin stays in flowing mode and races with any
// external process (e.g. vim) for keystrokes on the same tty.
// start() calls resume() to counterpart this.
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
// re-interpreted after raw mode is disabled. This fixes a race condition
// where Ctrl+D could close the parent shell over SSH.
process.stdin.pause();
// Restore raw mode state
if (process.stdin.setRawMode) {
process.stdin.setRawMode(this.wasRaw);
}
+79 -7
View File
@@ -222,16 +222,17 @@ export class TUI extends Container {
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
public onDebug?: () => void;
private renderRequested = false;
private stopped = false;
private rendering = false;
private cursorRow = 0; // Logical cursor row (end of rendered content)
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
private inputBuffer = ""; // Buffer for parsing terminal responses
private cellSizeQueryPending = false;
private showHardwareCursor = $env.PI_HARDWARE_CURSOR === "1";
private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1";
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
private fullRedrawCount = 0;
private stopped = false;
// Overlay stack for modal components rendered on top of base content
private overlayStack: {
@@ -267,6 +268,19 @@ export class TUI extends Container {
this.requestRender();
}
getClearOnShrink(): boolean {
return this.clearOnShrink;
}
/**
* Set whether to trigger full re-render when content shrinks.
* When true (default), empty rows are cleared when content shrinks.
* When false, empty rows remain (reduces redraws on slower terminals).
*/
setClearOnShrink(enabled: boolean): void {
this.clearOnShrink = enabled;
}
setFocus(component: Component | null): void {
// Clear focused flag on old component
if (isFocusable(this.focusedComponent)) {
@@ -884,6 +898,7 @@ export class TUI extends Container {
}
private doRender(): void {
if (this.stopped) return;
// Guard against re-entrant renders (can happen on Windows when Bun.spawnSync
// yields to the event loop during a sync subprocess call)
if (this.rendering) return;
@@ -921,11 +936,20 @@ export class TUI extends Container {
newLines = this.applyLineResets(newLines);
// Width changed - need full re-render
// Width changed - need full re-render (line wrapping changes)
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
const logRedraw = (reason: string): void => {
if (!debugRedraw) return;
const logPath = path.join(os.homedir(), ".pi", "agent", "pi-debug.log");
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
fs.appendFileSync(logPath, msg);
};
// First render - just output everything without clearing (assumes clean screen)
if (this.previousLines.length === 0 && !widthChanged) {
logRedraw("first render");
this.fullRedrawCount += 1;
let buffer = "\x1b[?2026h"; // Begin synchronized output
for (let i = 0; i < newLines.length; i++) {
@@ -945,8 +969,33 @@ export class TUI extends Container {
return;
}
// Width changed - full re-render
// Width changed - full re-render (line wrapping changes)
if (widthChanged) {
logRedraw(`width changed (${this.previousWidth} -> ${width})`);
this.fullRedrawCount += 1;
let buffer = "\x1b[?2026h"; // Begin synchronized output
buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer += "\r\n";
buffer += newLines[i];
}
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = this.cursorRow;
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
return;
}
// Content shrunk below the working area and no overlays - re-render to clear empty rows
// (overlays need the padding, so only do this when no overlays are active)
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK env var
if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
this.fullRedrawCount += 1;
let buffer = "\x1b[?2026h"; // Begin synchronized output
buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
@@ -1007,8 +1056,28 @@ export class TUI extends Container {
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
buffer += "\r";
// Clear extra lines
// Clear extra lines without scrolling
const extraLines = this.previousLines.length - newLines.length;
if (extraLines > height) {
logRedraw(`extraLines > height (${extraLines} > ${height})`);
this.fullRedrawCount += 1;
let buffer2 = "\x1b[?2026h"; // Begin synchronized output
buffer2 += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer2 += "\r\n";
buffer2 += newLines[i];
}
buffer2 += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer2);
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = this.cursorRow;
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
return;
}
for (let i = 0; i < extraLines; i++) {
buffer += "\r\n\x1b[2K";
}
@@ -1025,9 +1094,12 @@ export class TUI extends Container {
return;
}
// Check if firstChanged is outside the viewport (based on maxLinesRendered)
if (firstChanged < viewportTop) {
// First change is above viewport - need full re-render
// Check if firstChanged is above what was previously visible
// Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks
const previousContentViewportTop = Math.max(0, this.previousLines.length - height);
if (firstChanged < previousContentViewportTop) {
// First change is above previous viewport - need full re-render
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`);
this.fullRedrawCount += 1;
let buffer = "\x1b[?2026h"; // Begin synchronized output
buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
+35 -1
View File
@@ -1,4 +1,7 @@
import { describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete";
describe("CombinedAutocompleteProvider", () => {
@@ -57,4 +60,35 @@ describe("CombinedAutocompleteProvider", () => {
}
});
});
describe("hidden paths", () => {
let baseDir: string;
beforeEach(() => {
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "autocomplete-test-"));
});
afterEach(() => {
fs.rmSync(baseDir, { recursive: true, force: true });
});
it("includes hidden paths but excludes .git", async () => {
for (const dir of [".pi", ".github", ".git"]) {
fs.mkdirSync(path.join(baseDir, dir), { recursive: true });
}
fs.mkdirSync(path.join(baseDir, ".github", "workflows"), { recursive: true });
fs.writeFileSync(path.join(baseDir, ".pi", "config.json"), "{}");
fs.writeFileSync(path.join(baseDir, ".github", "workflows", "ci.yml"), "name: ci");
fs.writeFileSync(path.join(baseDir, ".git", "config"), "[core]");
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = "@";
const result = await provider.getSuggestions([line], 0, line.length);
const values = result?.items.map(item => item.value) ?? [];
expect(values).toContain("@.pi/");
expect(values).toContain("@.github/");
expect(values.some(value => value === "@.git" || value.startsWith("@.git/"))).toBe(false);
});
});
});
+435
View File
@@ -719,4 +719,439 @@ describe("Editor component", () => {
expect(allText).toBe("1234567890");
});
});
describe("Sticky column", () => {
it("preserves target column when moving up through a shorter line", () => {
const editor = new Editor(defaultEditorTheme);
// Line 0: "2222222222x222" (x at col 10)
// Line 1: "" (empty)
// Line 2: "1111111111_111111111111" (_ at col 10)
editor.setText("2222222222x222\n\n1111111111_111111111111");
// Position cursor on _ (line 2, col 10)
expect(editor.getCursor()).toEqual({ line: 2, col: 23 }); // At end
editor.handleInput("\x01"); // Ctrl+A - go to start of line
for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C"); // Move right to col 10
expect(editor.getCursor()).toEqual({ line: 2, col: 10 });
// Press Up - should move to empty line (col clamped to 0)
editor.handleInput("\x1b[A"); // Up arrow
expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
// Press Up again - should move to line 0 at col 10 (on 'x')
editor.handleInput("\x1b[A"); // Up arrow
expect(editor.getCursor()).toEqual({ line: 0, col: 10 });
});
it("preserves target column when moving down through a shorter line", () => {
const editor = new Editor(defaultEditorTheme);
editor.setText("1111111111_111\n\n2222222222x222222222222");
// Position cursor on _ (line 0, col 10)
editor.handleInput("\x1b[A"); // Up to line 1
editor.handleInput("\x1b[A"); // Up to line 0
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 10; i++) editor.handleInput("\x1b[C");
expect(editor.getCursor()).toEqual({ line: 0, col: 10 });
// Press Down - should move to empty line (col clamped to 0)
editor.handleInput("\x1b[B"); // Down arrow
expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
// Press Down again - should move to line 2 at col 10 (on 'x')
editor.handleInput("\x1b[B"); // Down arrow
expect(editor.getCursor()).toEqual({ line: 2, col: 10 });
});
it("resets sticky column on horizontal movement (left arrow)", () => {
const editor = new Editor(defaultEditorTheme);
editor.setText("1234567890\n\n1234567890");
// Start at line 2, col 5
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C");
expect(editor.getCursor()).toEqual({ line: 2, col: 5 });
// Move up through empty line
editor.handleInput("\x1b[A"); // Up - line 1, col 0
editor.handleInput("\x1b[A"); // Up - line 0, col 5 (sticky)
expect(editor.getCursor()).toEqual({ line: 0, col: 5 });
// Move left - resets sticky column
editor.handleInput("\x1b[D"); // Left
expect(editor.getCursor()).toEqual({ line: 0, col: 4 });
// Move down twice
editor.handleInput("\x1b[B"); // Down - line 1, col 0
editor.handleInput("\x1b[B"); // Down - line 2, col 4 (new sticky from col 4)
expect(editor.getCursor()).toEqual({ line: 2, col: 4 });
});
it("resets sticky column on horizontal movement (right arrow)", () => {
const editor = new Editor(defaultEditorTheme);
editor.setText("1234567890\n\n1234567890");
// Start at line 0, col 5
editor.handleInput("\x1b[A"); // Up to line 1
editor.handleInput("\x1b[A"); // Up to line 0
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C");
expect(editor.getCursor()).toEqual({ line: 0, col: 5 });
// Move down through empty line
editor.handleInput("\x1b[B"); // Down - line 1, col 0
editor.handleInput("\x1b[B"); // Down - line 2, col 5 (sticky)
expect(editor.getCursor()).toEqual({ line: 2, col: 5 });
// Move right - resets sticky column
editor.handleInput("\x1b[C"); // Right
expect(editor.getCursor()).toEqual({ line: 2, col: 6 });
// Move up twice
editor.handleInput("\x1b[A"); // Up - line 1, col 0
editor.handleInput("\x1b[A"); // Up - line 0, col 6 (new sticky from col 6)
expect(editor.getCursor()).toEqual({ line: 0, col: 6 });
});
it("resets sticky column on typing", () => {
const editor = new Editor(defaultEditorTheme);
editor.setText("1234567890\n\n1234567890");
// Start at line 2, col 8
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C");
// Move up through empty line
editor.handleInput("\x1b[A"); // Up
editor.handleInput("\x1b[A"); // Up - line 0, col 8
expect(editor.getCursor()).toEqual({ line: 0, col: 8 });
// Type a character - resets sticky column
editor.handleInput("X");
expect(editor.getCursor()).toEqual({ line: 0, col: 9 });
// Move down twice
editor.handleInput("\x1b[B"); // Down - line 1, col 0
editor.handleInput("\x1b[B"); // Down - line 2, col 9 (new sticky from col 9)
expect(editor.getCursor()).toEqual({ line: 2, col: 9 });
});
it("resets sticky column on backspace", () => {
const editor = new Editor(defaultEditorTheme);
editor.setText("1234567890\n\n1234567890");
// Start at line 2, col 8
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C");
// Move up through empty line
editor.handleInput("\x1b[A"); // Up
editor.handleInput("\x1b[A"); // Up - line 0, col 8
expect(editor.getCursor()).toEqual({ line: 0, col: 8 });
// Backspace - resets sticky column
editor.handleInput("\x7f"); // Backspace
expect(editor.getCursor()).toEqual({ line: 0, col: 7 });
// Move down twice
editor.handleInput("\x1b[B"); // Down - line 1, col 0
editor.handleInput("\x1b[B"); // Down - line 2, col 7 (new sticky from col 7)
expect(editor.getCursor()).toEqual({ line: 2, col: 7 });
});
it("resets sticky column on Ctrl+A (move to line start)", () => {
const editor = new Editor(defaultEditorTheme);
editor.setText("1234567890\n\n1234567890");
// Start at line 2, col 8
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C");
// Move up - establishes sticky col 8
editor.handleInput("\x1b[A"); // Up - line 1, col 0
// Ctrl+A - resets sticky column to 0
editor.handleInput("\x01"); // Ctrl+A
expect(editor.getCursor()).toEqual({ line: 1, col: 0 });
// Move up
editor.handleInput("\x1b[A"); // Up - line 0, col 0 (new sticky from col 0)
expect(editor.getCursor()).toEqual({ line: 0, col: 0 });
});
it("resets sticky column on Ctrl+E (move to line end)", () => {
const editor = new Editor(defaultEditorTheme);
editor.setText("12345\n\n1234567890");
// Start at line 2, col 3
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 3; i++) editor.handleInput("\x1b[C");
// Move up through empty line - establishes sticky col 3
editor.handleInput("\x1b[A"); // Up - line 1, col 0
editor.handleInput("\x1b[A"); // Up - line 0, col 3
expect(editor.getCursor()).toEqual({ line: 0, col: 3 });
// Ctrl+E - resets sticky column to end
editor.handleInput("\x05"); // Ctrl+E
expect(editor.getCursor()).toEqual({ line: 0, col: 5 });
// Move down twice
editor.handleInput("\x1b[B"); // Down - line 1, col 0
editor.handleInput("\x1b[B"); // Down - line 2, col 5 (new sticky from col 5)
expect(editor.getCursor()).toEqual({ line: 2, col: 5 });
});
it("resets sticky column on word movement (Ctrl+Left)", () => {
const editor = new Editor(defaultEditorTheme);
editor.setText("hello world\n\nhello world");
// Start at end of line 2 (col 11)
expect(editor.getCursor()).toEqual({ line: 2, col: 11 });
// Move up through empty line - establishes sticky col 11
editor.handleInput("\x1b[A"); // Up - line 1, col 0
editor.handleInput("\x1b[A"); // Up - line 0, col 11
expect(editor.getCursor()).toEqual({ line: 0, col: 11 });
// Ctrl+Left - word movement resets sticky column
editor.handleInput("\x1b[1;5D"); // Ctrl+Left
expect(editor.getCursor()).toEqual({ line: 0, col: 6 }); // Before "world"
// Move down twice
editor.handleInput("\x1b[B"); // Down - line 1, col 0
editor.handleInput("\x1b[B"); // Down - line 2, col 6 (new sticky from col 6)
expect(editor.getCursor()).toEqual({ line: 2, col: 6 });
});
it("resets sticky column on word movement (Ctrl+Right)", () => {
const editor = new Editor(defaultEditorTheme);
editor.setText("hello world\n\nhello world");
// Start at line 0, col 0
editor.handleInput("\x1b[A"); // Up
editor.handleInput("\x1b[A"); // Up
editor.handleInput("\x01"); // Ctrl+A
expect(editor.getCursor()).toEqual({ line: 0, col: 0 });
// Move down through empty line - establishes sticky col 0
editor.handleInput("\x1b[B"); // Down - line 1, col 0
editor.handleInput("\x1b[B"); // Down - line 2, col 0
expect(editor.getCursor()).toEqual({ line: 2, col: 0 });
// Ctrl+Right - word movement resets sticky column
editor.handleInput("\x1b[1;5C"); // Ctrl+Right
expect(editor.getCursor()).toEqual({ line: 2, col: 5 }); // After "hello"
// Move up twice
editor.handleInput("\x1b[A"); // Up - line 1, col 0
editor.handleInput("\x1b[A"); // Up - line 0, col 5 (new sticky from col 5)
expect(editor.getCursor()).toEqual({ line: 0, col: 5 });
});
it("resets sticky column on undo", () => {
const editor = new Editor(defaultEditorTheme);
editor.setText("1234567890\n\n1234567890");
// Go to line 0, col 8
editor.handleInput("\x1b[A"); // Up to line 1
editor.handleInput("\x1b[A"); // Up to line 0
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C");
expect(editor.getCursor()).toEqual({ line: 0, col: 8 });
// Move down through empty line - establishes sticky col 8
editor.handleInput("\x1b[B"); // Down - line 1, col 0
editor.handleInput("\x1b[B"); // Down - line 2, col 8 (sticky)
expect(editor.getCursor()).toEqual({ line: 2, col: 8 });
// Type something to create undo state - this clears sticky and sets col to 9
editor.handleInput("X");
expect(editor.getText()).toBe("1234567890\n\n12345678X90");
expect(editor.getCursor()).toEqual({ line: 2, col: 9 });
// Move up - establishes new sticky col 9
editor.handleInput("\x1b[A"); // Up - line 1, col 0
editor.handleInput("\x1b[A"); // Up - line 0, col 9
expect(editor.getCursor()).toEqual({ line: 0, col: 9 });
// Undo - resets sticky column and restores cursor to line 2, col 8
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
expect(editor.getText()).toBe("1234567890\n\n1234567890");
expect(editor.getCursor()).toEqual({ line: 2, col: 8 });
// Move up - should capture new sticky from restored col 8, not old col 9
editor.handleInput("\x1b[A"); // Up - line 1, col 0
editor.handleInput("\x1b[A"); // Up - line 0, col 8 (new sticky from restored position)
expect(editor.getCursor()).toEqual({ line: 0, col: 8 });
});
it("handles multiple consecutive up/down movements", () => {
const editor = new Editor(defaultEditorTheme);
editor.setText("1234567890\nab\ncd\nef\n1234567890");
// Start at line 4, col 7
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 7; i++) editor.handleInput("\x1b[C");
expect(editor.getCursor()).toEqual({ line: 4, col: 7 });
// Move up multiple times through short lines
editor.handleInput("\x1b[A"); // Up - line 3, col 2 (clamped)
editor.handleInput("\x1b[A"); // Up - line 2, col 2 (clamped)
editor.handleInput("\x1b[A"); // Up - line 1, col 2 (clamped)
editor.handleInput("\x1b[A"); // Up - line 0, col 7 (restored)
expect(editor.getCursor()).toEqual({ line: 0, col: 7 });
// Move down multiple times - sticky should still be 7
editor.handleInput("\x1b[B"); // Down - line 1, col 2
editor.handleInput("\x1b[B"); // Down - line 2, col 2
editor.handleInput("\x1b[B"); // Down - line 3, col 2
editor.handleInput("\x1b[B"); // Down - line 4, col 7 (restored)
expect(editor.getCursor()).toEqual({ line: 4, col: 7 });
});
it("moves correctly through wrapped visual lines without getting stuck", () => {
const editor = new Editor(defaultEditorTheme);
// Line 0: short
// Line 1: 30 chars = wraps to multiple visual lines at narrow width
editor.setText("short\n123456789012345678901234567890");
editor.render(15); // Narrow width to force wrapping
// Position at end of line 1 (col 30)
expect(editor.getCursor()).toEqual({ line: 1, col: 30 });
// Move up repeatedly - should traverse all visual lines of the wrapped text
// and eventually reach line 0
editor.handleInput("\x1b[A"); // Up - to previous visual line within line 1
expect(editor.getCursor().line).toBe(1);
editor.handleInput("\x1b[A"); // Up - another visual line
expect(editor.getCursor().line).toBe(1);
editor.handleInput("\x1b[A"); // Up - should reach line 0
expect(editor.getCursor().line).toBe(0);
});
it("handles setText resetting sticky column", () => {
const editor = new Editor(defaultEditorTheme);
editor.setText("1234567890\n\n1234567890");
// Establish sticky column
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 8; i++) editor.handleInput("\x1b[C");
editor.handleInput("\x1b[A"); // Up
// setText should reset sticky column
editor.setText("abcdefghij\n\nabcdefghij");
expect(editor.getCursor()).toEqual({ line: 2, col: 10 }); // At end
// Move up - should capture new sticky from current position (10)
editor.handleInput("\x1b[A"); // Up - line 1, col 0
editor.handleInput("\x1b[A"); // Up - line 0, col 10
expect(editor.getCursor()).toEqual({ line: 0, col: 10 });
});
it("sets preferredVisualCol when pressing right at end of prompt (last line)", () => {
const editor = new Editor(defaultEditorTheme);
// Line 0: 20 chars with 'x' at col 10
// Line 1: empty
// Line 2: 10 chars ending with '_'
editor.setText("111111111x1111111111\n\n333333333_");
// Go to line 0, press Ctrl+E (end of line) - col 20
editor.handleInput("\x1b[A"); // Up to line 1
editor.handleInput("\x1b[A"); // Up to line 0
editor.handleInput("\x05"); // Ctrl+E - move to end of line
expect(editor.getCursor()).toEqual({ line: 0, col: 20 });
// Move down to line 2 - cursor clamped to col 10 (end of line)
editor.handleInput("\x1b[B"); // Down to line 1, col 0
editor.handleInput("\x1b[B"); // Down to line 2, col 10 (clamped)
expect(editor.getCursor()).toEqual({ line: 2, col: 10 });
// Press Right at end of prompt - nothing visible happens, but sets preferredVisualCol to 10
editor.handleInput("\x1b[C"); // Right - can't move, but sets preferredVisualCol
expect(editor.getCursor()).toEqual({ line: 2, col: 10 }); // Still at same position
// Move up twice to line 0 - should use preferredVisualCol (10) to land on 'x'
editor.handleInput("\x1b[A"); // Up to line 1, col 0
editor.handleInput("\x1b[A"); // Up to line 0, col 10 (on 'x')
expect(editor.getCursor()).toEqual({ line: 0, col: 10 });
});
it("handles editor resizes when preferredVisualCol is on the same line", () => {
const editor = new Editor(defaultEditorTheme);
editor.setText("12345678901234567890\n\n12345678901234567890");
// Start at line 2, col 15
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 15; i++) editor.handleInput("\x1b[C");
// Move up through empty line - establishes sticky col 15
editor.handleInput("\x1b[A"); // Up
editor.handleInput("\x1b[A"); // Up - line 0, col 15
expect(editor.getCursor()).toEqual({ line: 0, col: 15 });
// Render with narrower width to simulate resize
editor.render(12); // Width 12
// Move down - sticky should be clamped to new width
editor.handleInput("\x1b[B"); // Down - line 1
editor.handleInput("\x1b[B"); // Down - line 2, col should be clamped
expect(editor.getCursor().col).toBe(4);
});
it("handles editor resizes when preferredVisualCol is on a different line", () => {
const editor = new Editor(defaultEditorTheme);
// Create a line that wraps into multiple visual lines at width 10
// "12345678901234567890" = 20 chars, wraps to 2 visual lines at width 10
editor.setText("short\n12345678901234567890");
// Go to line 1, col 15
editor.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 15; i++) editor.handleInput("\x1b[C");
expect(editor.getCursor()).toEqual({ line: 1, col: 15 });
// Move up to establish sticky col 15
editor.handleInput("\x1b[A"); // Up to line 0
// Line 0 has only 5 chars, so cursor at col 5
expect(editor.getCursor()).toEqual({ line: 0, col: 5 });
// Narrow the editor
editor.render(10);
// Move down - preferredVisualCol was 15, but width is 10
// Should land on line 1, clamped to width (visual col 9, which is logical col 9)
editor.handleInput("\x1b[B"); // Down to line 1
expect(editor.getCursor()).toEqual({ line: 1, col: 8 });
// Move up
editor.handleInput("\x1b[A"); // Up - should go to line 0
expect(editor.getCursor()).toEqual({ line: 0, col: 5 }); // Line 0 only has 5 chars
// Restore the original width
editor.render(80);
// Move down - preferredVisualCol was kept at 15
editor.handleInput("\x1b[B"); // Down to line 1
expect(editor.getCursor()).toEqual({ line: 1, col: 15 });
});
});
});
+35 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { matchesKey, setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
import { matchesKey, parseKey, setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
describe("matchesKey", () => {
it("matches ctrl+letter sequences", () => {
@@ -17,4 +17,38 @@ describe("matchesKey", () => {
setKittyProtocolActive(false);
expect(matchesKey("\x1b[5~", "pageUp")).toBe(true);
});
it("should prefer codepoint for Latin letters even when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118)
const dvorakCtrlK = "\x1b[107::118;5u";
expect(matchesKey(dvorakCtrlK, "ctrl+k")).toBe(true);
expect(matchesKey(dvorakCtrlK, "ctrl+v")).toBe(false);
setKittyProtocolActive(false);
});
it("should prefer codepoint for symbol keys even when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91)
const dvorakCtrlSlash = "\x1b[47::91;5u";
expect(matchesKey(dvorakCtrlSlash, "ctrl+/")).toBe(true);
expect(matchesKey(dvorakCtrlSlash, "ctrl+[")).toBe(false);
setKittyProtocolActive(false);
});
});
describe("parseKey", () => {
it("should prefer codepoint for Latin letters when base layout differs", () => {
setKittyProtocolActive(true);
const dvorakCtrlK = "\x1b[107::118;5u";
expect(parseKey(dvorakCtrlK)).toBe("ctrl+k");
setKittyProtocolActive(false);
});
it("should prefer codepoint for symbol keys when base layout differs", () => {
setKittyProtocolActive(true);
const dvorakCtrlSlash = "\x1b[47::91;5u";
expect(parseKey(dvorakCtrlSlash)).toBe("ctrl+/");
setKittyProtocolActive(false);
});
});
+151
View File
@@ -692,6 +692,157 @@ again, hello world`,
});
});
describe("Blockquotes with multiline content", () => {
it("should apply consistent styling to all lines in lazy continuation blockquote", () => {
// Markdown "lazy continuation" - second line without > is still part of the quote
const markdown = new Markdown(
`>Foo
bar`,
0,
0,
defaultMarkdownTheme,
{
color: text => chalk.magenta(text), // This should NOT be applied to blockquotes
},
);
const lines = markdown.render(80);
// Both lines should have the quote border
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, ""));
const quotedLines = plainLines.filter(line => line.startsWith("│ "));
expect(quotedLines.length).toBe(2);
// Both lines should have italic (from theme.quote styling)
const fooLine = lines.find(line => line.includes("Foo"));
const barLine = lines.find(line => line.includes("bar"));
expect(fooLine).toBeTruthy();
expect(barLine).toBeTruthy();
// Check that both have italic (\x1b[3m) - blockquotes use theme styling, not default message color
expect(fooLine?.includes("\x1b[3m")).toBeTruthy();
expect(barLine?.includes("\x1b[3m")).toBeTruthy();
// Blockquotes should NOT have the default message color (magenta)
expect(fooLine?.includes("\x1b[35m")).toBeFalsy();
expect(barLine?.includes("\x1b[35m")).toBeFalsy();
});
it("should apply consistent styling to explicit multiline blockquote", () => {
const markdown = new Markdown(
`>Foo
>bar`,
0,
0,
defaultMarkdownTheme,
{
color: text => chalk.cyan(text), // This should NOT be applied to blockquotes
},
);
const lines = markdown.render(80);
// Both lines should have the quote border
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, ""));
const quotedLines = plainLines.filter(line => line.startsWith("│ "));
expect(quotedLines.length).toBe(2);
// Both lines should have italic (from theme.quote styling)
const fooLine = lines.find(line => line.includes("Foo"));
const barLine = lines.find(line => line.includes("bar"));
expect(fooLine?.includes("\x1b[3m")).toBeTruthy();
expect(barLine?.includes("\x1b[3m")).toBeTruthy();
// Blockquotes should NOT have the default message color (cyan)
expect(fooLine?.includes("\x1b[36m")).toBeFalsy();
expect(barLine?.includes("\x1b[36m")).toBeFalsy();
});
it("should wrap long blockquote lines and add border to each wrapped line", () => {
const longText = "This is a very long blockquote line that should wrap to multiple lines when rendered";
const markdown = new Markdown(`> ${longText}`, 0, 0, defaultMarkdownTheme);
// Render at narrow width to force wrapping
const lines = markdown.render(30);
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
// Filter to non-empty lines (exclude trailing blank line after blockquote)
const contentLines = plainLines.filter(line => line.length > 0);
// Should have multiple lines due to wrapping
expect(contentLines.length > 1).toBeTruthy();
// Every content line should start with the quote border
for (const line of contentLines) {
expect(line.startsWith("│ ")).toBeTruthy();
}
// All content should be preserved
const allText = contentLines.join(" ");
expect(allText.includes("very long")).toBeTruthy();
expect(allText.includes("blockquote")).toBeTruthy();
expect(allText.includes("multiple")).toBeTruthy();
});
it("should properly indent wrapped blockquote lines with styling", () => {
const markdown = new Markdown(
"> This is styled text that is long enough to wrap",
0,
0,
defaultMarkdownTheme,
{
color: text => chalk.yellow(text), // This should NOT be applied to blockquotes
italic: true,
},
);
const lines = markdown.render(25);
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
// Filter to non-empty lines
const contentLines = plainLines.filter(line => line.length > 0);
// All lines should have the quote border
for (const line of contentLines) {
expect(line.startsWith("│ ")).toBeTruthy();
}
// Check that italic is applied (from theme.quote)
const allOutput = lines.join("\n");
expect(allOutput.includes("\x1b[3m")).toBeTruthy();
// Blockquotes should NOT have the default message color (yellow)
expect(allOutput.includes("\x1b[33m")).toBeFalsy();
});
it("should render inline formatting inside blockquotes and reapply quote styling after", () => {
const markdown = new Markdown("> Quote with **bold** and `code`", 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, ""));
// Should have the quote border
expect(plainLines.some(line => line.startsWith("│ "))).toBeTruthy();
// Content should be preserved
const allPlain = plainLines.join(" ");
expect(allPlain.includes("Quote with")).toBeTruthy();
expect(allPlain.includes("bold")).toBeTruthy();
expect(allPlain.includes("code")).toBeTruthy();
const allOutput = lines.join("\n");
// Should have bold styling (\x1b[1m)
expect(allOutput.includes("\x1b[1m")).toBeTruthy();
// Should have code styling (yellow = \x1b[33m from defaultMarkdownTheme)
expect(allOutput.includes("\x1b[33m")).toBeTruthy();
// Should have italic from quote styling (\x1b[3m)
expect(allOutput.includes("\x1b[3m")).toBeTruthy();
});
});
describe("Links", () => {
it("should not duplicate URL for autolinked emails", () => {
const markdown = new Markdown("Contact user@example.com for help", 0, 0, defaultMarkdownTheme);
+4
View File
@@ -36,6 +36,10 @@ export class VirtualTerminal implements Terminal {
this.xterm.write("\x1b[?2004h");
}
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {
// No-op for virtual terminal - no stdin to drain
}
stop(): void {
// Disable bracketed paste mode
this.xterm.write("\x1b[?2004l");