feat(natives): backported fixes from pi-mono (82d7da878..9ce00079)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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}}">
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 : "";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user