feat: implemented Emacs-style kill ring with Alt+D and Ctrl+Y bindings
- Added automatic retry logic for OpenAI Codex responses with configurable delay and max retries. - Added input event handlers for extensions to intercept user input and argument completion support. - Added Emacs-style kill ring for text deletion operations with Alt+D and Ctrl+Y keybindings. - Added configurable editor padding via editorPaddingX theme option and setMaxHeight() method. - Fixed tool prefix handling in Claude provider to use case-insensitive comparison. - Enhanced Kitty keyboard protocol support for better key detection and shifted symbol handling.
This commit is contained in:
@@ -5,8 +5,8 @@ Use it for any merge: single file, feature branch, or full release sync.
|
||||
|
||||
## Last Sync Point
|
||||
|
||||
**Commit:** `b74535dc`
|
||||
**Date:** January 2026
|
||||
**Commit:** `72047c05`
|
||||
**Date:** 2026-01-14
|
||||
|
||||
When starting a new sync, generate patches from this commit forward:
|
||||
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added automatic retry logic for OpenAI Codex responses with configurable delay and max retries
|
||||
- Added tool call ID sanitization for Amazon Bedrock to ensure valid characters
|
||||
- Added support for Bedrock Claude thinking budget management with automatic token allocation
|
||||
- Added numeric string coercion in validation to handle string numbers in tool arguments
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved error handling in stream processing with better message filtering
|
||||
- Enhanced validation to automatically convert numeric strings to numbers when appropriate
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed tool prefix handling in Claude provider to use case-insensitive comparison
|
||||
- Fixed Gemini 3 model handling to treat unsigned tool calls as context-only
|
||||
- Fixed message transformation to filter out empty error messages from conversation history
|
||||
- Fixed OpenAI completions provider to avoid using developer role for opencode provider
|
||||
- Fixed orphaned tool call handling to skip synthetic results for errored assistant messages
|
||||
|
||||
## [5.5.0] - 2026-01-18
|
||||
### Changed
|
||||
|
||||
@@ -54,6 +54,10 @@ export interface BedrockOptions extends StreamOptions {
|
||||
|
||||
type Block = (TextContent | ThinkingContent | ToolCall) & { index?: number; partialJson?: string };
|
||||
|
||||
function sanitizeToolCallId(id: string): string {
|
||||
return id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
}
|
||||
|
||||
export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
context: Context,
|
||||
@@ -165,7 +169,7 @@ function handleContentBlockStart(
|
||||
if (start?.toolUse) {
|
||||
const block: Block = {
|
||||
type: "toolCall",
|
||||
id: start.toolUse.toolUseId || "",
|
||||
id: sanitizeToolCallId(start.toolUse.toolUseId || ""),
|
||||
name: start.toolUse.name || "",
|
||||
arguments: {},
|
||||
partialJson: "",
|
||||
@@ -348,7 +352,11 @@ function convertMessages(context: Context, model: Model<"bedrock-converse-stream
|
||||
break;
|
||||
case "toolCall":
|
||||
contentBlocks.push({
|
||||
toolUse: { toolUseId: c.id, name: c.name, input: c.arguments },
|
||||
toolUse: {
|
||||
toolUseId: sanitizeToolCallId(c.id),
|
||||
name: c.name,
|
||||
input: c.arguments,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case "thinking":
|
||||
@@ -382,7 +390,7 @@ function convertMessages(context: Context, model: Model<"bedrock-converse-stream
|
||||
// Add current tool result with all content blocks combined
|
||||
toolResults.push({
|
||||
toolResult: {
|
||||
toolUseId: m.toolCallId,
|
||||
toolUseId: sanitizeToolCallId(m.toolCallId),
|
||||
content: m.content.map((c) =>
|
||||
c.type === "image"
|
||||
? { image: createImageBlock(c.mimeType, c.data) }
|
||||
@@ -398,7 +406,7 @@ function convertMessages(context: Context, model: Model<"bedrock-converse-stream
|
||||
const nextMsg = transformedMessages[j] as ToolResultMessage;
|
||||
toolResults.push({
|
||||
toolResult: {
|
||||
toolUseId: nextMsg.toolCallId,
|
||||
toolUseId: sanitizeToolCallId(nextMsg.toolCallId),
|
||||
content: nextMsg.content.map((c) =>
|
||||
c.type === "image"
|
||||
? { image: createImageBlock(c.mimeType, c.data) }
|
||||
|
||||
@@ -47,12 +47,16 @@ export const claudeCodeHeaders = {
|
||||
} as const;
|
||||
|
||||
export const applyClaudeToolPrefix = (name: string) => {
|
||||
if (!claudeToolPrefix || name.startsWith(claudeToolPrefix)) return name;
|
||||
if (!claudeToolPrefix) return name;
|
||||
const prefix = claudeToolPrefix.toLowerCase();
|
||||
if (name.toLowerCase().startsWith(prefix)) return name;
|
||||
return `${claudeToolPrefix}${name}`;
|
||||
};
|
||||
|
||||
export const stripClaudeToolPrefix = (name: string) => {
|
||||
if (!claudeToolPrefix || !name.startsWith(claudeToolPrefix)) return name;
|
||||
if (!claudeToolPrefix) return name;
|
||||
const prefix = claudeToolPrefix.toLowerCase();
|
||||
if (!name.toLowerCase().startsWith(prefix)) return name;
|
||||
return name.slice(claudeToolPrefix.length);
|
||||
};
|
||||
|
||||
|
||||
@@ -65,6 +65,10 @@ export function requiresToolCallId(modelId: string): boolean {
|
||||
return modelId.startsWith("claude-");
|
||||
}
|
||||
|
||||
function isGemini3Model(modelId: string): boolean {
|
||||
return modelId.includes("gemini-3");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal messages to Gemini Content[] format.
|
||||
*/
|
||||
@@ -131,6 +135,18 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
||||
});
|
||||
}
|
||||
} else if (block.type === "toolCall") {
|
||||
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
|
||||
if (isGemini3Model(model.id) && !thoughtSignature) {
|
||||
const args = JSON.stringify(block.arguments ?? {});
|
||||
parts.push({
|
||||
text: sanitizeSurrogates(
|
||||
`Unsigned tool call from a previous model: ${block.name}(${args}). ` +
|
||||
`Treat this as context only and do not attempt to mimic or execute the tool call.`,
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const part: Part = {
|
||||
functionCall: {
|
||||
name: block.name,
|
||||
@@ -141,7 +157,6 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
|
||||
if (model.provider === "google-vertex" && part?.functionCall?.id) {
|
||||
delete part.functionCall.id; // Vertex AI does not support 'id' in functionCall
|
||||
}
|
||||
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
|
||||
if (thoughtSignature) {
|
||||
part.thoughtSignature = thoughtSignature;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
|
||||
}
|
||||
|
||||
const CODEX_DEBUG = process.env.PI_CODEX_DEBUG === "1" || process.env.PI_CODEX_DEBUG === "true";
|
||||
const CODEX_MAX_RETRIES = 2;
|
||||
const CODEX_RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
|
||||
const CODEX_RETRY_DELAY_MS = 500;
|
||||
|
||||
export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"> = (
|
||||
model: Model<"openai-codex-responses">,
|
||||
@@ -134,12 +137,15 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
|
||||
headers: redactHeaders(headers),
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal: options?.signal,
|
||||
});
|
||||
const response = await fetchWithRetry(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(transformedBody),
|
||||
},
|
||||
options?.signal,
|
||||
);
|
||||
|
||||
logCodexDebug("codex response", {
|
||||
url: response.url,
|
||||
@@ -409,6 +415,43 @@ function logCodexDebug(message: string, details?: Record<string, unknown>): void
|
||||
console.error(`[codex] ${message}`);
|
||||
}
|
||||
|
||||
function getRetryDelayMs(response: Response | null, attempt: number): number {
|
||||
const retryAfter = response?.headers?.get("retry-after") || null;
|
||||
if (retryAfter) {
|
||||
const seconds = Number(retryAfter);
|
||||
if (Number.isFinite(seconds)) {
|
||||
return Math.max(0, seconds * 1000);
|
||||
}
|
||||
const parsedDate = Date.parse(retryAfter);
|
||||
if (!Number.isNaN(parsedDate)) {
|
||||
return Math.max(0, parsedDate - Date.now());
|
||||
}
|
||||
}
|
||||
return CODEX_RETRY_DELAY_MS * (attempt + 1);
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
const response = await fetch(url, { ...init, signal: signal ?? init.signal });
|
||||
if (!CODEX_RETRYABLE_STATUS.has(response.status) || attempt >= CODEX_MAX_RETRIES) {
|
||||
return response;
|
||||
}
|
||||
if (signal?.aborted) return response;
|
||||
const delay = getRetryDelayMs(response, attempt);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
} catch (error) {
|
||||
if (attempt >= CODEX_MAX_RETRIES || signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
const delay = CODEX_RETRY_DELAY_MS * (attempt + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function redactHeaders(headers: Headers): Record<string, string> {
|
||||
const redacted: Record<string, string> = {};
|
||||
for (const [key, value] of headers.entries()) {
|
||||
|
||||
@@ -460,7 +460,7 @@ function convertMessages(
|
||||
const transformedMessages = transformMessages(context.messages, model);
|
||||
|
||||
if (context.systemPrompt) {
|
||||
const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole;
|
||||
const useDeveloperRole = model.reasoning && compat.supportsDeveloperRole && model.provider !== "opencode";
|
||||
const role = useDeveloperRole ? "developer" : "system";
|
||||
params.push({ role: role, content: sanitizeSurrogates(context.systemPrompt) });
|
||||
}
|
||||
@@ -685,13 +685,15 @@ function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"]): Sto
|
||||
*/
|
||||
function detectCompatFromUrl(baseUrl: string): Required<OpenAICompat> {
|
||||
const isZai = baseUrl.includes("api.z.ai");
|
||||
const isOpencode = baseUrl.includes("opencode.ai");
|
||||
|
||||
const isNonStandard =
|
||||
baseUrl.includes("cerebras.ai") ||
|
||||
baseUrl.includes("api.x.ai") ||
|
||||
baseUrl.includes("mistral.ai") ||
|
||||
baseUrl.includes("chutes.ai") ||
|
||||
isZai;
|
||||
isZai ||
|
||||
isOpencode;
|
||||
|
||||
const useMaxTokens = baseUrl.includes("mistral.ai") || baseUrl.includes("chutes.ai");
|
||||
|
||||
|
||||
@@ -14,19 +14,19 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
|
||||
const toolCallIdMap = new Map<string, string>();
|
||||
|
||||
// First pass: transform messages (thinking blocks, tool call ID normalization)
|
||||
const transformed = messages.map((msg) => {
|
||||
const transformed = messages.flatMap((msg) => {
|
||||
// User messages pass through unchanged
|
||||
if (msg.role === "user") {
|
||||
return msg;
|
||||
return [msg];
|
||||
}
|
||||
|
||||
// Handle toolResult messages - normalize toolCallId if we have a mapping
|
||||
if (msg.role === "toolResult") {
|
||||
const normalizedId = toolCallIdMap.get(msg.toolCallId);
|
||||
if (normalizedId && normalizedId !== msg.toolCallId) {
|
||||
return { ...msg, toolCallId: normalizedId };
|
||||
return [{ ...msg, toolCallId: normalizedId }];
|
||||
}
|
||||
return msg;
|
||||
return [msg];
|
||||
}
|
||||
|
||||
// Assistant messages need transformation check
|
||||
@@ -35,7 +35,10 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
|
||||
|
||||
// If message is from the same provider and API, keep as is
|
||||
if (assistantMsg.provider === model.provider && assistantMsg.api === model.api) {
|
||||
return msg;
|
||||
if (assistantMsg.stopReason === "error" && assistantMsg.content.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [msg];
|
||||
}
|
||||
|
||||
// Check if we need to normalize tool call IDs
|
||||
@@ -73,13 +76,19 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
|
||||
return block;
|
||||
});
|
||||
|
||||
if (assistantMsg.stopReason === "error" && transformedContent.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return transformed assistant message
|
||||
return {
|
||||
...assistantMsg,
|
||||
content: transformedContent,
|
||||
};
|
||||
return [
|
||||
{
|
||||
...assistantMsg,
|
||||
content: transformedContent,
|
||||
},
|
||||
];
|
||||
}
|
||||
return msg;
|
||||
return [msg];
|
||||
});
|
||||
|
||||
// Second pass: insert synthetic empty tool results for orphaned tool calls
|
||||
@@ -110,12 +119,16 @@ export function transformMessages<TApi extends Api>(messages: Message[], model:
|
||||
existingToolResultIds = new Set();
|
||||
}
|
||||
|
||||
// Track tool calls from this assistant message
|
||||
// Track tool calls from this assistant message unless it errored
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
const isErroredAssistant = assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted";
|
||||
const toolCalls = assistantMsg.content.filter((b) => b.type === "toolCall") as ToolCall[];
|
||||
if (toolCalls.length > 0) {
|
||||
if (!isErroredAssistant && toolCalls.length > 0) {
|
||||
pendingToolCalls = toolCalls;
|
||||
existingToolResultIds = new Set();
|
||||
} else if (isErroredAssistant) {
|
||||
pendingToolCalls = [];
|
||||
existingToolResultIds = new Set();
|
||||
}
|
||||
|
||||
result.push(msg);
|
||||
|
||||
@@ -223,6 +223,25 @@ const GOOGLE_THINKING: Record<ThinkingLevel, number> = {
|
||||
xhigh: 24575,
|
||||
};
|
||||
|
||||
const BEDROCK_CLAUDE_THINKING: Record<ThinkingLevel, number> = {
|
||||
minimal: 1024,
|
||||
low: 2048,
|
||||
medium: 8192,
|
||||
high: 16384,
|
||||
xhigh: 16384,
|
||||
};
|
||||
|
||||
function resolveBedrockThinkingBudget(
|
||||
model: Model<"bedrock-converse-stream">,
|
||||
options?: SimpleStreamOptions,
|
||||
): { budget: number; level: ThinkingLevel } | null {
|
||||
if (!options?.reasoning || !model.reasoning) return null;
|
||||
if (!model.id.includes("anthropic.claude")) return null;
|
||||
const level = options.reasoning === "xhigh" ? "high" : options.reasoning;
|
||||
const budget = options.thinkingBudgets?.[level] ?? BEDROCK_CLAUDE_THINKING[level];
|
||||
return { budget, level };
|
||||
}
|
||||
|
||||
function mapOptionsForApi<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
options?: SimpleStreamOptions,
|
||||
@@ -282,12 +301,28 @@ function mapOptionsForApi<TApi extends Api>(
|
||||
}
|
||||
}
|
||||
|
||||
case "bedrock-converse-stream":
|
||||
return {
|
||||
case "bedrock-converse-stream": {
|
||||
const bedrockBase: BedrockOptions = {
|
||||
...base,
|
||||
reasoning: options?.reasoning,
|
||||
thinkingBudgets: options?.thinkingBudgets,
|
||||
} satisfies BedrockOptions;
|
||||
};
|
||||
const budgetInfo = resolveBedrockThinkingBudget(model as Model<"bedrock-converse-stream">, options);
|
||||
if (!budgetInfo) return bedrockBase as OptionsForApi<TApi>;
|
||||
let maxTokens = bedrockBase.maxTokens ?? model.maxTokens;
|
||||
let thinkingBudgets = bedrockBase.thinkingBudgets;
|
||||
if (maxTokens <= budgetInfo.budget) {
|
||||
const desiredMaxTokens = Math.min(model.maxTokens, budgetInfo.budget + MIN_OUTPUT_TOKENS);
|
||||
if (desiredMaxTokens > maxTokens) {
|
||||
maxTokens = desiredMaxTokens;
|
||||
}
|
||||
}
|
||||
if (maxTokens <= budgetInfo.budget) {
|
||||
const adjustedBudget = Math.max(0, maxTokens - MIN_OUTPUT_TOKENS);
|
||||
thinkingBudgets = { ...(thinkingBudgets ?? {}), [budgetInfo.level]: adjustedBudget };
|
||||
}
|
||||
return { ...bedrockBase, maxTokens, thinkingBudgets } satisfies BedrockOptions;
|
||||
}
|
||||
|
||||
case "openai-completions":
|
||||
return {
|
||||
|
||||
@@ -29,6 +29,9 @@ import type { Tool, ToolCall } from "../types";
|
||||
/** Regex matching valid JSON number literals (integers, decimals, scientific notation) */
|
||||
const JSON_NUMBER_PATTERN = /^[+-]?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
||||
|
||||
/** Regex matching numeric strings (allows leading zeros) */
|
||||
const NUMERIC_STRING_PATTERN = /^[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
||||
|
||||
/**
|
||||
* Normalizes AJV's `params.type` into a consistent string array.
|
||||
* AJV may report the expected type as a single string or an array of strings
|
||||
@@ -69,6 +72,28 @@ function matchesExpectedType(value: unknown, expectedTypes: string[]): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
function tryParseNumberString(value: string, expectedTypes: string[]): { value: unknown; changed: boolean } {
|
||||
if (!expectedTypes.includes("number") && !expectedTypes.includes("integer")) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !NUMERIC_STRING_PATTERN.test(trimmed)) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
if (!matchesExpectedType(parsed, expectedTypes)) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
return { value: parsed, changed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse a string as JSON if it looks like a JSON literal and
|
||||
* the parsed result matches one of the expected types.
|
||||
@@ -86,6 +111,11 @@ function tryParseJsonForTypes(value: string, expectedTypes: string[]): { value:
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return { value, changed: false };
|
||||
|
||||
const numberCoercion = tryParseNumberString(trimmed, expectedTypes);
|
||||
if (numberCoercion.changed) {
|
||||
return numberCoercion;
|
||||
}
|
||||
|
||||
// Quick syntactic checks to avoid unnecessary parse attempts
|
||||
const looksJsonObject = trimmed.startsWith("{") && trimmed.endsWith("}");
|
||||
const looksJsonArray = trimmed.startsWith("[") && trimmed.endsWith("]");
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added support for input event handlers in extensions to intercept and modify user input
|
||||
- Added argument completion support for custom commands in extensions
|
||||
- Added compact() method to extension command context for programmatic session compaction
|
||||
- Added setLabel() method to extension API for custom display labels
|
||||
- Added startup quiet setting to suppress welcome screen and startup messages
|
||||
- Added support for auto-discovering APPEND_SYSTEM.md files
|
||||
- Added support for piped input in non-interactive mode
|
||||
- Added global session listing across all project directories
|
||||
- Added enhanced session search including title, cwd, and path
|
||||
- Added key hint formatting utilities for UI labels
|
||||
- Added timeout display in bash tool output
|
||||
- Added fuzzy text normalization for improved diff matching
|
||||
- Added $@ argument slicing syntax in prompt templates
|
||||
- Added configurable keybindings for expand tools and dequeue actions
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved error retry detection to include fetch failures
|
||||
- Enhanced session selector to search across more metadata fields
|
||||
- Updated skill command execution to include skill path metadata
|
||||
- Streamlined session file collection with shared utility function
|
||||
- Improved extension runner error handling and logging
|
||||
- Enhanced bash tool renderer to show timeout information
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed duplicate custom message rendering in event controller
|
||||
- Fixed session selector width truncation for long text
|
||||
- Fixed model selection to respect remembered model roles
|
||||
- Fixed auto-print mode detection when piped input is present
|
||||
|
||||
## [5.5.0] - 2026-01-18
|
||||
### Changed
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*
|
||||
* Test with: npx tsx src/cli-new.ts [args...]
|
||||
*/
|
||||
import { APP_NAME } from "./config";
|
||||
import { main } from "./main";
|
||||
|
||||
process.title = APP_NAME;
|
||||
main(process.argv.slice(2));
|
||||
|
||||
@@ -967,7 +967,7 @@ export class AgentSession {
|
||||
});
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Custom command "${commandName}" failed: ${message}`);
|
||||
logger.error("Custom command failed", { commandName, error: message });
|
||||
}
|
||||
return ""; // Command was handled (with error)
|
||||
}
|
||||
@@ -2092,8 +2092,8 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
private _isRetryableErrorMessage(errorMessage: string): boolean {
|
||||
// Match: overloaded_error, rate limit, usage limit, 429, 500, 502, 503, 504, service unavailable, connection error
|
||||
return /overloaded|rate.?limit|usage.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error/i.test(
|
||||
// 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(
|
||||
errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import type { AssistantMessage, Model, Usage } from "@oh-my-pi/pi-ai";
|
||||
import { complete, completeSimple } from "@oh-my-pi/pi-ai";
|
||||
import { completeSimple } from "@oh-my-pi/pi-ai";
|
||||
import compactionSummaryPrompt from "../../prompts/compaction/compaction-summary.md" with { type: "text" };
|
||||
import compactionTurnPrefixPrompt from "../../prompts/compaction/compaction-turn-prefix.md" with { type: "text" };
|
||||
import compactionUpdateSummaryPrompt from "../../prompts/compaction/compaction-update-summary.md" with { type: "text" };
|
||||
@@ -642,17 +642,22 @@ async function generateTurnPrefixSummary(
|
||||
): Promise<string> {
|
||||
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
|
||||
|
||||
const transformedMessages = convertToLlm(messages);
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
|
||||
const summarizationMessages = [
|
||||
...transformedMessages,
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: TURN_PREFIX_SUMMARIZATION_PROMPT }],
|
||||
content: [{ type: "text" as const, text: promptText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await complete(model, { messages: summarizationMessages }, { maxTokens, signal, apiKey });
|
||||
const response = await completeSimple(
|
||||
model,
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
{ maxTokens, signal, apiKey, reasoning: "high" },
|
||||
);
|
||||
|
||||
if (response.stopReason === "error") {
|
||||
throw new Error(`Turn prefix summarization failed: ${response.errorMessage || "Unknown error"}`);
|
||||
|
||||
@@ -54,6 +54,8 @@ export type {
|
||||
GetAllToolsHandler,
|
||||
GetThinkingLevelHandler,
|
||||
GrepToolResultEvent,
|
||||
InputEvent,
|
||||
InputEventResult,
|
||||
KeybindingsManager,
|
||||
LoadExtensionsResult,
|
||||
LsToolResultEvent,
|
||||
|
||||
@@ -88,10 +88,21 @@ function createExtensionAPI(
|
||||
});
|
||||
},
|
||||
|
||||
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void {
|
||||
registerCommand(
|
||||
name: string,
|
||||
options: {
|
||||
description?: string;
|
||||
getArgumentCompletions?: RegisteredCommand["getArgumentCompletions"];
|
||||
handler: RegisteredCommand["handler"];
|
||||
},
|
||||
): void {
|
||||
extension.commands.set(name, { name, ...options });
|
||||
},
|
||||
|
||||
setLabel(label: string): void {
|
||||
extension.label = label;
|
||||
},
|
||||
|
||||
registerShortcut(
|
||||
shortcut: KeyId,
|
||||
options: {
|
||||
|
||||
@@ -26,6 +26,8 @@ import type {
|
||||
ExtensionRuntime,
|
||||
ExtensionShortcut,
|
||||
ExtensionUIContext,
|
||||
InputEvent,
|
||||
InputEventResult,
|
||||
MessageRenderer,
|
||||
RegisteredCommand,
|
||||
RegisteredTool,
|
||||
@@ -113,6 +115,7 @@ export class ExtensionRunner {
|
||||
private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
|
||||
private branchHandler: BranchHandler = async () => ({ cancelled: false });
|
||||
private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
|
||||
private compactHandler: (customInstructions?: string) => Promise<void> = async () => {};
|
||||
private shutdownHandler: ShutdownHandler = () => {};
|
||||
|
||||
constructor(
|
||||
@@ -160,6 +163,7 @@ export class ExtensionRunner {
|
||||
this.newSessionHandler = commandContextActions.newSession;
|
||||
this.branchHandler = commandContextActions.branch;
|
||||
this.navigateTreeHandler = commandContextActions.navigateTree;
|
||||
this.compactHandler = commandContextActions.compact;
|
||||
}
|
||||
|
||||
this.uiContext = uiContext ?? noOpUIContext;
|
||||
@@ -328,6 +332,7 @@ export class ExtensionRunner {
|
||||
newSession: (options) => this.newSessionHandler(options),
|
||||
branch: (entryId) => this.branchHandler(entryId),
|
||||
navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options),
|
||||
compact: (customInstructions) => this.compactHandler(customInstructions),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -446,6 +451,53 @@ export class ExtensionRunner {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async emitInput(event: InputEvent): Promise<InputEventResult | undefined> {
|
||||
const ctx = this.createContext();
|
||||
let currentText = event.text;
|
||||
let currentImages = event.images;
|
||||
let changed = false;
|
||||
|
||||
for (const ext of this.extensions) {
|
||||
const handlers = ext.handlers.get("input");
|
||||
if (!handlers || handlers.length === 0) continue;
|
||||
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const handlerResult = await handler({ type: "input", text: currentText, images: currentImages }, ctx);
|
||||
if (handlerResult) {
|
||||
const result = handlerResult as InputEventResult;
|
||||
if (result.text !== undefined) {
|
||||
currentText = result.text;
|
||||
changed = true;
|
||||
}
|
||||
if (result.images !== undefined) {
|
||||
currentImages = result.images;
|
||||
changed = true;
|
||||
}
|
||||
if (result.handled) {
|
||||
return { handled: true, text: currentText, images: currentImages };
|
||||
}
|
||||
}
|
||||
} 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: "input",
|
||||
error: message,
|
||||
stack,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { text: currentText, images: currentImages };
|
||||
}
|
||||
|
||||
async emitContext(messages: AgentMessage[]): Promise<AgentMessage[]> {
|
||||
const ctx = this.createContext();
|
||||
let currentMessages = structuredClone(messages);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type { ImageContent, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
|
||||
import type { Component, EditorComponent, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
|
||||
import type { AutocompleteItem, Component, EditorComponent, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
|
||||
import type { Static, TSchema } from "@sinclair/typebox";
|
||||
import type * as piCodingAgent from "../../index";
|
||||
import type { Theme } from "../../modes/interactive/theme/theme";
|
||||
@@ -172,6 +172,9 @@ export interface ExtensionCommandContext extends ExtensionContext {
|
||||
|
||||
/** Navigate to a different point in the session tree. */
|
||||
navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>;
|
||||
|
||||
/** Compact the session context (interactive mode shows UI). */
|
||||
compact(customInstructions?: string): Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -382,6 +385,17 @@ export interface UserBashEvent {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Input Events
|
||||
// ============================================================================
|
||||
|
||||
/** Fired when the user submits input (interactive mode only). */
|
||||
export interface InputEvent {
|
||||
type: "input";
|
||||
text: string;
|
||||
images?: ImageContent[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tool Events
|
||||
// ============================================================================
|
||||
@@ -486,6 +500,7 @@ export type ExtensionEvent =
|
||||
| TurnStartEvent
|
||||
| TurnEndEvent
|
||||
| UserBashEvent
|
||||
| InputEvent
|
||||
| ToolCallEvent
|
||||
| ToolResultEvent;
|
||||
|
||||
@@ -502,6 +517,16 @@ export interface ToolCallEventResult {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Result from input event handler */
|
||||
export interface InputEventResult {
|
||||
/** If true, the input was handled and should not continue through normal flow */
|
||||
handled?: boolean;
|
||||
/** Replace the input text */
|
||||
text?: string;
|
||||
/** Replace any pending images */
|
||||
images?: ImageContent[];
|
||||
}
|
||||
|
||||
/** Result from user_bash event handler */
|
||||
export interface UserBashEventResult {
|
||||
/** Custom operations to use for execution */
|
||||
@@ -565,6 +590,7 @@ export type MessageRenderer<T = unknown> = (
|
||||
export interface RegisteredCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
getArgumentCompletions?: (argumentPrefix: string) => AutocompleteItem[] | null;
|
||||
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -622,6 +648,7 @@ export interface ExtensionAPI {
|
||||
on(event: "agent_end", handler: ExtensionHandler<AgentEndEvent>): void;
|
||||
on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
|
||||
on(event: "turn_end", handler: ExtensionHandler<TurnEndEvent>): void;
|
||||
on(event: "input", handler: ExtensionHandler<InputEvent, InputEventResult>): void;
|
||||
on(event: "tool_call", handler: ExtensionHandler<ToolCallEvent, ToolCallEventResult>): void;
|
||||
on(event: "tool_result", handler: ExtensionHandler<ToolResultEvent, ToolResultEventResult>): void;
|
||||
on(event: "user_bash", handler: ExtensionHandler<UserBashEvent, UserBashEventResult>): void;
|
||||
@@ -638,7 +665,14 @@ export interface ExtensionAPI {
|
||||
// =========================================================================
|
||||
|
||||
/** Register a custom command. */
|
||||
registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void;
|
||||
registerCommand(
|
||||
name: string,
|
||||
options: {
|
||||
description?: string;
|
||||
getArgumentCompletions?: RegisteredCommand["getArgumentCompletions"];
|
||||
handler: RegisteredCommand["handler"];
|
||||
},
|
||||
): void;
|
||||
|
||||
/** Register a keyboard shortcut. */
|
||||
registerShortcut(
|
||||
@@ -659,6 +693,9 @@ export interface ExtensionAPI {
|
||||
},
|
||||
): void;
|
||||
|
||||
/** Set the display label for this extension. */
|
||||
setLabel(label: string): void;
|
||||
|
||||
/** Get the value of a registered CLI flag. */
|
||||
getFlag(name: string): boolean | string | undefined;
|
||||
|
||||
@@ -802,6 +839,7 @@ export interface ExtensionCommandContextActions {
|
||||
}) => Promise<{ cancelled: boolean }>;
|
||||
branch: (entryId: string) => Promise<{ cancelled: boolean }>;
|
||||
navigateTree: (targetId: string, options?: { summarize?: boolean }) => Promise<{ cancelled: boolean }>;
|
||||
compact: (customInstructions?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Full runtime = state + actions. */
|
||||
@@ -811,6 +849,7 @@ export interface ExtensionRuntime extends ExtensionRuntimeState, ExtensionAction
|
||||
export interface Extension {
|
||||
path: string;
|
||||
resolvedPath: string;
|
||||
label?: string;
|
||||
handlers: Map<string, HandlerFn[]>;
|
||||
tools: Map<string, RegisteredTool>;
|
||||
messageRenderers: Map<string, MessageRenderer>;
|
||||
|
||||
@@ -93,6 +93,53 @@ function isAppAction(action: string): action is AppAction {
|
||||
return APP_ACTIONS.includes(action as AppAction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Key hint formatting utilities for UI labels.
|
||||
*/
|
||||
const MODIFIER_LABELS: Record<string, string> = {
|
||||
ctrl: "Ctrl",
|
||||
shift: "Shift",
|
||||
alt: "Alt",
|
||||
};
|
||||
|
||||
const KEY_LABELS: Record<string, string> = {
|
||||
esc: "Esc",
|
||||
escape: "Esc",
|
||||
enter: "Enter",
|
||||
return: "Enter",
|
||||
space: "Space",
|
||||
tab: "Tab",
|
||||
backspace: "Backspace",
|
||||
delete: "Delete",
|
||||
home: "Home",
|
||||
end: "End",
|
||||
pageup: "PgUp",
|
||||
pagedown: "PgDn",
|
||||
up: "Up",
|
||||
down: "Down",
|
||||
left: "Left",
|
||||
right: "Right",
|
||||
};
|
||||
|
||||
function formatKeyPart(part: string): string {
|
||||
const lower = part.toLowerCase();
|
||||
const modifier = MODIFIER_LABELS[lower];
|
||||
if (modifier) return modifier;
|
||||
const label = KEY_LABELS[lower];
|
||||
if (label) return label;
|
||||
if (part.length === 1) return part.toUpperCase();
|
||||
return `${part.charAt(0).toUpperCase()}${part.slice(1)}`;
|
||||
}
|
||||
|
||||
export function formatKeyHint(key: KeyId): string {
|
||||
return key.split("+").map(formatKeyPart).join("+");
|
||||
}
|
||||
|
||||
export function formatKeyHints(keys: KeyId | KeyId[]): string {
|
||||
const list = Array.isArray(keys) ? keys : [keys];
|
||||
return list.map(formatKeyHint).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages all keybindings (app + editor).
|
||||
*/
|
||||
|
||||
@@ -335,6 +335,21 @@ export function substituteArgs(content: string, args: string[]): string {
|
||||
return args[index] ?? "";
|
||||
});
|
||||
|
||||
result = result.replace(/\$@\[(\d+)(?::(\d*)?)?\]/g, (_, startRaw: string, lengthRaw?: string) => {
|
||||
const start = Number.parseInt(startRaw, 10);
|
||||
if (!Number.isFinite(start) || start < 1) return "";
|
||||
const startIndex = start - 1;
|
||||
if (startIndex >= args.length) return "";
|
||||
|
||||
if (lengthRaw === undefined || lengthRaw === "") {
|
||||
return args.slice(startIndex).join(" ");
|
||||
}
|
||||
|
||||
const length = Number.parseInt(lengthRaw, 10);
|
||||
if (!Number.isFinite(length) || length <= 0) return "";
|
||||
return args.slice(startIndex, startIndex + length).join(" ");
|
||||
});
|
||||
|
||||
// Pre-compute all args joined (optimization)
|
||||
const allArgs = args.join(" ");
|
||||
|
||||
|
||||
@@ -726,7 +726,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
|
||||
// Log MCP errors
|
||||
for (const { path, error } of mcpResult.errors) {
|
||||
console.error(`MCP "${path}": ${error}`);
|
||||
logger.error("MCP tool load failed", { path, error });
|
||||
}
|
||||
|
||||
if (mcpResult.tools.length > 0) {
|
||||
@@ -784,7 +784,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
);
|
||||
time("discoverAndLoadExtensions");
|
||||
for (const { path, error } of extensionsResult.errors) {
|
||||
console.error(`Failed to load extension "${path}": ${error}`);
|
||||
logger.error("Failed to load extension", { path, error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,10 +804,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
}
|
||||
|
||||
// Discover custom commands (TypeScript slash commands)
|
||||
const customCommandsResult = await loadCustomCommandsInternal({ cwd, agentDir });
|
||||
time("discoverCustomCommands");
|
||||
for (const { path, error } of customCommandsResult.errors) {
|
||||
console.error(`Failed to load custom command "${path}": ${error}`);
|
||||
const customCommandsResult: CustomCommandsLoadResult = options.disableExtensionDiscovery
|
||||
? { commands: [], errors: [] }
|
||||
: await loadCustomCommandsInternal({ cwd, agentDir });
|
||||
if (!options.disableExtensionDiscovery) {
|
||||
time("discoverCustomCommands");
|
||||
for (const { path, error } of customCommandsResult.errors) {
|
||||
logger.error("Failed to load custom command", { path, error });
|
||||
}
|
||||
}
|
||||
|
||||
let extensionRunner: ExtensionRunner | undefined;
|
||||
|
||||
@@ -456,8 +456,8 @@ export function loadEntriesFromFile(filePath: string, storage: SessionStorage =
|
||||
|
||||
// Validate session header
|
||||
if (entries.length === 0) return entries;
|
||||
const header = entries[0];
|
||||
if (header.type !== "session" || typeof (header as any).id !== "string") {
|
||||
const header = entries[0] as SessionHeader;
|
||||
if (header.type !== "session" || typeof header.id !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -834,6 +834,85 @@ function getTaskToolUsage(details: unknown): Usage | undefined {
|
||||
return usage as Usage;
|
||||
}
|
||||
|
||||
function extractTextFromContent(content: Message["content"]): string {
|
||||
if (typeof content === "string") return content;
|
||||
return content
|
||||
.filter((block): block is TextContent => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function collectSessionsFromFiles(files: string[], storage: SessionStorage): SessionInfo[] {
|
||||
const sessions: SessionInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = storage.readTextSync(file);
|
||||
const lines = content.trim().split("\n");
|
||||
if (lines.length === 0) continue;
|
||||
|
||||
// Check first line for valid session header
|
||||
type SessionHeaderShape = { type: string; id: string; cwd?: string; title?: string; timestamp: string };
|
||||
let header: SessionHeaderShape | null = null;
|
||||
try {
|
||||
const first = JSON.parse(lines[0]) as SessionHeaderShape;
|
||||
if (first.type === "session" && first.id) {
|
||||
header = first;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON
|
||||
}
|
||||
if (!header) continue;
|
||||
|
||||
const stats = storage.statSync(file);
|
||||
let messageCount = 0;
|
||||
let firstMessage = "";
|
||||
const allMessages: string[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]) as { type?: string; message?: Message };
|
||||
|
||||
if (entry.type === "message" && entry.message) {
|
||||
messageCount++;
|
||||
|
||||
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
||||
const textContent = extractTextFromContent(entry.message.content);
|
||||
|
||||
if (textContent) {
|
||||
allMessages.push(textContent);
|
||||
|
||||
if (!firstMessage && entry.message.role === "user") {
|
||||
firstMessage = textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
path: file,
|
||||
id: header.id,
|
||||
cwd: typeof header.cwd === "string" ? header.cwd : "",
|
||||
title: header.title,
|
||||
created: new Date(header.timestamp),
|
||||
modified: stats.mtime,
|
||||
messageCount,
|
||||
firstMessage: firstMessage || "(no messages)",
|
||||
allMessagesText: allMessages.join(" "),
|
||||
});
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||
return sessions;
|
||||
}
|
||||
|
||||
export class SessionManager {
|
||||
private sessionId: string = "";
|
||||
private sessionTitle: string | undefined;
|
||||
@@ -1699,82 +1778,26 @@ export class SessionManager {
|
||||
*/
|
||||
static list(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionInfo[] {
|
||||
const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
|
||||
const sessions: SessionInfo[] = [];
|
||||
|
||||
try {
|
||||
const files = storage.listFilesSync(dir, "*.jsonl");
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = storage.readTextSync(file);
|
||||
const lines = content.trim().split("\n");
|
||||
if (lines.length === 0) continue;
|
||||
|
||||
// Check first line for valid session header
|
||||
let header: { type: string; id: string; cwd?: string; title?: string; timestamp: string } | null = null;
|
||||
try {
|
||||
const first = JSON.parse(lines[0]);
|
||||
if (first.type === "session" && first.id) {
|
||||
header = first;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON
|
||||
}
|
||||
if (!header) continue;
|
||||
|
||||
const stats = storage.statSync(file);
|
||||
let messageCount = 0;
|
||||
let firstMessage = "";
|
||||
const allMessages: string[] = [];
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
|
||||
if (entry.type === "message") {
|
||||
messageCount++;
|
||||
|
||||
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
||||
const textContent = entry.message.content
|
||||
.filter((c: any) => c.type === "text")
|
||||
.map((c: any) => c.text)
|
||||
.join(" ");
|
||||
|
||||
if (textContent) {
|
||||
allMessages.push(textContent);
|
||||
|
||||
if (!firstMessage && entry.message.role === "user") {
|
||||
firstMessage = textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
path: file,
|
||||
id: header.id,
|
||||
cwd: typeof header.cwd === "string" ? header.cwd : "",
|
||||
title: header.title,
|
||||
created: new Date(header.timestamp),
|
||||
modified: stats.mtime,
|
||||
messageCount,
|
||||
firstMessage: firstMessage || "(no messages)",
|
||||
allMessagesText: allMessages.join(" "),
|
||||
});
|
||||
} catch {
|
||||
// Skip files that can't be read
|
||||
}
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||
return collectSessionsFromFiles(files, storage);
|
||||
} catch {
|
||||
// Return empty list on error
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
/**
|
||||
* List all sessions across all project directories.
|
||||
*/
|
||||
static listAll(storage: SessionStorage = new FileSessionStorage()): SessionInfo[] {
|
||||
const sessionsRoot = join(getDefaultAgentDir(), "sessions");
|
||||
try {
|
||||
const files = Array.from(new Bun.Glob("**/*.jsonl").scanSync(sessionsRoot)).map((name) =>
|
||||
join(sessionsRoot, name),
|
||||
);
|
||||
return collectSessionsFromFiles(files, storage);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ export interface TerminalSettings {
|
||||
showImages?: boolean; // default: true (only relevant if terminal supports images)
|
||||
}
|
||||
|
||||
export interface StartupSettings {
|
||||
quiet?: boolean; // default: false - suppress welcome screen and startup info
|
||||
}
|
||||
|
||||
export interface ImageSettings {
|
||||
autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility)
|
||||
blockImages?: boolean; // default: false - when true, prevents all images from being sent to LLM providers
|
||||
@@ -194,6 +198,7 @@ export interface Settings {
|
||||
hideThinkingBlock?: boolean;
|
||||
shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows)
|
||||
collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full)
|
||||
startup?: StartupSettings;
|
||||
doubleEscapeAction?: "branch" | "tree"; // Action for double-escape with empty editor (default: "tree")
|
||||
thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels
|
||||
/** Environment variables to set automatically on startup */
|
||||
@@ -843,6 +848,18 @@ export class SettingsManager {
|
||||
await this.save();
|
||||
}
|
||||
|
||||
getStartupQuiet(): boolean {
|
||||
return this.settings.startup?.quiet ?? false;
|
||||
}
|
||||
|
||||
async setStartupQuiet(quiet: boolean): Promise<void> {
|
||||
if (!this.globalSettings.startup) {
|
||||
this.globalSettings.startup = {};
|
||||
}
|
||||
this.globalSettings.startup.quiet = quiet;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
getExtensionPaths(): string[] {
|
||||
return [...(this.settings.extensions ?? [])];
|
||||
}
|
||||
|
||||
@@ -178,6 +178,8 @@ interface BashRenderContext {
|
||||
expanded?: boolean;
|
||||
/** Number of preview lines when collapsed */
|
||||
previewLines?: number;
|
||||
/** Timeout in seconds */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// Preview line limit when not expanded (matches tool-execution behavior)
|
||||
@@ -236,6 +238,11 @@ export const bashToolRenderer = {
|
||||
// Build truncation warning lines (static, doesn't depend on width)
|
||||
const truncation = details?.truncation;
|
||||
const fullOutputPath = details?.fullOutputPath;
|
||||
const timeoutSeconds = renderContext?.timeout;
|
||||
const timeoutLine =
|
||||
typeof timeoutSeconds === "number"
|
||||
? uiTheme.fg("dim", ui.wrapBrackets(`Timeout: ${timeoutSeconds}s`))
|
||||
: undefined;
|
||||
let warningLine: string | undefined;
|
||||
if (fullOutputPath || (truncation?.truncated && !showingFullOutput)) {
|
||||
const warnings: string[] = [];
|
||||
@@ -258,7 +265,8 @@ export const bashToolRenderer = {
|
||||
|
||||
if (!displayOutput) {
|
||||
// No output - just show warning if any
|
||||
return new Text(warningLine ?? "", 0, 0);
|
||||
const lines = [timeoutLine, warningLine].filter(Boolean) as string[];
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
@@ -267,7 +275,7 @@ export const bashToolRenderer = {
|
||||
.split("\n")
|
||||
.map((line) => uiTheme.fg("toolOutput", line))
|
||||
.join("\n");
|
||||
const lines = warningLine ? [styledOutput, warningLine] : [styledOutput];
|
||||
const lines = [styledOutput, timeoutLine, warningLine].filter(Boolean) as string[];
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
}
|
||||
|
||||
@@ -300,6 +308,9 @@ export const bashToolRenderer = {
|
||||
outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
|
||||
}
|
||||
outputLines.push(...cachedLines);
|
||||
if (timeoutLine) {
|
||||
outputLines.push(truncateToWidth(timeoutLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
|
||||
}
|
||||
if (warningLine) {
|
||||
outputLines.push(truncateToWidth(warningLine, width, uiTheme.fg("warning", uiTheme.format.ellipsis)));
|
||||
}
|
||||
|
||||
@@ -80,6 +80,13 @@ function computeRelativeIndentDepths(lines: string[]): number[] {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeFuzzyText(text: string): string {
|
||||
return text
|
||||
.replace(/[“”„‟«»]/g, '"')
|
||||
.replace(/[‘’‚‛`´]/g, "'")
|
||||
.replace(/[‐‑‒–—−]/g, "-");
|
||||
}
|
||||
|
||||
function normalizeLinesForMatch(lines: string[], includeDepth = true): string[] {
|
||||
const indentDepths = includeDepth ? computeRelativeIndentDepths(lines) : null;
|
||||
return lines.map((line, index) => {
|
||||
@@ -88,7 +95,8 @@ function normalizeLinesForMatch(lines: string[], includeDepth = true): string[]
|
||||
if (trimmed.length === 0) {
|
||||
return prefix;
|
||||
}
|
||||
const collapsed = trimmed.replace(/[ \t]+/g, " ");
|
||||
const normalized = normalizeFuzzyText(trimmed);
|
||||
const collapsed = normalized.replace(/[ \t]+/g, " ");
|
||||
return `${prefix}${collapsed}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
export { StringEnum } from "@oh-my-pi/pi-ai";
|
||||
// Re-export TUI components for custom tool rendering
|
||||
export { Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
|
||||
export { getAgentDir, VERSION } from "./config";
|
||||
export {
|
||||
AgentSession,
|
||||
type AgentSessionConfig,
|
||||
@@ -82,6 +83,8 @@ export type {
|
||||
ExtensionShortcut,
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
InputEvent,
|
||||
InputEventResult,
|
||||
KeybindingsManager,
|
||||
LoadExtensionsResult,
|
||||
MessageRenderer,
|
||||
@@ -107,6 +110,7 @@ export {
|
||||
} from "./core/extensions/index";
|
||||
// Hook system types (legacy re-export)
|
||||
export type * from "./core/hooks/index";
|
||||
export { formatKeyHint, formatKeyHints } from "./core/keybindings";
|
||||
// Logging
|
||||
export { type Logger, logger } from "./core/logger";
|
||||
export { convertToLlm } from "./core/messages";
|
||||
@@ -269,3 +273,4 @@ export {
|
||||
Theme,
|
||||
type ThemeColor,
|
||||
} from "./modes/interactive/theme/theme";
|
||||
export { getShellConfig } from "./utils/shell";
|
||||
|
||||
@@ -21,12 +21,13 @@ import type { AgentSession } from "./core/agent-session";
|
||||
import { exportFromFile } from "./core/export-html/index";
|
||||
import type { ExtensionUIContext } from "./core/index";
|
||||
import type { ModelRegistry } from "./core/model-registry";
|
||||
import { parseModelPattern, resolveModelScope, type ScopedModel } from "./core/model-resolver";
|
||||
import { parseModelPattern, parseModelString, resolveModelScope, type ScopedModel } from "./core/model-resolver";
|
||||
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage, discoverModels } from "./core/sdk";
|
||||
import { SessionManager } from "./core/session-manager";
|
||||
import { SettingsManager } from "./core/settings-manager";
|
||||
import { resolvePromptInput } from "./core/system-prompt";
|
||||
import { printTimings, time } from "./core/timings";
|
||||
import { initializeWithSettings } from "./discovery";
|
||||
import { runMigrations, showDeprecationWarnings } from "./migrations";
|
||||
import { InteractiveMode, installTerminalCrashHandlers, runPrintMode, runRpcMode } from "./modes/index";
|
||||
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme";
|
||||
@@ -50,6 +51,25 @@ async function checkForNewVersion(currentVersion: string): Promise<string | unde
|
||||
}
|
||||
}
|
||||
|
||||
const writeStdout = (message: string): void => {
|
||||
process.stdout.write(`${message}\n`);
|
||||
};
|
||||
|
||||
const writeStderr = (message: string): void => {
|
||||
process.stderr.write(`${message}\n`);
|
||||
};
|
||||
|
||||
async function readPipedInput(): Promise<string | undefined> {
|
||||
if (process.stdin.isTTY !== false) return undefined;
|
||||
try {
|
||||
const text = await Bun.stdin.text();
|
||||
if (text.trim().length === 0) return undefined;
|
||||
return text;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function runInteractiveMode(
|
||||
session: AgentSession,
|
||||
version: string,
|
||||
@@ -156,7 +176,12 @@ function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string
|
||||
|
||||
// Try to match as session ID (full or partial UUID)
|
||||
const sessions = SessionManager.list(cwd, sessionDir);
|
||||
const matches = sessions.filter((session) => session.id.startsWith(sessionArg));
|
||||
let matches = sessions.filter((session) => session.id.startsWith(sessionArg));
|
||||
|
||||
if (matches.length === 0 && !sessionDir) {
|
||||
const globalSessions = SessionManager.listAll();
|
||||
matches = globalSessions.filter((session) => session.id.startsWith(sessionArg));
|
||||
}
|
||||
|
||||
if (matches.length >= 1) {
|
||||
return matches[0].path; // Already sorted by modified time (most recent first)
|
||||
@@ -279,6 +304,19 @@ function discoverSystemPromptFile(): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Discover APPEND_SYSTEM.md file if no CLI append system prompt was provided */
|
||||
function discoverAppendSystemPromptFile(): string | undefined {
|
||||
const projectPath = findConfigFile("APPEND_SYSTEM.md", { user: false });
|
||||
if (projectPath) {
|
||||
return projectPath;
|
||||
}
|
||||
const globalPath = findConfigFile("APPEND_SYSTEM.md", { user: true });
|
||||
if (globalPath) {
|
||||
return globalPath;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function buildSessionOptions(
|
||||
parsed: Args,
|
||||
scopedModels: ScopedModel[],
|
||||
@@ -293,7 +331,8 @@ async function buildSessionOptions(
|
||||
// Auto-discover SYSTEM.md if no CLI system prompt provided
|
||||
const systemPromptSource = parsed.systemPrompt ?? discoverSystemPromptFile();
|
||||
const resolvedSystemPrompt = resolvePromptInput(systemPromptSource, "system prompt");
|
||||
const resolvedAppendPrompt = resolvePromptInput(parsed.appendSystemPrompt, "append system prompt");
|
||||
const appendPromptSource = parsed.appendSystemPrompt ?? discoverAppendSystemPromptFile();
|
||||
const resolvedAppendPrompt = resolvePromptInput(appendPromptSource, "append system prompt");
|
||||
|
||||
if (sessionManager) {
|
||||
options.sessionManager = sessionManager;
|
||||
@@ -304,10 +343,10 @@ async function buildSessionOptions(
|
||||
const available = modelRegistry.getAvailable();
|
||||
const { model, warning } = parseModelPattern(parsed.model, available);
|
||||
if (warning) {
|
||||
console.warn(chalk.yellow(`Warning: ${warning}`));
|
||||
writeStderr(chalk.yellow(`Warning: ${warning}`));
|
||||
}
|
||||
if (!model) {
|
||||
console.error(chalk.red(`Model "${parsed.model}" not found`));
|
||||
writeStderr(chalk.red(`Model "${parsed.model}" not found`));
|
||||
process.exit(1);
|
||||
}
|
||||
options.model = model;
|
||||
@@ -315,7 +354,22 @@ async function buildSessionOptions(
|
||||
modelRoles: { default: `${model.provider}/${model.id}` },
|
||||
});
|
||||
} else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
|
||||
options.model = scopedModels[0].model;
|
||||
const remembered = settingsManager.getModelRole("default");
|
||||
if (remembered) {
|
||||
const parsedModel = parseModelString(remembered);
|
||||
const rememberedModel = parsedModel
|
||||
? scopedModels.find(
|
||||
(scopedModel) =>
|
||||
scopedModel.model.provider === parsedModel.provider && scopedModel.model.id === parsedModel.id,
|
||||
)
|
||||
: scopedModels.find((scopedModel) => scopedModel.model.id.toLowerCase() === remembered.toLowerCase());
|
||||
if (rememberedModel) {
|
||||
options.model = rememberedModel.model;
|
||||
}
|
||||
}
|
||||
if (!options.model) {
|
||||
options.model = scopedModels[0].model;
|
||||
}
|
||||
}
|
||||
|
||||
// Thinking level
|
||||
@@ -378,13 +432,14 @@ async function buildSessionOptions(
|
||||
}
|
||||
|
||||
// Additional extension paths from CLI
|
||||
const cliExtensionPaths = [...(parsed.extensions ?? []), ...(parsed.hooks ?? [])];
|
||||
const cliExtensionPaths = parsed.noExtensions ? [] : [...(parsed.extensions ?? []), ...(parsed.hooks ?? [])];
|
||||
if (cliExtensionPaths.length > 0) {
|
||||
options.additionalExtensionPaths = cliExtensionPaths;
|
||||
}
|
||||
|
||||
if (parsed.noExtensions) {
|
||||
options.disableExtensionDiscovery = true;
|
||||
options.additionalExtensionPaths = [];
|
||||
}
|
||||
|
||||
return options;
|
||||
@@ -443,7 +498,7 @@ export async function main(args: string[]) {
|
||||
time("discoverModels");
|
||||
|
||||
if (parsed.version) {
|
||||
console.log(VERSION);
|
||||
writeStdout(VERSION);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -462,17 +517,17 @@ export async function main(args: string[]) {
|
||||
try {
|
||||
const outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;
|
||||
const result = await exportFromFile(parsed.export, outputPath);
|
||||
console.log(`Exported to: ${result}`);
|
||||
writeStdout(`Exported to: ${result}`);
|
||||
return;
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Failed to export session";
|
||||
console.error(chalk.red(`Error: ${message}`));
|
||||
writeStderr(chalk.red(`Error: ${message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.mode === "rpc" && parsed.fileArgs.length > 0) {
|
||||
console.error(chalk.red("Error: @file arguments are not supported in RPC mode"));
|
||||
writeStderr(chalk.red("Error: @file arguments are not supported in RPC mode"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -480,13 +535,17 @@ export async function main(args: string[]) {
|
||||
const settingsManager = await SettingsManager.create(cwd);
|
||||
settingsManager.applyEnvironmentVariables();
|
||||
time("SettingsManager.create");
|
||||
const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
|
||||
const pipedInput = await readPipedInput();
|
||||
let { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize());
|
||||
if (pipedInput) {
|
||||
initialMessage = initialMessage ? `${initialMessage}\n${pipedInput}` : pipedInput;
|
||||
}
|
||||
time("prepareInitialMessage");
|
||||
const isInteractive = !parsed.print && parsed.mode === undefined;
|
||||
const autoPrint = pipedInput !== undefined && !parsed.print && parsed.mode === undefined;
|
||||
const isInteractive = !parsed.print && !autoPrint && parsed.mode === undefined;
|
||||
const mode = parsed.mode || "text";
|
||||
|
||||
// Initialize discovery system with settings for provider persistence
|
||||
const { initializeWithSettings } = await import("./discovery");
|
||||
initializeWithSettings(settingsManager);
|
||||
time("initializeWithSettings");
|
||||
|
||||
@@ -524,13 +583,13 @@ export async function main(args: string[]) {
|
||||
const sessions = SessionManager.list(cwd, parsed.sessionDir);
|
||||
time("SessionManager.list");
|
||||
if (sessions.length === 0) {
|
||||
console.log(chalk.dim("No sessions found"));
|
||||
writeStdout(chalk.dim("No sessions found"));
|
||||
return;
|
||||
}
|
||||
const selectedPath = await selectSession(sessions);
|
||||
time("selectSession");
|
||||
if (!selectedPath) {
|
||||
console.log(chalk.dim("No session selected"));
|
||||
writeStdout(chalk.dim("No session selected"));
|
||||
return;
|
||||
}
|
||||
sessionManager = await SessionManager.open(selectedPath);
|
||||
@@ -551,7 +610,7 @@ export async function main(args: string[]) {
|
||||
// Handle CLI --api-key as runtime override (not persisted)
|
||||
if (parsed.apiKey) {
|
||||
if (!sessionOptions.model) {
|
||||
console.error(chalk.red("--api-key requires a model to be specified via --provider/--model or -m/--models"));
|
||||
writeStderr(chalk.red("--api-key requires a model to be specified via --provider/--model or -m/--models"));
|
||||
process.exit(1);
|
||||
}
|
||||
authStorage.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);
|
||||
@@ -578,10 +637,10 @@ export async function main(args: string[]) {
|
||||
time("applyExtensionFlags");
|
||||
|
||||
if (!isInteractive && !session.model) {
|
||||
console.error(chalk.red("No models available."));
|
||||
console.error(chalk.yellow("\nSet an API key environment variable:"));
|
||||
console.error(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
|
||||
console.error(chalk.yellow(`\nOr create ${getModelsPath()}`));
|
||||
writeStderr(chalk.red("No models available."));
|
||||
writeStderr(chalk.yellow("\nSet an API key environment variable:"));
|
||||
writeStderr(" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.");
|
||||
writeStderr(chalk.yellow(`\nOr create ${getModelsPath()}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -612,7 +671,7 @@ export async function main(args: string[]) {
|
||||
return `${scopedModel.model.id}${thinkingStr}`;
|
||||
})
|
||||
.join(", ");
|
||||
console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
|
||||
writeStdout(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
|
||||
}
|
||||
|
||||
installTerminalCrashHandlers();
|
||||
|
||||
@@ -51,11 +51,17 @@ class SessionList implements Component {
|
||||
}
|
||||
|
||||
private filterSessions(query: string): void {
|
||||
this.filteredSessions = fuzzyFilter(
|
||||
this.allSessions,
|
||||
query,
|
||||
(session) => `${session.id} ${session.allMessagesText}`,
|
||||
);
|
||||
this.filteredSessions = fuzzyFilter(this.allSessions, query, (session) => {
|
||||
const parts = [
|
||||
session.id,
|
||||
session.title ?? "",
|
||||
session.cwd ?? "",
|
||||
session.firstMessage ?? "",
|
||||
session.allMessagesText,
|
||||
session.path,
|
||||
];
|
||||
return parts.filter(Boolean).join(" ");
|
||||
});
|
||||
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1));
|
||||
}
|
||||
|
||||
@@ -73,10 +79,16 @@ class SessionList implements Component {
|
||||
if (this.filteredSessions.length === 0) {
|
||||
if (this.showCwd) {
|
||||
// "All" scope - no sessions anywhere that match filter
|
||||
lines.push(theme.fg("muted", " No sessions found"));
|
||||
lines.push(truncateToWidth(theme.fg("muted", " No sessions found"), width, theme.format.ellipsis));
|
||||
} else {
|
||||
// "Current folder" scope - hint to try "all"
|
||||
lines.push(theme.fg("muted", " No sessions in current folder. Press Tab to view all."));
|
||||
lines.push(
|
||||
truncateToWidth(
|
||||
theme.fg("muted", " No sessions in current folder. Press Tab to view all."),
|
||||
width,
|
||||
theme.format.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -139,7 +151,7 @@ class SessionList implements Component {
|
||||
const modified = formatDate(session.modified);
|
||||
const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`;
|
||||
const metadata = ` ${modified} ${theme.sep.dot} ${msgCount}`;
|
||||
const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, ""));
|
||||
const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, theme.format.ellipsis));
|
||||
|
||||
lines.push(metadataLine);
|
||||
lines.push(""); // Blank line between sessions
|
||||
@@ -148,7 +160,7 @@ class SessionList implements Component {
|
||||
// Add scroll indicator if needed
|
||||
if (startIndex > 0 || endIndex < this.filteredSessions.length) {
|
||||
const scrollText = ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`;
|
||||
const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width, ""));
|
||||
const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width, theme.format.ellipsis));
|
||||
lines.push(scrollInfo);
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,15 @@ export const SETTINGS_DEFS: SettingDef[] = [
|
||||
get: (sm) => sm.getCollapseChangelog(),
|
||||
set: (sm, v) => sm.setCollapseChangelog(v),
|
||||
},
|
||||
{
|
||||
id: "startupQuiet",
|
||||
tab: "config",
|
||||
type: "boolean",
|
||||
label: "Startup quiet",
|
||||
description: "Skip welcome screen and startup status messages",
|
||||
get: (sm) => sm.getStartupQuiet(),
|
||||
set: (sm, v) => sm.setStartupQuiet(v),
|
||||
},
|
||||
{
|
||||
id: "doubleEscapeAction",
|
||||
tab: "config",
|
||||
|
||||
@@ -507,6 +507,7 @@ export class ToolExecutionComponent extends Container {
|
||||
context.output = output;
|
||||
context.expanded = this.expanded;
|
||||
context.previewLines = BASH_DEFAULT_PREVIEW_LINES;
|
||||
context.timeout = typeof this.args?.timeout === "number" ? this.args.timeout : undefined;
|
||||
} else if (this.toolName === "edit") {
|
||||
// Edit needs diff preview and renderDiff function
|
||||
context.editDiffPreview = this.editDiffPreview;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isEnter,
|
||||
isEscape,
|
||||
isShiftCtrlO,
|
||||
matchesKey,
|
||||
Spacer,
|
||||
Text,
|
||||
TruncatedText,
|
||||
@@ -696,6 +697,21 @@ class TreeList implements Component {
|
||||
const currentIndex = modes.indexOf(this.filterMode);
|
||||
this.filterMode = modes[(currentIndex + 1) % modes.length];
|
||||
this.applyFilter();
|
||||
} else if (matchesKey(keyData, "alt+d")) {
|
||||
this.filterMode = "default";
|
||||
this.applyFilter();
|
||||
} else if (matchesKey(keyData, "alt+t")) {
|
||||
this.filterMode = "no-tools";
|
||||
this.applyFilter();
|
||||
} else if (matchesKey(keyData, "alt+u")) {
|
||||
this.filterMode = "user-only";
|
||||
this.applyFilter();
|
||||
} else if (matchesKey(keyData, "alt+l")) {
|
||||
this.filterMode = "labeled-only";
|
||||
this.applyFilter();
|
||||
} else if (matchesKey(keyData, "alt+a")) {
|
||||
this.filterMode = "all";
|
||||
this.applyFilter();
|
||||
} else if (isBackspace(keyData)) {
|
||||
if (this.searchQuery.length > 0) {
|
||||
this.searchQuery = this.searchQuery.slice(0, -1);
|
||||
@@ -815,7 +831,7 @@ export class TreeSelectorComponent extends Container {
|
||||
new TruncatedText(
|
||||
theme.fg(
|
||||
"muted",
|
||||
" Up/Down: move. Left/Right: page. l: label. Ctrl+O/Shift+Ctrl+O: filter. Type to search",
|
||||
" Up/Down: move. Left/Right: page. l: label. Ctrl+O/Shift+Ctrl+O: filter. Alt+D/T/U/L/A: filter. Type to search",
|
||||
),
|
||||
0,
|
||||
0,
|
||||
|
||||
@@ -305,6 +305,7 @@ export class CommandController {
|
||||
}
|
||||
|
||||
handleHotkeysCommand(): void {
|
||||
const expandToolsKey = this.ctx.keybindings.getDisplayString("expandTools") || "Ctrl+O";
|
||||
const hotkeys = `
|
||||
**Navigation**
|
||||
| Key | Action |
|
||||
@@ -337,7 +338,7 @@ export class CommandController {
|
||||
| \`Alt+P\` | Select model (temporary) |
|
||||
| \`Ctrl+L\` | Select model (set roles) |
|
||||
| \`Ctrl+R\` | Search prompt history |
|
||||
| \`Ctrl+O\` | Toggle tool output expansion |
|
||||
| \`${expandToolsKey}\` | Toggle tool output expansion |
|
||||
| \`Ctrl+T\` | Toggle todo list expansion |
|
||||
| \`Ctrl+G\` | Edit message in external editor |
|
||||
| \`/\` | Slash commands |
|
||||
@@ -481,7 +482,11 @@ export class CommandController {
|
||||
try {
|
||||
const content = fs.readFileSync(skillPath, "utf-8");
|
||||
const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
|
||||
const message = args ? `${body}\n\n---\n\nUser: ${args}` : body;
|
||||
const metaLines = [`Skill: ${skillPath}`];
|
||||
if (args) {
|
||||
metaLines.push(`User: ${args}`);
|
||||
}
|
||||
const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
|
||||
await this.ctx.session.prompt(message);
|
||||
} catch (err) {
|
||||
this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { InteractiveModeContext, TodoItem } from "../types";
|
||||
export class EventController {
|
||||
private lastReadGroup: ReadToolGroupComponent | undefined = undefined;
|
||||
private lastThinkingCount = 0;
|
||||
private renderedCustomMessages = new Set<string>();
|
||||
|
||||
constructor(private ctx: InteractiveModeContext) {}
|
||||
|
||||
@@ -73,6 +74,11 @@ export class EventController {
|
||||
|
||||
case "message_start":
|
||||
if (event.message.role === "hookMessage" || event.message.role === "custom") {
|
||||
const signature = `${event.message.role}:${event.message.customType}:${event.message.timestamp}`;
|
||||
if (this.renderedCustomMessages.has(signature)) {
|
||||
break;
|
||||
}
|
||||
this.renderedCustomMessages.add(signature);
|
||||
this.resetReadGroup();
|
||||
this.ctx.addMessageToChat(event.message);
|
||||
this.ctx.ui.requestRender();
|
||||
@@ -324,6 +330,8 @@ export class EventController {
|
||||
});
|
||||
this.ctx.statusLine.invalidate();
|
||||
this.ctx.updateEditorTopBorder();
|
||||
} else {
|
||||
this.ctx.showWarning("Auto-compaction failed; continuing without compaction");
|
||||
}
|
||||
await this.ctx.flushCompactionQueue({ willRetry: event.willRetry });
|
||||
this.ctx.ui.requestRender();
|
||||
|
||||
@@ -178,6 +178,13 @@ export class ExtensionUiController {
|
||||
|
||||
return { cancelled: false };
|
||||
},
|
||||
compact: async (customInstructions) => {
|
||||
if (this.ctx.isBackgrounded) {
|
||||
await this.ctx.session.compact(customInstructions);
|
||||
return;
|
||||
}
|
||||
await this.ctx.executeCompaction(customInstructions, false);
|
||||
},
|
||||
};
|
||||
|
||||
extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
|
||||
@@ -335,6 +342,13 @@ export class ExtensionUiController {
|
||||
|
||||
return { cancelled: false };
|
||||
},
|
||||
compact: async (customInstructions) => {
|
||||
if (this.ctx.isBackgrounded) {
|
||||
await this.ctx.session.compact(customInstructions);
|
||||
return;
|
||||
}
|
||||
await this.ctx.executeCompaction(customInstructions, false);
|
||||
},
|
||||
};
|
||||
|
||||
extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
|
||||
|
||||
@@ -59,16 +59,28 @@ export class InputController {
|
||||
this.ctx.ui.onDebug = () => this.ctx.handleDebugCommand();
|
||||
this.ctx.editor.onCtrlL = () => this.ctx.showModelSelector();
|
||||
this.ctx.editor.onCtrlR = () => this.ctx.showHistorySearch();
|
||||
this.ctx.editor.onCtrlO = () => this.toggleToolOutputExpansion();
|
||||
this.ctx.editor.onCtrlT = () => this.ctx.toggleTodoExpansion();
|
||||
this.ctx.editor.onCtrlG = () => this.openExternalEditor();
|
||||
this.ctx.editor.onQuestionMark = () => this.ctx.handleHotkeysCommand();
|
||||
this.ctx.editor.onCtrlV = () => this.handleImagePaste();
|
||||
this.ctx.editor.onAltUp = () => this.handleDequeue();
|
||||
|
||||
// Wire up extension shortcuts
|
||||
this.registerExtensionShortcuts();
|
||||
|
||||
const expandToolsKeys = this.ctx.keybindings.getKeys("expandTools");
|
||||
this.ctx.editor.onCtrlO = expandToolsKeys.includes("ctrl+o") ? () => this.toggleToolOutputExpansion() : undefined;
|
||||
for (const key of expandToolsKeys) {
|
||||
if (key === "ctrl+o") continue;
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => this.toggleToolOutputExpansion());
|
||||
}
|
||||
|
||||
const dequeueKeys = this.ctx.keybindings.getKeys("dequeue");
|
||||
this.ctx.editor.onAltUp = dequeueKeys.includes("alt+up") ? () => this.handleDequeue() : undefined;
|
||||
for (const key of dequeueKeys) {
|
||||
if (key === "alt+up") continue;
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => this.handleDequeue());
|
||||
}
|
||||
|
||||
this.ctx.editor.onChange = (text: string) => {
|
||||
const wasBashMode = this.ctx.isBashMode;
|
||||
this.ctx.isBashMode = text.trimStart().startsWith("!");
|
||||
@@ -122,6 +134,26 @@ export class InputController {
|
||||
|
||||
if (!text) return;
|
||||
|
||||
const runner = this.ctx.session.extensionRunner;
|
||||
let inputImages = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
|
||||
|
||||
if (runner?.hasHandlers("input")) {
|
||||
const result = await runner.emitInput({ type: "input", text, images: inputImages });
|
||||
if (result?.handled) {
|
||||
this.ctx.editor.setText("");
|
||||
this.ctx.pendingImages = [];
|
||||
return;
|
||||
}
|
||||
if (result?.text !== undefined) {
|
||||
text = result.text.trim();
|
||||
}
|
||||
if (result?.images !== undefined) {
|
||||
inputImages = result.images;
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) return;
|
||||
|
||||
// Handle slash commands
|
||||
if (text === "/settings") {
|
||||
this.ctx.showSettingsSelector();
|
||||
@@ -246,7 +278,11 @@ export class InputController {
|
||||
try {
|
||||
const content = fs.readFileSync(skillPath, "utf-8");
|
||||
const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
|
||||
const message = args ? `${body}\n\n---\n\nUser: ${args}` : body;
|
||||
const metaLines = [`Skill: ${skillPath}`];
|
||||
if (args) {
|
||||
metaLines.push(`User: ${args}`);
|
||||
}
|
||||
const message = `${body}\n\n---\n\n${metaLines.join("\n")}`;
|
||||
await this.ctx.session.prompt(message);
|
||||
} catch (err) {
|
||||
this.ctx.showError(`Failed to load skill: ${err instanceof Error ? err.message : String(err)}`);
|
||||
@@ -288,7 +324,7 @@ export class InputController {
|
||||
if (this.ctx.session.isStreaming) {
|
||||
this.ctx.editor.addToHistory(text);
|
||||
this.ctx.editor.setText("");
|
||||
const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
|
||||
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
|
||||
this.ctx.pendingImages = [];
|
||||
await this.ctx.session.prompt(text, { streamingBehavior: "steer", images });
|
||||
this.ctx.updatePendingMessagesDisplay();
|
||||
@@ -317,7 +353,7 @@ export class InputController {
|
||||
|
||||
if (this.ctx.onInputCallback) {
|
||||
// Include any pending images from clipboard paste
|
||||
const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
|
||||
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
|
||||
this.ctx.pendingImages = [];
|
||||
this.ctx.onInputCallback({ text, images });
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import chalk from "chalk";
|
||||
import type { AgentSession, AgentSessionEvent } from "../../core/agent-session";
|
||||
import type { ExtensionUIContext } from "../../core/extensions/index";
|
||||
import { HistoryStorage } from "../../core/history-storage";
|
||||
import type { KeybindingsManager } from "../../core/keybindings";
|
||||
import { KeybindingsManager } from "../../core/keybindings";
|
||||
import { logger } from "../../core/logger";
|
||||
import type { SessionContext, SessionManager } from "../../core/session-manager";
|
||||
import { getRecentSessions } from "../../core/session-manager";
|
||||
@@ -71,6 +71,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
public session: AgentSession;
|
||||
public sessionManager: SessionManager;
|
||||
public settingsManager: SettingsManager;
|
||||
public keybindings: KeybindingsManager;
|
||||
public agent: AgentSession["agent"];
|
||||
public voiceSupervisor: VoiceSupervisor;
|
||||
public historyStorage?: HistoryStorage;
|
||||
@@ -148,6 +149,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.session = session;
|
||||
this.sessionManager = session.sessionManager;
|
||||
this.settingsManager = session.settingsManager;
|
||||
this.keybindings = KeybindingsManager.inMemory();
|
||||
this.agent = session.agent;
|
||||
this.version = version;
|
||||
this.changelogMarkdown = changelogMarkdown;
|
||||
@@ -226,6 +228,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
const hookCommands: SlashCommand[] = (this.session.extensionRunner?.getRegisteredCommands() ?? []).map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description ?? "(hook command)",
|
||||
getArgumentCompletions: cmd.getArgumentCompletions,
|
||||
}));
|
||||
|
||||
// Convert custom commands (TypeScript) to SlashCommand format
|
||||
@@ -259,6 +262,8 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
async init(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
this.keybindings = await KeybindingsManager.create();
|
||||
|
||||
// Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP)
|
||||
this.cleanupUnsubscribe = registerAsyncCleanup(() => this.sessionManager.flush());
|
||||
|
||||
@@ -295,8 +300,34 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
fileTypes: s.fileTypes,
|
||||
})) ?? [];
|
||||
|
||||
// Add welcome header
|
||||
const welcome = new WelcomeComponent(this.version, modelName, providerName, recentSessions, lspServerInfo);
|
||||
const startupQuiet = this.settingsManager.getStartupQuiet();
|
||||
|
||||
if (!startupQuiet) {
|
||||
// Add welcome header
|
||||
const welcome = new WelcomeComponent(this.version, modelName, providerName, recentSessions, lspServerInfo);
|
||||
|
||||
// Setup UI layout
|
||||
this.ui.addChild(new Spacer(1));
|
||||
this.ui.addChild(welcome);
|
||||
this.ui.addChild(new Spacer(1));
|
||||
|
||||
// Add changelog if provided
|
||||
if (this.changelogMarkdown) {
|
||||
this.ui.addChild(new DynamicBorder());
|
||||
if (this.settingsManager.getCollapseChangelog()) {
|
||||
const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
|
||||
const latestVersion = versionMatch ? versionMatch[1] : this.version;
|
||||
const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
|
||||
this.ui.addChild(new Text(condensedText, 1, 0));
|
||||
} else {
|
||||
this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
|
||||
this.ui.addChild(new Spacer(1));
|
||||
this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
|
||||
this.ui.addChild(new Spacer(1));
|
||||
}
|
||||
this.ui.addChild(new DynamicBorder());
|
||||
}
|
||||
}
|
||||
|
||||
// Set terminal title if session already has one (resumed session)
|
||||
const existingTitle = this.sessionManager.getSessionTitle();
|
||||
@@ -304,28 +335,6 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
setTerminalTitle(`pi: ${existingTitle}`);
|
||||
}
|
||||
|
||||
// Setup UI layout
|
||||
this.ui.addChild(new Spacer(1));
|
||||
this.ui.addChild(welcome);
|
||||
this.ui.addChild(new Spacer(1));
|
||||
|
||||
// Add changelog if provided
|
||||
if (this.changelogMarkdown) {
|
||||
this.ui.addChild(new DynamicBorder());
|
||||
if (this.settingsManager.getCollapseChangelog()) {
|
||||
const versionMatch = this.changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
|
||||
const latestVersion = versionMatch ? versionMatch[1] : this.version;
|
||||
const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
|
||||
this.ui.addChild(new Text(condensedText, 1, 0));
|
||||
} else {
|
||||
this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
|
||||
this.ui.addChild(new Spacer(1));
|
||||
this.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
|
||||
this.ui.addChild(new Spacer(1));
|
||||
}
|
||||
this.ui.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
this.ui.addChild(this.chatContainer);
|
||||
this.ui.addChild(this.pendingMessagesContainer);
|
||||
this.ui.addChild(this.statusContainer);
|
||||
@@ -369,6 +378,15 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
|
||||
// Initial top border update
|
||||
this.updateEditorTopBorder();
|
||||
|
||||
if (!startupQuiet) {
|
||||
const templateNames = this.session.promptTemplates.map((template) => template.name).sort();
|
||||
if (templateNames.length > 0) {
|
||||
const preview = templateNames.slice(0, 3).join(", ");
|
||||
const suffix = templateNames.length > 3 ? ` +${templateNames.length - 3} more` : "";
|
||||
this.showStatus(`Loaded prompt templates: ${preview}${suffix}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getUserInput(): Promise<{ text: string; images?: ImageContent[] }> {
|
||||
@@ -510,6 +528,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
// Emit shutdown event to hooks
|
||||
await this.session.emitCustomToolSessionEvent("shutdown");
|
||||
|
||||
if (this.isInitialized) {
|
||||
await this.ui.waitForRender();
|
||||
}
|
||||
|
||||
this.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Pi Coding Agent Theme",
|
||||
"description": "Theme schema for OMP coding agent",
|
||||
"title": "OMP Coding Agent Theme",
|
||||
"description": "Theme schema for the OMP coding agent",
|
||||
"type": "object",
|
||||
"required": ["name", "colors"],
|
||||
"properties": {
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface InteractiveModeContext {
|
||||
session: AgentSession;
|
||||
sessionManager: SessionManager;
|
||||
settingsManager: SettingsManager;
|
||||
keybindings: KeybindingsManager;
|
||||
agent: AgentSession["agent"];
|
||||
voiceSupervisor: VoiceSupervisor;
|
||||
historyStorage?: HistoryStorage;
|
||||
|
||||
@@ -340,7 +340,8 @@ export class UiHelpers {
|
||||
const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
|
||||
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
|
||||
}
|
||||
const hintText = theme.fg("dim", `${theme.tree.hook} Alt+Up to edit`);
|
||||
const dequeueKey = this.ctx.keybindings.getDisplayString("dequeue") || "Alt+Up";
|
||||
const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`);
|
||||
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,9 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
||||
const result = await session.navigateTree(targetId, { summarize: options?.summarize });
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
compact: async (customInstructions) => {
|
||||
await session.compact(customInstructions);
|
||||
},
|
||||
},
|
||||
// No UI context
|
||||
);
|
||||
|
||||
@@ -330,6 +330,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||
const result = await session.navigateTree(targetId, { summarize: options?.summarize });
|
||||
return { cancelled: result.cancelled };
|
||||
},
|
||||
compact: async (customInstructions) => {
|
||||
await session.compact(customInstructions);
|
||||
},
|
||||
},
|
||||
createExtensionUIContext(),
|
||||
);
|
||||
|
||||
@@ -24,6 +24,26 @@ describe("substituteArgs", () => {
|
||||
expect(substituteArgs("Test: $@", ["a", "b", "c"])).toBe("Test: a b c");
|
||||
});
|
||||
|
||||
test("should support $@ slicing with start offset", () => {
|
||||
expect(substituteArgs("Test: $@[2]", ["a", "b", "c"])).toBe("Test: b c");
|
||||
});
|
||||
|
||||
test("should support $@ slicing with start and length", () => {
|
||||
expect(substituteArgs("Test: $@[2:2]", ["a", "b", "c", "d"])).toBe("Test: b c");
|
||||
});
|
||||
|
||||
test("should support $@ slicing with start and trailing colon", () => {
|
||||
expect(substituteArgs("Test: $@[3:]", ["a", "b", "c", "d"])).toBe("Test: c d");
|
||||
});
|
||||
|
||||
test("should handle out-of-range $@ slicing", () => {
|
||||
expect(substituteArgs("Test: $@[5:]", ["a", "b"])).toBe("Test: ");
|
||||
});
|
||||
|
||||
test("should treat non-positive $@ slicing as empty", () => {
|
||||
expect(substituteArgs("Test: $@[0:]", ["a", "b"])).toBe("Test: ");
|
||||
});
|
||||
|
||||
test("should replace $@ and $ARGUMENTS identically", () => {
|
||||
const args = ["foo", "bar", "baz"];
|
||||
expect(substituteArgs("Test: $@", args)).toBe(substituteArgs("Test: $ARGUMENTS", args));
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
|
||||
- Added configurable editor padding via `editorPaddingX` theme option
|
||||
- Added `setMaxHeight()` method to limit editor height with scrolling
|
||||
- Added Emacs-style kill ring for text deletion operations
|
||||
- Added `Alt+D` keybinding to delete words forward
|
||||
- Added `Ctrl+Y` keybinding to yank from kill ring
|
||||
- Added `waitForRender()` method to await pending renders
|
||||
- Added support for shifted symbol keys in keybindings
|
||||
|
||||
### Changed
|
||||
|
||||
- Enhanced Kitty keyboard protocol support for better key detection
|
||||
- Improved cursor positioning with safer bounds checking
|
||||
- Updated editor layout to respect configurable padding
|
||||
- Refactored scrolling logic for better viewport management
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed key detection for shifted symbol characters
|
||||
- Fixed backspace handling with additional codepoint support
|
||||
- Fixed Alt+letter key combinations for better recognition
|
||||
|
||||
## [5.5.0] - 2026-01-18
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
isCtrlRight,
|
||||
isCtrlU,
|
||||
isCtrlW,
|
||||
isCtrlY,
|
||||
isDelete,
|
||||
isEnd,
|
||||
isEnter,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
isShiftEnter,
|
||||
isShiftSpace,
|
||||
isTab,
|
||||
matchesKey,
|
||||
} from "../keys";
|
||||
import type { SymbolTheme } from "../symbols";
|
||||
import type { Component } from "../tui";
|
||||
@@ -231,6 +233,7 @@ export interface EditorTheme {
|
||||
borderColor: (str: string) => string;
|
||||
selectList: SelectListTheme;
|
||||
symbols: SymbolTheme;
|
||||
editorPaddingX?: number;
|
||||
}
|
||||
|
||||
export interface EditorTopBorder {
|
||||
@@ -261,6 +264,12 @@ export class Editor implements Component {
|
||||
|
||||
// Store last render width for cursor navigation
|
||||
private lastWidth: number = 80;
|
||||
private maxHeight?: number;
|
||||
private scrollOffset: number = 0;
|
||||
|
||||
// Emacs-style kill ring
|
||||
private killRing: string[] = [];
|
||||
private lastKillWasKillCommand: boolean = false;
|
||||
|
||||
// Border color (can be changed dynamically)
|
||||
public borderColor: (str: string) => string;
|
||||
@@ -318,6 +327,11 @@ export class Editor implements Component {
|
||||
this.useTerminalCursor = useTerminalCursor;
|
||||
}
|
||||
|
||||
setMaxHeight(maxHeight: number | undefined): void {
|
||||
this.maxHeight = maxHeight;
|
||||
this.scrollOffset = 0;
|
||||
}
|
||||
|
||||
setHistoryStorage(storage: HistoryStorage): void {
|
||||
this.historyStorage = storage;
|
||||
const recent = storage.getRecent(100);
|
||||
@@ -348,18 +362,21 @@ export class Editor implements Component {
|
||||
}
|
||||
|
||||
private isOnFirstVisualLine(): boolean {
|
||||
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
||||
const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX());
|
||||
const visualLines = this.buildVisualLineMap(contentWidth);
|
||||
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
||||
return currentVisualLine === 0;
|
||||
}
|
||||
|
||||
private isOnLastVisualLine(): boolean {
|
||||
const visualLines = this.buildVisualLineMap(this.lastWidth);
|
||||
const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX());
|
||||
const visualLines = this.buildVisualLineMap(contentWidth);
|
||||
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
||||
return currentVisualLine === visualLines.length - 1;
|
||||
}
|
||||
|
||||
private navigateHistory(direction: 1 | -1): void {
|
||||
this.resetKillSequence();
|
||||
if (this.history.length === 0) return;
|
||||
|
||||
const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
|
||||
@@ -391,27 +408,65 @@ export class Editor implements Component {
|
||||
// No cached state to invalidate currently
|
||||
}
|
||||
|
||||
private getEditorPaddingX(): number {
|
||||
const padding = this.theme.editorPaddingX ?? 2;
|
||||
return Math.max(1, padding);
|
||||
}
|
||||
|
||||
private getContentWidth(width: number, paddingX: number): number {
|
||||
return Math.max(0, width - 2 * (paddingX + 1));
|
||||
}
|
||||
|
||||
private getVisibleContentHeight(contentLines: number): number {
|
||||
if (this.maxHeight === undefined) return contentLines;
|
||||
return Math.max(1, this.maxHeight - 2);
|
||||
}
|
||||
|
||||
private updateScrollOffset(contentWidth: number, layoutLines: LayoutLine[], visibleHeight: number): void {
|
||||
if (layoutLines.length <= visibleHeight) {
|
||||
this.scrollOffset = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const visualLines = this.buildVisualLineMap(contentWidth);
|
||||
const cursorLine = this.findCurrentVisualLine(visualLines);
|
||||
if (cursorLine < this.scrollOffset) {
|
||||
this.scrollOffset = cursorLine;
|
||||
} else if (cursorLine >= this.scrollOffset + visibleHeight) {
|
||||
this.scrollOffset = cursorLine - visibleHeight + 1;
|
||||
}
|
||||
|
||||
const maxOffset = Math.max(0, layoutLines.length - visibleHeight);
|
||||
this.scrollOffset = Math.min(this.scrollOffset, maxOffset);
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// Store width for cursor navigation
|
||||
this.lastWidth = width;
|
||||
|
||||
// Box-drawing characters for rounded corners
|
||||
const box = this.theme.symbols.boxRound;
|
||||
const topLeft = this.borderColor(`${box.topLeft}${box.horizontal}`);
|
||||
const topRight = this.borderColor(`${box.horizontal}${box.topRight}`);
|
||||
const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}`);
|
||||
const bottomRight = this.borderColor(`${box.horizontal}${box.bottomRight}`);
|
||||
const paddingX = this.getEditorPaddingX();
|
||||
const borderWidth = paddingX + 1;
|
||||
const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`);
|
||||
const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`);
|
||||
const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${" ".repeat(Math.max(0, paddingX - 1))}`);
|
||||
const bottomRight = this.borderColor(
|
||||
`${" ".repeat(Math.max(0, paddingX - 1))}${box.horizontal}${box.bottomRight}`,
|
||||
);
|
||||
const horizontal = this.borderColor(box.horizontal);
|
||||
|
||||
// Layout the text - content area is width minus 6 for borders (3 left + 3 right)
|
||||
const contentAreaWidth = width - 6;
|
||||
// Layout the text
|
||||
const contentAreaWidth = this.getContentWidth(width, paddingX);
|
||||
const layoutLines = this.layoutText(contentAreaWidth);
|
||||
const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length);
|
||||
this.updateScrollOffset(contentAreaWidth, layoutLines, visibleContentHeight);
|
||||
const visibleLayoutLines = layoutLines.slice(this.scrollOffset, this.scrollOffset + visibleContentHeight);
|
||||
|
||||
const result: string[] = [];
|
||||
|
||||
// Render top border: ╭─ [status content] ────────────────╮
|
||||
// Reserve: 2 for "╭─", 2 for "─╮" = 4 total for corners
|
||||
const topFillWidth = width - 4;
|
||||
const topFillWidth = width - borderWidth * 2;
|
||||
if (this.topBorderContent) {
|
||||
const { content, width: statusWidth } = this.topBorderContent;
|
||||
if (statusWidth <= topFillWidth) {
|
||||
@@ -430,9 +485,8 @@ export class Editor implements Component {
|
||||
}
|
||||
|
||||
// Render each layout line
|
||||
// Content area is width - 6 (for "│ " prefix and " │" suffix borders)
|
||||
const lineContentWidth = width - 6;
|
||||
for (const layoutLine of layoutLines) {
|
||||
const lineContentWidth = contentAreaWidth;
|
||||
for (const layoutLine of visibleLayoutLines) {
|
||||
let displayText = layoutLine.text;
|
||||
let displayWidth = visibleWidth(layoutLine.text);
|
||||
|
||||
@@ -475,16 +529,15 @@ export class Editor implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
// All lines have consistent 6-char borders (3 left + 3 right)
|
||||
const isLastLine = layoutLine === layoutLines[layoutLines.length - 1];
|
||||
// All lines have consistent borders based on padding
|
||||
const isLastLine = layoutLine === visibleLayoutLines[visibleLayoutLines.length - 1];
|
||||
const padding = " ".repeat(Math.max(0, lineContentWidth - displayWidth));
|
||||
|
||||
if (isLastLine) {
|
||||
// Last line: "╰─ " (3) + content + padding + " ─╯" (3) = 6 chars border
|
||||
result.push(`${bottomLeft} ${displayText}${padding} ${bottomRight}`);
|
||||
result.push(`${bottomLeft}${displayText}${padding}${bottomRight}`);
|
||||
} else {
|
||||
const leftBorder = this.borderColor(`${box.vertical} `);
|
||||
const rightBorder = this.borderColor(` ${box.vertical}`);
|
||||
const leftBorder = this.borderColor(`${box.vertical}${" ".repeat(paddingX)}`);
|
||||
const rightBorder = this.borderColor(`${" ".repeat(paddingX)}${box.vertical}`);
|
||||
result.push(leftBorder + displayText + padding + rightBorder);
|
||||
}
|
||||
}
|
||||
@@ -501,11 +554,17 @@ export class Editor implements Component {
|
||||
getCursorPosition(width: number): { row: number; col: number } | null {
|
||||
if (!this.useTerminalCursor) return null;
|
||||
|
||||
const contentWidth = width - 6;
|
||||
const paddingX = this.getEditorPaddingX();
|
||||
const borderWidth = paddingX + 1;
|
||||
const contentWidth = this.getContentWidth(width, paddingX);
|
||||
if (contentWidth <= 0) return null;
|
||||
|
||||
const layoutLines = this.layoutText(contentWidth);
|
||||
const visibleContentHeight = this.getVisibleContentHeight(layoutLines.length);
|
||||
this.updateScrollOffset(contentWidth, layoutLines, visibleContentHeight);
|
||||
|
||||
for (let i = 0; i < layoutLines.length; i++) {
|
||||
if (i < this.scrollOffset || i >= this.scrollOffset + visibleContentHeight) continue;
|
||||
const layoutLine = layoutLines[i];
|
||||
if (!layoutLine || !layoutLine.hasCursor || layoutLine.cursorPos === undefined) continue;
|
||||
|
||||
@@ -516,13 +575,13 @@ export class Editor implements Component {
|
||||
const graphemes = [...segmenter.segment(layoutLine.text)];
|
||||
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
||||
const lastWidth = visibleWidth(lastGrapheme) || 1;
|
||||
const colOffset = 3 + Math.max(0, lineWidth - lastWidth);
|
||||
return { row: 1 + i, col: colOffset };
|
||||
const colOffset = borderWidth + Math.max(0, lineWidth - lastWidth);
|
||||
return { row: 1 + i - this.scrollOffset, col: colOffset };
|
||||
}
|
||||
|
||||
const before = layoutLine.text.slice(0, layoutLine.cursorPos);
|
||||
const colOffset = 3 + visibleWidth(before);
|
||||
return { row: 1 + i, col: colOffset };
|
||||
const colOffset = borderWidth + visibleWidth(before);
|
||||
return { row: 1 + i - this.scrollOffset, col: colOffset };
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -705,6 +764,14 @@ export class Editor implements Component {
|
||||
else if (isAltBackspace(data)) {
|
||||
this.deleteWordBackwards();
|
||||
}
|
||||
// Option/Alt+D - Delete word forwards
|
||||
else if (matchesKey(data, "alt+d")) {
|
||||
this.deleteWordForwards();
|
||||
}
|
||||
// Ctrl+Y - Yank from kill ring
|
||||
else if (isCtrlY(data)) {
|
||||
this.yankFromKillRing();
|
||||
}
|
||||
// Ctrl+A - Move to start of line
|
||||
else if (isCtrlA(data)) {
|
||||
this.moveToLineStart();
|
||||
@@ -743,6 +810,8 @@ export class Editor implements Component {
|
||||
return;
|
||||
}
|
||||
|
||||
this.resetKillSequence();
|
||||
|
||||
// Get text and substitute paste markers with actual content
|
||||
let result = this.state.lines.join("\n").trim();
|
||||
|
||||
@@ -789,9 +858,11 @@ export class Editor implements Component {
|
||||
// Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
|
||||
else if (isAltLeft(data) || isCtrlLeft(data)) {
|
||||
// Word left
|
||||
this.resetKillSequence();
|
||||
this.moveWordBackwards();
|
||||
} else if (isAltRight(data) || isCtrlRight(data)) {
|
||||
// Word right
|
||||
this.resetKillSequence();
|
||||
this.moveWordForwards();
|
||||
}
|
||||
// Arrow keys
|
||||
@@ -943,12 +1014,14 @@ export class Editor implements Component {
|
||||
|
||||
setText(text: string): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.resetKillSequence();
|
||||
this.setTextInternal(text);
|
||||
}
|
||||
|
||||
/** Insert text at the current cursor position */
|
||||
insertText(text: string): void {
|
||||
this.historyIndex = -1;
|
||||
this.resetKillSequence();
|
||||
|
||||
const line = this.state.lines[this.state.cursorLine] || "";
|
||||
const before = line.slice(0, this.state.cursorCol);
|
||||
@@ -965,6 +1038,7 @@ export class Editor implements Component {
|
||||
// All the editor methods from before...
|
||||
private insertCharacter(char: string): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.resetKillSequence();
|
||||
|
||||
const line = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
@@ -1014,6 +1088,7 @@ export class Editor implements Component {
|
||||
|
||||
private handlePaste(pastedText: string): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.resetKillSequence();
|
||||
|
||||
// Clean the pasted text
|
||||
const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
@@ -1114,6 +1189,7 @@ export class Editor implements Component {
|
||||
|
||||
private addNewLine(): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.resetKillSequence();
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
@@ -1135,6 +1211,7 @@ export class Editor implements Component {
|
||||
|
||||
private handleBackspace(): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.resetKillSequence();
|
||||
|
||||
if (this.state.cursorCol > 0) {
|
||||
// Delete grapheme before cursor (handles emojis, combining characters, etc.)
|
||||
@@ -1186,25 +1263,98 @@ export class Editor implements Component {
|
||||
}
|
||||
|
||||
private moveToLineStart(): void {
|
||||
this.resetKillSequence();
|
||||
this.state.cursorCol = 0;
|
||||
}
|
||||
|
||||
private moveToLineEnd(): void {
|
||||
this.resetKillSequence();
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
this.state.cursorCol = currentLine.length;
|
||||
}
|
||||
|
||||
private resetKillSequence(): void {
|
||||
this.lastKillWasKillCommand = false;
|
||||
this.killRingIndex = 0;
|
||||
}
|
||||
|
||||
private recordKill(text: string, direction: "forward" | "backward"): void {
|
||||
if (!text) return;
|
||||
if (this.lastKillWasKillCommand && this.killRing.length > 0) {
|
||||
if (direction === "backward") {
|
||||
this.killRing[0] = text + this.killRing[0];
|
||||
} else {
|
||||
this.killRing[0] = this.killRing[0] + text;
|
||||
}
|
||||
} else {
|
||||
this.killRing.unshift(text);
|
||||
}
|
||||
this.killRingIndex = 0;
|
||||
this.lastKillWasKillCommand = true;
|
||||
}
|
||||
|
||||
private insertTextAtCursor(text: string): void {
|
||||
this.historyIndex = -1;
|
||||
this.resetKillSequence();
|
||||
|
||||
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
|
||||
if (lines.length === 1) {
|
||||
const line = this.state.lines[this.state.cursorLine] || "";
|
||||
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;
|
||||
} else {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
const afterCursor = currentLine.slice(this.state.cursorCol);
|
||||
|
||||
const newLines: string[] = [];
|
||||
for (let i = 0; i < this.state.cursorLine; i++) {
|
||||
newLines.push(this.state.lines[i] || "");
|
||||
}
|
||||
|
||||
newLines.push(beforeCursor + (lines[0] || ""));
|
||||
for (let i = 1; i < lines.length - 1; i++) {
|
||||
newLines.push(lines[i] || "");
|
||||
}
|
||||
newLines.push((lines[lines.length - 1] || "") + afterCursor);
|
||||
|
||||
for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
|
||||
newLines.push(this.state.lines[i] || "");
|
||||
}
|
||||
|
||||
this.state.lines = newLines;
|
||||
this.state.cursorLine += lines.length - 1;
|
||||
this.state.cursorCol = (lines[lines.length - 1] || "").length;
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private yankFromKillRing(): void {
|
||||
if (this.killRing.length === 0) return;
|
||||
this.insertTextAtCursor(this.killRing[0] || "");
|
||||
}
|
||||
|
||||
private deleteToStartOfLine(): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
let deletedText = "";
|
||||
|
||||
if (this.state.cursorCol > 0) {
|
||||
// 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;
|
||||
} else if (this.state.cursorLine > 0) {
|
||||
// At start of line - merge with previous line
|
||||
deletedText = "\n";
|
||||
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
||||
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
||||
this.state.lines.splice(this.state.cursorLine, 1);
|
||||
@@ -1212,6 +1362,8 @@ export class Editor implements Component {
|
||||
this.state.cursorCol = previousLine.length;
|
||||
}
|
||||
|
||||
this.recordKill(deletedText, "backward");
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
@@ -1221,17 +1373,22 @@ export class Editor implements Component {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
let deletedText = "";
|
||||
|
||||
if (this.state.cursorCol < currentLine.length) {
|
||||
// Delete from cursor to end of line
|
||||
deletedText = currentLine.slice(this.state.cursorCol);
|
||||
this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);
|
||||
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
||||
// At end of line - merge with next line
|
||||
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
||||
deletedText = `\n${nextLine}`;
|
||||
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
||||
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
||||
}
|
||||
|
||||
this.recordKill(deletedText, "forward");
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
@@ -1245,6 +1402,7 @@ export class Editor implements Component {
|
||||
// If at start of line, behave like backspace at column 0 (merge with previous line)
|
||||
if (this.state.cursorCol === 0) {
|
||||
if (this.state.cursorLine > 0) {
|
||||
this.recordKill("\n", "backward");
|
||||
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
||||
this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine;
|
||||
this.state.lines.splice(this.state.cursorLine, 1);
|
||||
@@ -1257,9 +1415,39 @@ export class Editor implements Component {
|
||||
const deleteFrom = this.state.cursorCol;
|
||||
this.state.cursorCol = 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.recordKill(deletedText, "backward");
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
private deleteWordForwards(): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
if (this.state.cursorCol >= currentLine.length) {
|
||||
if (this.state.cursorLine < this.state.lines.length - 1) {
|
||||
this.recordKill("\n", "forward");
|
||||
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
||||
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
||||
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
||||
}
|
||||
} else {
|
||||
const oldCursorCol = this.state.cursorCol;
|
||||
this.moveWordForwards();
|
||||
const deleteTo = this.state.cursorCol;
|
||||
this.state.cursorCol = oldCursorCol;
|
||||
|
||||
const deletedText = currentLine.slice(oldCursorCol, deleteTo);
|
||||
this.state.lines[this.state.cursorLine] = currentLine.slice(0, oldCursorCol) + currentLine.slice(deleteTo);
|
||||
this.recordKill(deletedText, "forward");
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
@@ -1269,6 +1457,7 @@ export class Editor implements Component {
|
||||
|
||||
private handleForwardDelete(): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.resetKillSequence();
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
|
||||
@@ -1371,11 +1560,12 @@ export class Editor implements Component {
|
||||
}
|
||||
|
||||
private moveCursor(deltaLine: number, deltaCol: number): void {
|
||||
const width = this.lastWidth;
|
||||
this.resetKillSequence();
|
||||
const contentWidth = this.getContentWidth(this.lastWidth, this.getEditorPaddingX());
|
||||
|
||||
if (deltaLine !== 0) {
|
||||
// Build visual line map for navigation
|
||||
const visualLines = this.buildVisualLineMap(width);
|
||||
const visualLines = this.buildVisualLineMap(contentWidth);
|
||||
const currentVisualLine = this.findCurrentVisualLine(visualLines);
|
||||
|
||||
// Calculate column position within current visual line
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type KeyId, matchesKey } from "./keys";
|
||||
import { type KeyId, matchesKey, parseKey } from "./keys";
|
||||
|
||||
/**
|
||||
* Editor actions that can be bound to keys.
|
||||
@@ -77,6 +77,29 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
|
||||
copy: "ctrl+c",
|
||||
};
|
||||
|
||||
const SHIFTED_SYMBOL_KEYS = new Set<string>([
|
||||
"!",
|
||||
"@",
|
||||
"#",
|
||||
"$",
|
||||
"%",
|
||||
"^",
|
||||
"&",
|
||||
"*",
|
||||
"(",
|
||||
")",
|
||||
"_",
|
||||
"+",
|
||||
"{",
|
||||
"}",
|
||||
"|",
|
||||
":",
|
||||
"<",
|
||||
">",
|
||||
"?",
|
||||
"~",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Manages keybindings for the editor.
|
||||
*/
|
||||
@@ -114,7 +137,12 @@ export class EditorKeybindingsManager {
|
||||
for (const key of keys) {
|
||||
if (matchesKey(data, key)) return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
const parsed = parseKey(data);
|
||||
if (!parsed || !parsed.startsWith("shift+")) return false;
|
||||
const keyName = parsed.slice("shift+".length);
|
||||
if (!SHIFTED_SYMBOL_KEYS.has(keyName)) return false;
|
||||
return keys.includes(keyName as KeyId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+94
-27
@@ -275,9 +275,12 @@ const CODEPOINTS = {
|
||||
enter: 13,
|
||||
space: 32,
|
||||
backspace: 127,
|
||||
backspaceCtrl: 8,
|
||||
kpEnter: 57414, // Numpad Enter (Kitty protocol)
|
||||
} as const;
|
||||
|
||||
const BACKSPACE_CODEPOINTS = new Set([CODEPOINTS.backspace, CODEPOINTS.backspaceCtrl]);
|
||||
|
||||
const ARROW_CODEPOINTS = {
|
||||
up: -1,
|
||||
down: -2,
|
||||
@@ -308,6 +311,8 @@ interface ParsedKittySequence {
|
||||
codepoint: number;
|
||||
modifier: number;
|
||||
eventType: KeyEventType;
|
||||
shiftedCodepoint?: number;
|
||||
baseCodepoint?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -374,14 +379,26 @@ function parseEventType(eventTypeStr: string | undefined): KeyEventType {
|
||||
return "press";
|
||||
}
|
||||
|
||||
function parseKittyKeyCodes(keyCodes: string): {
|
||||
codepoint: number;
|
||||
shiftedCodepoint?: number;
|
||||
baseCodepoint?: number;
|
||||
} {
|
||||
const parts = keyCodes.split(":");
|
||||
const codepoint = parseInt(parts[0]!, 10);
|
||||
const shiftedCodepoint = parts[1] ? parseInt(parts[1], 10) : undefined;
|
||||
const baseCodepoint = parts.length > 2 && parts[2] ? parseInt(parts[2], 10) : undefined;
|
||||
return { codepoint, shiftedCodepoint, baseCodepoint };
|
||||
}
|
||||
|
||||
function parseKittySequence(data: string): ParsedKittySequence | null {
|
||||
// CSI u format: \x1b[<num>u or \x1b[<num>;<mod>u or \x1b[<num>;<mod>:<event>u
|
||||
const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?u$/);
|
||||
const csiUMatch = data.match(/^\x1b\[(\d+(?::\d+)*)(?:;(\d+))?(?::(\d+))?u$/);
|
||||
if (csiUMatch) {
|
||||
const codepoint = parseInt(csiUMatch[1]!, 10);
|
||||
const { codepoint, shiftedCodepoint, baseCodepoint } = parseKittyKeyCodes(csiUMatch[1]!);
|
||||
const modValue = csiUMatch[2] ? parseInt(csiUMatch[2], 10) : 1;
|
||||
const eventType = parseEventType(csiUMatch[3]);
|
||||
return { codepoint, modifier: modValue - 1, eventType };
|
||||
return { codepoint, modifier: modValue - 1, eventType, shiftedCodepoint, baseCodepoint };
|
||||
}
|
||||
|
||||
// Arrow keys with modifier: \x1b[1;<mod>A/B/C/D or \x1b[1;<mod>:<event>A/B/C/D
|
||||
@@ -425,12 +442,21 @@ function parseKittySequence(data: string): ParsedKittySequence | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getKittyCodepoints(parsed: ParsedKittySequence): number[] {
|
||||
const codepoints = new Set<number>();
|
||||
codepoints.add(parsed.codepoint);
|
||||
if (parsed.shiftedCodepoint !== undefined) codepoints.add(parsed.shiftedCodepoint);
|
||||
if (parsed.baseCodepoint !== undefined) codepoints.add(parsed.baseCodepoint);
|
||||
return Array.from(codepoints.values());
|
||||
}
|
||||
|
||||
function matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean {
|
||||
const parsed = parseKittySequence(data);
|
||||
if (!parsed) return false;
|
||||
const actualMod = parsed.modifier & ~LOCK_MASK;
|
||||
const expectedMod = expectedModifier & ~LOCK_MASK;
|
||||
return parsed.codepoint === expectedCodepoint && actualMod === expectedMod;
|
||||
if (actualMod !== expectedMod) return false;
|
||||
return getKittyCodepoints(parsed).includes(expectedCodepoint);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -542,12 +568,25 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
|
||||
case "backspace":
|
||||
if (alt && !ctrl && !shift) {
|
||||
return data === "\x1b\x7f" || matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt);
|
||||
return (
|
||||
data === "\x1b\x7f" ||
|
||||
data === "\x1b\x08" ||
|
||||
matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt) ||
|
||||
matchesKittySequence(data, CODEPOINTS.backspaceCtrl, MODIFIERS.alt)
|
||||
);
|
||||
}
|
||||
if (modifier === 0) {
|
||||
return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0);
|
||||
return (
|
||||
data === "\x7f" ||
|
||||
data === "\x08" ||
|
||||
matchesKittySequence(data, CODEPOINTS.backspace, 0) ||
|
||||
matchesKittySequence(data, CODEPOINTS.backspaceCtrl, 0)
|
||||
);
|
||||
}
|
||||
return matchesKittySequence(data, CODEPOINTS.backspace, modifier);
|
||||
return (
|
||||
matchesKittySequence(data, CODEPOINTS.backspace, modifier) ||
|
||||
matchesKittySequence(data, CODEPOINTS.backspaceCtrl, modifier)
|
||||
);
|
||||
|
||||
case "delete":
|
||||
if (modifier === 0) {
|
||||
@@ -655,6 +694,10 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
return matchesKittySequence(data, codepoint, MODIFIERS.shift);
|
||||
}
|
||||
|
||||
if (alt && !ctrl && !shift && key >= "a" && key <= "z") {
|
||||
return data === `\x1b${key}` || data === `\x1b${key.toUpperCase()}`;
|
||||
}
|
||||
|
||||
if (modifier !== 0) {
|
||||
return matchesKittySequence(data, codepoint, modifier);
|
||||
}
|
||||
@@ -665,6 +708,42 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function keyNameFromCodepoint(codepoint: number): string | undefined {
|
||||
if (codepoint === CODEPOINTS.escape) return "escape";
|
||||
if (codepoint === CODEPOINTS.tab) return "tab";
|
||||
if (codepoint === CODEPOINTS.enter || codepoint === CODEPOINTS.kpEnter) return "enter";
|
||||
if (codepoint === CODEPOINTS.space) return "space";
|
||||
if (BACKSPACE_CODEPOINTS.has(codepoint)) return "backspace";
|
||||
if (codepoint === FUNCTIONAL_CODEPOINTS.delete) return "delete";
|
||||
if (codepoint === FUNCTIONAL_CODEPOINTS.home) return "home";
|
||||
if (codepoint === FUNCTIONAL_CODEPOINTS.end) return "end";
|
||||
if (codepoint === FUNCTIONAL_CODEPOINTS.pageUp) return "pageUp";
|
||||
if (codepoint === FUNCTIONAL_CODEPOINTS.pageDown) return "pageDown";
|
||||
if (codepoint === ARROW_CODEPOINTS.up) return "up";
|
||||
if (codepoint === ARROW_CODEPOINTS.down) return "down";
|
||||
if (codepoint === ARROW_CODEPOINTS.left) return "left";
|
||||
if (codepoint === ARROW_CODEPOINTS.right) return "right";
|
||||
if (codepoint >= 97 && codepoint <= 122) return String.fromCharCode(codepoint);
|
||||
const char = String.fromCharCode(codepoint);
|
||||
if (SYMBOL_KEYS.has(char)) return char;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveKittyKeyName(parsed: ParsedKittySequence, effectiveMod: number): string | undefined {
|
||||
if (effectiveMod & MODIFIERS.shift && parsed.shiftedCodepoint !== undefined) {
|
||||
const keyName = keyNameFromCodepoint(parsed.shiftedCodepoint);
|
||||
if (keyName) return keyName;
|
||||
}
|
||||
if (parsed.baseCodepoint !== undefined) {
|
||||
const keyName = keyNameFromCodepoint(parsed.baseCodepoint);
|
||||
if (keyName) return keyName;
|
||||
}
|
||||
const fallback = keyNameFromCodepoint(parsed.codepoint);
|
||||
if (fallback) return fallback;
|
||||
if (parsed.shiftedCodepoint !== undefined) return keyNameFromCodepoint(parsed.shiftedCodepoint);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse input data and return the key identifier if recognized.
|
||||
*
|
||||
@@ -674,31 +753,14 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
export function parseKey(data: string): string | undefined {
|
||||
const kitty = parseKittySequence(data);
|
||||
if (kitty) {
|
||||
const { codepoint, modifier } = kitty;
|
||||
const { modifier } = kitty;
|
||||
const mods: string[] = [];
|
||||
const effectiveMod = modifier & ~LOCK_MASK;
|
||||
if (effectiveMod & MODIFIERS.shift) mods.push("shift");
|
||||
if (effectiveMod & MODIFIERS.ctrl) mods.push("ctrl");
|
||||
if (effectiveMod & MODIFIERS.alt) mods.push("alt");
|
||||
|
||||
let keyName: string | undefined;
|
||||
if (codepoint === CODEPOINTS.escape) keyName = "escape";
|
||||
else if (codepoint === CODEPOINTS.tab) keyName = "tab";
|
||||
else if (codepoint === CODEPOINTS.enter || codepoint === CODEPOINTS.kpEnter) keyName = "enter";
|
||||
else if (codepoint === CODEPOINTS.space) keyName = "space";
|
||||
else if (codepoint === CODEPOINTS.backspace) keyName = "backspace";
|
||||
else if (codepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = "delete";
|
||||
else if (codepoint === FUNCTIONAL_CODEPOINTS.home) keyName = "home";
|
||||
else if (codepoint === FUNCTIONAL_CODEPOINTS.end) keyName = "end";
|
||||
else if (codepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = "pageUp";
|
||||
else if (codepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = "pageDown";
|
||||
else if (codepoint === ARROW_CODEPOINTS.up) keyName = "up";
|
||||
else if (codepoint === ARROW_CODEPOINTS.down) keyName = "down";
|
||||
else if (codepoint === ARROW_CODEPOINTS.left) keyName = "left";
|
||||
else if (codepoint === ARROW_CODEPOINTS.right) keyName = "right";
|
||||
else if (codepoint >= 97 && codepoint <= 122) keyName = String.fromCharCode(codepoint);
|
||||
else if (SYMBOL_KEYS.has(String.fromCharCode(codepoint))) keyName = String.fromCharCode(codepoint);
|
||||
|
||||
const keyName = resolveKittyKeyName(kitty, effectiveMod);
|
||||
if (keyName) {
|
||||
return mods.length > 0 ? `${mods.join("+")}+${keyName}` : keyName;
|
||||
}
|
||||
@@ -720,7 +782,12 @@ export function parseKey(data: string): string | undefined {
|
||||
if (data === "\x7f" || data === "\x08") return "backspace";
|
||||
if (data === "\x1b[Z") return "shift+tab";
|
||||
if (!kittyProtocolActive && data === "\x1b\r") return "alt+enter";
|
||||
if (data === "\x1b\x7f") return "alt+backspace";
|
||||
if (data === "\x1b\x7f" || data === "\x1b\x08") return "alt+backspace";
|
||||
if (data.length === 2 && data.startsWith("\x1b")) {
|
||||
const letter = data[1];
|
||||
if (letter >= "a" && letter <= "z") return `alt+${letter}`;
|
||||
if (letter >= "A" && letter <= "Z") return `alt+${letter.toLowerCase()}`;
|
||||
}
|
||||
if (data === "\x1b[A") return "up";
|
||||
if (data === "\x1b[B") return "down";
|
||||
if (data === "\x1b[C") return "right";
|
||||
|
||||
@@ -136,7 +136,8 @@ export class ProcessTerminal implements Terminal {
|
||||
// Enable Kitty keyboard protocol (push flags)
|
||||
// Flag 1 = disambiguate escape codes
|
||||
// Flag 2 = report event types (press/repeat/release)
|
||||
process.stdout.write("\x1b[>3u");
|
||||
// Flag 4 = report alternate keys
|
||||
process.stdout.write("\x1b[>7u");
|
||||
return; // Don't forward protocol response to TUI
|
||||
}
|
||||
}
|
||||
|
||||
+18
-6
@@ -370,6 +370,20 @@ export class TUI extends Container {
|
||||
});
|
||||
}
|
||||
|
||||
async waitForRender(): Promise<void> {
|
||||
if (!this.renderRequested && !this.rendering) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
if (!this.renderRequested && !this.rendering) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(check, 0);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
private areCursorsEqual(
|
||||
left: { row: number; col: number } | null,
|
||||
right: { row: number; col: number } | null,
|
||||
@@ -385,13 +399,11 @@ export class TUI extends Container {
|
||||
cursor: { row: number; col: number } | null,
|
||||
currentCursorRow: number,
|
||||
): void {
|
||||
if (!cursor || totalLines <= 0) {
|
||||
this.terminal.hideCursor();
|
||||
return;
|
||||
}
|
||||
const safeTotalLines = Math.max(totalLines, 1);
|
||||
const resolvedCursor = cursor ?? { row: safeTotalLines - 1, col: 0 };
|
||||
|
||||
const targetRow = Math.max(0, Math.min(cursor.row, totalLines - 1));
|
||||
const targetCol = Math.max(0, Math.min(cursor.col, width - 1));
|
||||
const targetRow = Math.max(0, Math.min(resolvedCursor.row, safeTotalLines - 1));
|
||||
const targetCol = Math.max(0, Math.min(resolvedCursor.col, width - 1));
|
||||
const rowDelta = targetRow - currentCursorRow;
|
||||
|
||||
let buffer = "";
|
||||
|
||||
Reference in New Issue
Block a user