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:
can1357
2026-01-18 17:12:18 +01:00
parent 095d01a8ac
commit f1d71fbd9d
48 changed files with 1227 additions and 255 deletions
+2 -2
View File
@@ -5,8 +5,8 @@ Use it for any merge: single file, feature branch, or full release sync.
## Last Sync Point
**Commit:** `b74535dc`
**Date:** January 2026
**Commit:** `72047c05`
**Date:** 2026-01-14
When starting a new sync, generate patches from this commit forward:
+19
View File
@@ -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
+12 -4
View File
@@ -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) }
+6 -2
View File
@@ -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);
};
+16 -1
View File
@@ -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");
+25 -12
View File
@@ -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);
+38 -3
View File
@@ -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 {
+30
View File
@@ -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("]");
+32
View File
@@ -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
+2
View File
@@ -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(" ");
+10 -6
View File
@@ -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 ?? [])];
}
+13 -2
View File
@@ -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
View File
@@ -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";
+81 -22
View File
@@ -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));
+22
View File
@@ -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
+216 -26
View File
@@ -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
+30 -2
View File
@@ -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
View File
@@ -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";
+2 -1
View File
@@ -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
View File
@@ -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 = "";