fix(coding-agent): backported pi-mono changes (1feccfed..b21b42d0)

packages/ai:
- feat: expose provider responseId on AssistantMessage
- feat: lazy-load provider modules for faster startup
- fix: hash foreign Responses API tool call IDs exceeding 64-char limit
- fix: ignore null chunks in openai-completions streams
- fix: keep image tool results inline for Gemini 3+ and Antigravity
- fix: correct Bedrock Claude 4.6 context window to 200k
- fix: support prompt caching for Bedrock application inference profiles
- fix: add OpenRouter reasoning payload format
- fix: ignore placeholder Vertex API keys
- fix: skip AJV validation in restricted runtimes
- fix: Anthropic OAuth client injection and responseId extraction
- fix: Codex incomplete/failed response status handling

packages/agent:
- fix: defer steering until after tool execution completes

packages/tui:
- feat: namespaced keybinding IDs with KeybindingsManager conflict detection
- feat: configurable select list column sizing (#2154 by @markusylisiurunen)
- fix: stream truncateToWidth for large strings
- fix: skip Termux height redraws
- fix: stop evicting unrelated default keybindings
- fix: resolve raw backspace ambiguity on Windows Terminal
- fix: clear stale scrollback on session switch (#2155 by @Perlence)
- fix: remove trailing markdown block spacing (#2152 by @markusylisiurunen)

packages/coding-agent:
- feat: add resizable share sidebar (#2435 by @dmmulroy)
- feat: emit OSC 133 command-executed marker
- feat: reload custom themes from disk watcher
- feat: add --fork session flag
- feat: file mutation queue for serialized writes
- feat: initial message consolidation utility
- fix: keybindings migrated to namespaced IDs
- fix: resolve waitForRetry() race when auto-retry produces tool calls
- fix: handle slash-delimited /model refs
- fix: refresh active model after provider updates
- fix: extended transient error patterns for retry
This commit is contained in:
can1357
2026-03-22 17:57:21 +01:00
parent c9a7bb0c96
commit 7fb18faf4c
60 changed files with 2581 additions and 939 deletions
+3 -3
View File
@@ -5,15 +5,15 @@ Use it for any merge: single file, feature branch, or full release sync.
## Last Sync Point
**Commit:** `1feccfedcb1eeeca91be0b9d389e8e5a9daee505`
**Date:** 2026-03-14
**Commit:** `b21b42d032919de2f2e6920a76fa9a37c3920c0a`
**Date:** 2026-03-22
Update this section after each sync; do not reuse the previous range.
When starting a new sync, generate patches from this commit forward:
```bash
git format-patch 15e0957b045d9e0d49253b2285cb585cf3a75c55..HEAD --stdout > changes.patch
git format-patch b21b42d032919de2f2e6920a76fa9a37c3920c0a..HEAD --stdout > changes.patch
```
## 0) Define the scope
+29 -35
View File
@@ -196,7 +196,6 @@ async function runLoop(
// Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) {
let hasMoreToolCalls = true;
let steeringAfterTools: AgentMessage[] | null = null;
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
@@ -225,6 +224,7 @@ async function runLoop(
// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
newMessages.push(message);
let steeringMessagesFromExecution: AgentMessage[] | undefined;
if (message.stopReason === "error" || message.stopReason === "aborted") {
// Create placeholder tool results for any tool calls in the aborted message
@@ -250,19 +250,20 @@ async function runLoop(
const toolResults: ToolResultMessage[] = [];
if (hasMoreToolCalls) {
const toolExecution = await executeToolCalls(
const executionResult = await executeToolCalls(
currentContext.tools,
message,
signal,
stream,
config.getSteeringMessages,
config.getToolContext,
config.interruptMode,
config.getToolContext,
config.transformToolCallArguments,
config.intentTracing,
);
toolResults.push(...toolExecution.toolResults);
steeringAfterTools = toolExecution.steeringMessages ?? null;
toolResults.push(...executionResult.toolResults);
steeringMessagesFromExecution = executionResult.steeringMessages;
for (const result of toolResults) {
currentContext.messages.push(result);
@@ -272,13 +273,7 @@ async function runLoop(
stream.push({ type: "turn_end", message, toolResults });
// Get steering messages after turn completes
if (steeringAfterTools && steeringAfterTools.length > 0) {
pendingMessages = steeringAfterTools;
steeringAfterTools = null;
} else {
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
pendingMessages = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
}
// Agent would stop here. Check for follow-up messages.
@@ -433,25 +428,37 @@ async function executeToolCalls(
signal: AbortSignal | undefined,
stream: EventStream<AgentEvent, AgentMessage[]>,
getSteeringMessages?: AgentLoopConfig["getSteeringMessages"],
getToolContext?: AgentLoopConfig["getToolContext"],
interruptMode: AgentLoopConfig["interruptMode"] = "immediate",
getToolContext?: AgentLoopConfig["getToolContext"],
transformToolCallArguments?: AgentLoopConfig["transformToolCallArguments"],
intentTracing?: AgentLoopConfig["intentTracing"],
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall");
const emittedToolResults: ToolResultMessage[] = [];
let steeringMessages: AgentMessage[] | undefined;
const shouldInterruptImmediately = interruptMode !== "wait";
const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
const shouldInterruptImmediately = interruptMode !== "wait";
const steeringAbortController = new AbortController();
const toolSignal = signal
? AbortSignal.any([signal, steeringAbortController.signal])
: steeringAbortController.signal;
const interruptState = { triggered: false };
let steeringMessages: AgentMessage[] | undefined;
let steeringCheck: Promise<void> | null = null;
const records = toolCalls.map(toolCall => ({
toolCall,
tool: tools?.find(t => t.name === toolCall.name),
args: toolCall.arguments as Record<string, unknown>,
started: false,
result: undefined as AgentToolResult<any> | undefined,
isError: false,
skipped: false,
toolResultMessage: undefined as ToolResultMessage | undefined,
resultEmitted: false,
}));
const checkSteering = async (): Promise<void> => {
if (!shouldInterruptImmediately || !getSteeringMessages || interruptState.triggered) {
return;
@@ -473,18 +480,6 @@ async function executeToolCalls(
await steeringCheck;
};
const records = toolCalls.map(toolCall => ({
toolCall,
tool: tools?.find(t => t.name === toolCall.name),
args: toolCall.arguments as Record<string, unknown>,
started: false,
result: undefined as AgentToolResult<any> | undefined,
isError: false,
skipped: false,
toolResultMessage: undefined as ToolResultMessage | undefined,
resultEmitted: false,
}));
const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
if (record.resultEmitted) return;
const { toolCall } = record;
@@ -578,7 +573,6 @@ async function executeToolCalls(
transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs,
tool.nonAbortable ? undefined : toolSignal,
partialResult => {
if (interruptState.triggered) return;
stream.push({
type: "tool_execution_update",
toolCallId: toolCall.id,
@@ -637,13 +631,6 @@ async function executeToolCalls(
return { toolResults: emittedToolResults, steeringMessages };
}
function createSkippedToolResult(): AgentToolResult<any> {
return {
content: [{ type: "text", text: "Skipped due to queued user message." }],
details: {},
};
}
/**
* Create a tool result for a tool call that was aborted or errored before execution.
* Maintains the tool_use/tool_result pairing required by the API.
@@ -690,3 +677,10 @@ function createAbortedToolResult(
return toolResultMessage;
}
function createSkippedToolResult(): AgentToolResult<any> {
return {
content: [{ type: "text", text: "Skipped due to queued user message." }],
details: {},
};
}
+20 -35
View File
@@ -603,33 +603,16 @@ describe("agentLoop with AgentMessage", () => {
expect(text).not.toContain("Tool execution was aborted.:");
}
});
it("should inject queued messages and skip remaining tool calls", async () => {
it("should skip remaining tool calls when steering is queued", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const executed: string[] = [];
const { promise: allowSecond, resolve: allowSecondResolve } = Promise.withResolvers<void>();
const tool: AgentTool<typeof toolSchema, { value: string }> = {
name: "echo",
label: "Echo",
description: "Echo tool",
parameters: toolSchema,
async execute(_toolCallId, params, signal) {
if (params.value === "second") {
await new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Tool aborted"));
return;
}
const onAbort = () => reject(new Error("Tool aborted"));
signal?.addEventListener("abort", onAbort, { once: true });
allowSecond.then(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
});
});
if (signal?.aborted) {
throw new Error("Tool aborted");
}
}
concurrency: "exclusive",
async execute(_toolCallId, params) {
executed.push(params.value);
return {
content: [{ type: "text", text: `ok:${params.value}` }],
@@ -654,11 +637,11 @@ describe("agentLoop with AgentMessage", () => {
const config: AgentLoopConfig = {
model: createModel(),
convertToLlm: identityConverter,
interruptMode: "immediate",
getSteeringMessages: async () => {
// Return queued message after first tool executes
if (executed.length === 1 && !queuedDelivered) {
// Return steering message after tool execution has started
if (executed.length >= 1 && !queuedDelivered) {
queuedDelivered = true;
allowSecondResolve();
return [queuedUserMessage];
}
return [];
@@ -700,29 +683,31 @@ describe("agentLoop with AgentMessage", () => {
events.push(event);
}
// Only first tool should have executed
// Only the first tool should execute; the second is skipped after steering is queued.
expect(executed).toEqual(["first"]);
// Second tool should be skipped
const toolEnds = events.filter(
(e): e is Extract<AgentEvent, { type: "tool_execution_end" }> => e.type === "tool_execution_end",
);
expect(toolEnds.length).toBe(2);
expect(toolEnds[0].isError).toBeFalsy();
expect(toolEnds[0].isError).toBe(false);
expect(toolEnds[1].isError).toBe(true);
if (toolEnds[1].result.content[0]?.type === "text") {
expect(toolEnds[1].result.content[0].text).toContain("Skipped due to queued user message");
}
// Queued message should appear in events
const queuedMessageEvent = events.find(
e =>
e.type === "message_start" &&
e.message.role === "user" &&
typeof e.message.content === "string" &&
e.message.content === "interrupt",
);
expect(queuedMessageEvent).toBeDefined();
// Queued message should appear in events after the tool results and before the next model call.
const eventSequence = events.flatMap(event => {
if (event.type !== "message_start") return [];
if (event.message.role === "toolResult") return [`tool:${event.message.toolCallId}`];
if (event.message.role === "user" && typeof event.message.content === "string") {
return [event.message.content];
}
return [];
});
expect(eventSequence).toContain("interrupt");
expect(eventSequence.indexOf("tool:tool-1")).toBeLessThan(eventSequence.indexOf("interrupt"));
expect(eventSequence.indexOf("tool:tool-2")).toBeLessThan(eventSequence.indexOf("interrupt"));
// Interrupt message should be in context when second LLM call is made
expect(sawInterruptInContext).toBe(true);
+1
View File
@@ -16,6 +16,7 @@ export * from "./providers/google";
export * from "./providers/google-gemini-cli";
export * from "./providers/google-vertex";
export * from "./providers/kimi";
export type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses";
export * from "./providers/openai-completions";
export * from "./providers/openai-responses";
export * from "./providers/synthetic";
@@ -360,6 +360,13 @@ function handleContentBlockStop(
/**
* Check if the model supports prompt caching.
* Supported: Claude 3.5 Haiku, Claude 3.7 Sonnet, Claude 4.x+ models, Haiku 4.5+
*
* For base models and system-defined inference profiles the model ID / ARN
* contains the model name, so we can decide locally.
*
* For application inference profiles (whose ARNs don't contain the model name),
* set AWS_BEDROCK_FORCE_CACHE=1 to enable cache points. Amazon Nova models
* have automatic caching and don't need explicit cache points.
*/
function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean {
if (model.cost.cacheRead || model.cost.cacheWrite) return true;
@@ -370,6 +377,9 @@ function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean
if (id.includes("claude-3-7-sonnet") || id.includes("claude-3-5-haiku")) return true;
// Claude Haiku 4.5+ (new naming)
if (id.includes("claude-haiku")) return true;
// Application inference profiles don't contain the model name in the ARN.
// Allow users to force cache points via environment variable.
if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") return true;
return false;
}
+31 -12
View File
@@ -376,6 +376,12 @@ export interface AnthropicOptions extends StreamOptions {
betas?: string[] | string;
/** Force OAuth bearer auth mode for proxy tokens that don't match Anthropic token prefixes. */
isOAuth?: boolean;
/**
* Pre-built Anthropic client instance. When provided, skips internal client
* construction entirely. Use this to inject alternative SDK clients such as
* `AnthropicVertex` that shares the same messaging API.
*/
client?: Anthropic;
}
export type AnthropicClientOptionsArgs = {
@@ -611,19 +617,31 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
let rawRequestDump: RawHttpRequestDump | undefined;
try {
const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? "";
const baseUrl = resolveAnthropicBaseUrl(model, apiKey) ?? "https://api.anthropic.com";
let client: Anthropic;
let isOAuthToken: boolean;
const { client, isOAuthToken } = createClient(model, {
model,
apiKey,
extraBetas: normalizeExtraBetas(options?.betas),
stream: true,
interleavedThinking: options?.interleavedThinking ?? true,
headers: options?.headers,
dynamicHeaders: copilotDynamicHeaders?.headers,
isOAuth: options?.isOAuth,
});
if (options?.client) {
client = options.client;
isOAuthToken = false;
} else {
const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? "";
const created = createClient(model, {
model,
apiKey,
extraBetas: normalizeExtraBetas(options?.betas),
stream: true,
interleavedThinking: options?.interleavedThinking ?? true,
headers: options?.headers,
dynamicHeaders: copilotDynamicHeaders?.headers,
isOAuth: options?.isOAuth,
});
client = created.client;
isOAuthToken = created.isOAuthToken;
}
const baseUrl =
resolveAnthropicBaseUrl(model, options?.apiKey ?? getEnvApiKey(model.provider) ?? "") ??
"https://api.anthropic.com";
let params = buildParams(model, baseUrl, context, isOAuthToken, options);
const replacementPayload = await options?.onPayload?.(params, model);
if (replacementPayload !== undefined) {
@@ -661,6 +679,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
for await (const event of anthropicStream) {
started = true;
if (event.type === "message_start") {
output.responseId = event.message.id;
// Capture initial token usage from message_start event
// This ensures we have input token counts even if the stream is aborted early
output.usage.input = event.message.usage.input_tokens || 0;
+21 -8
View File
@@ -68,6 +68,20 @@ export function requiresToolCallId(modelId: string): boolean {
return modelId.startsWith("claude-");
}
function getGeminiMajorVersion(modelId: string): number | undefined {
const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/);
if (!match) return undefined;
return Number.parseInt(match[1], 10);
}
function supportsMultimodalFunctionResponse(modelId: string): boolean {
const geminiMajorVersion = getGeminiMajorVersion(modelId);
if (geminiMajorVersion !== undefined) {
return geminiMajorVersion >= 3;
}
return true;
}
function isGemini3Model(modelId: string): boolean {
return modelId.includes("gemini-3");
}
@@ -189,10 +203,10 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
const hasText = textResult.length > 0;
const hasImages = imageContent.length > 0;
// Gemini 3 supports multimodal function responses with images nested inside functionResponse.parts
// See: https://ai.google.dev/gemini-api/docs/function-calling#multimodal
// Older models don't support this, so we put images in a separate user message.
const supportsMultimodalFunctionResponse = model.id.includes("gemini-3");
// Gemini 3+ models support multimodal function responses with images nested inside
// functionResponse.parts. Claude and other non-Gemini models behind Cloud Code Assist /
// Antigravity also accept this shape. Gemini < 3 still needs a separate user image turn.
const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id);
// Use "output" key for success, "error" key for errors as per SDK documentation
const responseValue = hasText ? textResult.toWellFormed() : hasImages ? "(see attached image)" : "";
@@ -209,8 +223,7 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
functionResponse: {
name: msg.toolName,
response: msg.isError ? { error: responseValue } : { output: responseValue },
// Nest images inside functionResponse.parts for Gemini 3
...(hasImages && supportsMultimodalFunctionResponse && { parts: imageParts }),
...(hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }),
...(includeId ? { id: msg.toolCallId } : {}),
},
};
@@ -231,8 +244,8 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
});
}
// For older models, add images in a separate user message
if (hasImages && !supportsMultimodalFunctionResponse) {
// For Gemini < 3, add images in a separate user message
if (hasImages && !modelSupportsMultimodalFunctionResponse) {
contents.push({
role: "user",
parts: [{ text: "Tool result image:" }, ...imageParts],
@@ -811,7 +811,7 @@ function handleCodexStreamEvent(args: {
return handleResponseCreated(runtime, rawEvent);
}
if (eventType === "response.completed" || eventType === "response.done") {
if (eventType === "response.completed" || eventType === "response.done" || eventType === "response.incomplete") {
handleResponseCompleted(model, output, runtime, rawEvent);
return firstTokenTime;
}
@@ -1046,6 +1046,9 @@ function handleResponseCompleted(
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
if (typeof response?.id === "string" && response.id.length > 0) {
output.responseId = response.id;
}
const state = runtime.websocketState;
if (runtime.transport === "websocket" && state) {
@@ -1764,6 +1767,7 @@ class CodexWebSocketConnection {
if (
eventType === "response.completed" ||
eventType === "response.done" ||
eventType === "response.incomplete" ||
eventType === "response.failed" ||
eventType === "error"
) {
@@ -89,7 +89,13 @@ export function detectOpenAICompat(model: Model<"openai-completions">): Resolved
requiresAssistantAfterToolResult: false,
requiresThinkingAsText: isMistral,
requiresMistralToolIds: isMistral,
thinkingFormat: isZai ? "zai" : isAlibaba || isQwen ? "qwen" : "openai",
thinkingFormat: isZai
? "zai"
: provider === "openrouter" || baseUrl.includes("openrouter.ai")
? "openrouter"
: isAlibaba || isQwen
? "qwen"
: "openai",
reasoningContentField: "reasoning_content",
requiresReasoningContentForToolCalls: isKimiModel,
requiresAssistantContentForToolCalls: isKimiModel,
+38 -11
View File
@@ -337,11 +337,17 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
errorMessage: "OpenAI completions stream stalled while waiting for the next event",
onIdle: () => requestAbortController.abort(),
})) {
if (!chunk || typeof chunk !== "object") continue;
// OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier,
// and each chunk in a streamed completion carries the same id.
output.responseId ||= chunk.id;
if (chunk.usage) {
output.usage = parseChunkUsage(chunk.usage, model, copilotPremiumRequests);
}
const choice = chunk.choices[0];
const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice) continue;
if (!chunk.usage) {
@@ -352,7 +358,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
}
if (choice.finish_reason) {
output.stopReason = mapStopReason(choice.finish_reason);
const finishReasonResult = mapStopReason(choice.finish_reason);
output.stopReason = finishReasonResult.stopReason;
if (finishReasonResult.errorMessage) {
output.errorMessage = finishReasonResult.errorMessage;
}
}
if (choice.delta) {
@@ -463,8 +473,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
throw new Error("Request was aborted");
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unknown error occurred");
if (output.stopReason === "aborted") {
throw new Error("Request was aborted");
}
if (output.stopReason === "error") {
throw new Error(output.errorMessage || "Provider returned an error stop reason");
}
output.duration = Date.now() - startTime;
@@ -616,6 +629,12 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio
Reflect.set(params, "enable_thinking", !!options?.reasoning);
} else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) {
Reflect.set(params, "chat_template_kwargs", { enable_thinking: !!options?.reasoning });
} else if (compat.thinkingFormat === "openrouter" && options?.reasoning && model.reasoning) {
// OpenRouter normalizes reasoning across providers via a nested reasoning object.
const openRouterParams = params as typeof params & { reasoning?: { effort?: string } };
openRouterParams.reasoning = {
effort: mapReasoningEffort(options.reasoning, compat.reasoningEffortMap),
};
} else if (options?.reasoning && model.reasoning && compat.supportsReasoningEffort) {
// OpenAI-style reasoning_effort
Reflect.set(params, "reasoning_effort", mapReasoningEffort(options.reasoning, compat.reasoningEffortMap));
@@ -1061,21 +1080,29 @@ function convertTools(tools: Tool[], compat: ResolvedOpenAICompat): OpenAI.Chat.
});
}
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): StopReason {
if (reason === null) return "stop";
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
stopReason: StopReason;
errorMessage?: string;
} {
if (reason === null) return { stopReason: "stop" };
switch (reason) {
case "stop":
case "end":
return "stop";
return { stopReason: "stop" };
case "length":
return "length";
return { stopReason: "length" };
case "function_call":
case "tool_calls":
return "toolUse";
return { stopReason: "toolUse" };
case "content_filter":
return "error";
return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" };
case "network_error":
return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" };
default:
throw new Error(`Unhandled stop reason: ${reason}`);
return {
stopReason: "error",
errorMessage: `Provider finish_reason: ${reason}`,
};
}
}
@@ -52,8 +52,26 @@ export function parseTextSignature(
return { id: signature };
}
export function normalizeResponsesToolCallIdForTransform(id: string): string {
export function normalizeResponsesToolCallIdForTransform(
id: string,
model?: Model<Api>,
source?: AssistantMessage,
): string {
if (!id.includes("|")) return id;
const isForeignToolCall =
source != null && model != null && (source.provider !== model.provider || source.api !== model.api);
if (isForeignToolCall) {
const [callId, itemId] = id.split("|");
const normalizeIdPart = (part: string): string => {
const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
const truncated = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
return truncated.replace(/_+$/, "");
};
const normalizedCallId = normalizeIdPart(callId);
let normalizedItemId = `fc_${Bun.hash(itemId).toString(36)}`;
if (normalizedItemId.length > 64) normalizedItemId = normalizedItemId.slice(0, 64);
return `${normalizedCallId}|${normalizedItemId}`;
}
const normalized = normalizeResponsesToolCallId(id);
return `${normalized.callId}|${normalized.itemId}`;
}
@@ -221,7 +239,9 @@ export async function processResponsesStream<TApi extends Api>(
let sawFirstToken = false;
for await (const event of openaiStream) {
if (event.type === "response.output_item.added") {
if (event.type === "response.created") {
output.responseId = event.response.id;
} else if (event.type === "response.output_item.added") {
if (!sawFirstToken) {
sawFirstToken = true;
options?.onFirstToken?.();
@@ -376,6 +396,9 @@ export async function processResponsesStream<TApi extends Api>(
}
} else if (event.type === "response.completed") {
const response = event.response;
if (response?.id) {
output.responseId = response.id;
}
if (response?.usage) {
const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0;
output.usage = {
@@ -0,0 +1,309 @@
/**
* Lazy provider module loading.
*
* Each provider module is loaded only when its stream function is first called.
* This avoids eagerly importing heavy SDK dependencies (e.g., @anthropic-ai/sdk,
* openai) at startup. The loaded module promise is cached so subsequent calls
* reuse the same import.
*
* NOTE: stream.ts currently imports providers directly, so this file is not yet
* wired into the main streaming path. It provides the infrastructure for lazy
* loading that can be integrated when stream.ts is refactored.
*/
import type {
Api,
AssistantMessage,
AssistantMessageEvent,
AssistantMessageEventStream,
Context,
Model,
OptionsForApi,
} from "../types";
import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream";
import type { BedrockOptions } from "./amazon-bedrock";
import type { AnthropicOptions } from "./anthropic";
import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses";
import type { CursorOptions } from "./cursor";
import type { GoogleOptions } from "./google";
import type { GoogleGeminiCliOptions } from "./google-gemini-cli";
import type { GoogleVertexOptions } from "./google-vertex";
import type { OpenAICodexResponsesOptions } from "./openai-codex-responses";
import type { OpenAICompletionsOptions } from "./openai-completions";
import type { OpenAIResponsesOptions } from "./openai-responses";
// ---------------------------------------------------------------------------
// Lazy provider module shape
// ---------------------------------------------------------------------------
interface LazyProviderModule<TApi extends Api> {
stream: (model: Model<TApi>, context: Context, options: OptionsForApi<TApi>) => AsyncIterable<AssistantMessageEvent>;
}
interface AnthropicProviderModule {
streamAnthropic: (
model: Model<"anthropic-messages">,
context: Context,
options: AnthropicOptions,
) => AssistantMessageEventStream;
}
interface AzureOpenAIResponsesProviderModule {
streamAzureOpenAIResponses: (
model: Model<"azure-openai-responses">,
context: Context,
options: AzureOpenAIResponsesOptions,
) => AssistantMessageEventStream;
}
interface GoogleProviderModule {
streamGoogle: (
model: Model<"google-generative-ai">,
context: Context,
options: GoogleOptions,
) => AssistantMessageEventStream;
}
interface GoogleGeminiCliProviderModule {
streamGoogleGeminiCli: (
model: Model<"google-gemini-cli">,
context: Context,
options: GoogleGeminiCliOptions,
) => AssistantMessageEventStream;
}
interface GoogleVertexProviderModule {
streamGoogleVertex: (
model: Model<"google-vertex">,
context: Context,
options: GoogleVertexOptions,
) => AssistantMessageEventStream;
}
interface OpenAICodexResponsesProviderModule {
streamOpenAICodexResponses: (
model: Model<"openai-codex-responses">,
context: Context,
options: OpenAICodexResponsesOptions,
) => AssistantMessageEventStream;
}
interface OpenAICompletionsProviderModule {
streamOpenAICompletions: (
model: Model<"openai-completions">,
context: Context,
options: OpenAICompletionsOptions,
) => AssistantMessageEventStream;
}
interface OpenAIResponsesProviderModule {
streamOpenAIResponses: (
model: Model<"openai-responses">,
context: Context,
options: OpenAIResponsesOptions,
) => AssistantMessageEventStream;
}
interface CursorProviderModule {
streamCursor: (
model: Model<"cursor-agent">,
context: Context,
options: CursorOptions,
) => AssistantMessageEventStream;
}
interface BedrockProviderModule {
streamBedrock: (
model: Model<"bedrock-converse-stream">,
context: Context,
options: BedrockOptions,
) => AssistantMessageEventStream;
}
// ---------------------------------------------------------------------------
// Module-level lazy promise caches
// ---------------------------------------------------------------------------
const importNodeOnlyProvider = (specifier: string): Promise<unknown> => import(specifier);
let anthropicProviderModulePromise: Promise<LazyProviderModule<"anthropic-messages">> | undefined;
let azureOpenAIResponsesProviderModulePromise: Promise<LazyProviderModule<"azure-openai-responses">> | undefined;
let googleProviderModulePromise: Promise<LazyProviderModule<"google-generative-ai">> | undefined;
let googleGeminiCliProviderModulePromise: Promise<LazyProviderModule<"google-gemini-cli">> | undefined;
let googleVertexProviderModulePromise: Promise<LazyProviderModule<"google-vertex">> | undefined;
let openAICodexResponsesProviderModulePromise: Promise<LazyProviderModule<"openai-codex-responses">> | undefined;
let openAICompletionsProviderModulePromise: Promise<LazyProviderModule<"openai-completions">> | undefined;
let openAIResponsesProviderModulePromise: Promise<LazyProviderModule<"openai-responses">> | undefined;
let cursorProviderModulePromise: Promise<LazyProviderModule<"cursor-agent">> | undefined;
let bedrockProviderModuleOverride: LazyProviderModule<"bedrock-converse-stream"> | undefined;
let bedrockProviderModulePromise: Promise<LazyProviderModule<"bedrock-converse-stream">> | undefined;
export function setBedrockProviderModule(module: BedrockProviderModule): void {
bedrockProviderModuleOverride = {
stream: module.streamBedrock,
};
}
// ---------------------------------------------------------------------------
// Stream forwarding / error helpers
// ---------------------------------------------------------------------------
function forwardStream(target: EventStreamImpl, source: AsyncIterable<AssistantMessageEvent>): void {
(async () => {
for await (const event of source) {
target.push(event);
}
target.end();
})();
}
function createLazyLoadErrorMessage<TApi extends Api>(model: Model<TApi>, error: unknown): AssistantMessage {
return {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
};
}
// ---------------------------------------------------------------------------
// Generic lazy stream factory
// ---------------------------------------------------------------------------
function createLazyStream<TApi extends Api>(
loadModule: () => Promise<LazyProviderModule<TApi>>,
): (model: Model<TApi>, context: Context, options: OptionsForApi<TApi>) => EventStreamImpl {
return (model, context, options) => {
const outer = new EventStreamImpl();
loadModule()
.then(module => {
const inner = module.stream(model, context, options);
forwardStream(outer, inner);
})
.catch(error => {
const message = createLazyLoadErrorMessage(model, error);
outer.push({ type: "error", reason: "error", error: message });
outer.end(message);
});
return outer;
};
}
// ---------------------------------------------------------------------------
// Module loaders (one per provider, cached via ||=)
// ---------------------------------------------------------------------------
function loadAnthropicProviderModule(): Promise<LazyProviderModule<"anthropic-messages">> {
anthropicProviderModulePromise ||= import("./anthropic").then(module => {
const provider = module as AnthropicProviderModule;
return { stream: provider.streamAnthropic };
});
return anthropicProviderModulePromise;
}
function loadAzureOpenAIResponsesProviderModule(): Promise<LazyProviderModule<"azure-openai-responses">> {
azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses").then(module => {
const provider = module as AzureOpenAIResponsesProviderModule;
return { stream: provider.streamAzureOpenAIResponses };
});
return azureOpenAIResponsesProviderModulePromise;
}
function loadGoogleProviderModule(): Promise<LazyProviderModule<"google-generative-ai">> {
googleProviderModulePromise ||= import("./google").then(module => {
const provider = module as GoogleProviderModule;
return { stream: provider.streamGoogle };
});
return googleProviderModulePromise;
}
function loadGoogleGeminiCliProviderModule(): Promise<LazyProviderModule<"google-gemini-cli">> {
googleGeminiCliProviderModulePromise ||= import("./google-gemini-cli").then(module => {
const provider = module as GoogleGeminiCliProviderModule;
return { stream: provider.streamGoogleGeminiCli };
});
return googleGeminiCliProviderModulePromise;
}
function loadGoogleVertexProviderModule(): Promise<LazyProviderModule<"google-vertex">> {
googleVertexProviderModulePromise ||= import("./google-vertex").then(module => {
const provider = module as GoogleVertexProviderModule;
return { stream: provider.streamGoogleVertex };
});
return googleVertexProviderModulePromise;
}
function loadOpenAICodexResponsesProviderModule(): Promise<LazyProviderModule<"openai-codex-responses">> {
openAICodexResponsesProviderModulePromise ||= import("./openai-codex-responses").then(module => {
const provider = module as OpenAICodexResponsesProviderModule;
return { stream: provider.streamOpenAICodexResponses };
});
return openAICodexResponsesProviderModulePromise;
}
function loadOpenAICompletionsProviderModule(): Promise<LazyProviderModule<"openai-completions">> {
openAICompletionsProviderModulePromise ||= import("./openai-completions").then(module => {
const provider = module as OpenAICompletionsProviderModule;
return { stream: provider.streamOpenAICompletions };
});
return openAICompletionsProviderModulePromise;
}
function loadOpenAIResponsesProviderModule(): Promise<LazyProviderModule<"openai-responses">> {
openAIResponsesProviderModulePromise ||= import("./openai-responses").then(module => {
const provider = module as OpenAIResponsesProviderModule;
return { stream: provider.streamOpenAIResponses };
});
return openAIResponsesProviderModulePromise;
}
function loadCursorProviderModule(): Promise<LazyProviderModule<"cursor-agent">> {
cursorProviderModulePromise ||= import("./cursor").then(module => {
const provider = module as CursorProviderModule;
return { stream: provider.streamCursor };
});
return cursorProviderModulePromise;
}
function loadBedrockProviderModule(): Promise<LazyProviderModule<"bedrock-converse-stream">> {
if (bedrockProviderModuleOverride) {
return Promise.resolve(bedrockProviderModuleOverride);
}
bedrockProviderModulePromise ||= importNodeOnlyProvider("./amazon-bedrock").then(module => {
const provider = module as BedrockProviderModule;
return { stream: provider.streamBedrock };
});
return bedrockProviderModulePromise;
}
// ---------------------------------------------------------------------------
// Lazy stream function exports
//
// These use the same names as the direct provider stream functions. When
// stream.ts is updated to import from this module instead of individual
// providers, the lazy loading will take effect on the main code path.
// ---------------------------------------------------------------------------
export const streamAnthropic = createLazyStream(loadAnthropicProviderModule);
export const streamAzureOpenAIResponses = createLazyStream(loadAzureOpenAIResponsesProviderModule);
export const streamGoogle = createLazyStream(loadGoogleProviderModule);
export const streamGoogleGeminiCli = createLazyStream(loadGoogleGeminiCliProviderModule);
export const streamGoogleVertex = createLazyStream(loadGoogleVertexProviderModule);
export const streamOpenAICodexResponses = createLazyStream(loadOpenAICodexResponsesProviderModule);
export const streamOpenAICompletions = createLazyStream(loadOpenAICompletionsProviderModule);
export const streamOpenAIResponses = createLazyStream(loadOpenAIResponsesProviderModule);
export const streamCursor = createLazyStream(loadCursorProviderModule);
export const streamBedrock = createLazyStream(loadBedrockProviderModule);
+3 -2
View File
@@ -338,6 +338,7 @@ export interface AssistantMessage {
api: Api;
provider: Provider;
model: string;
responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one
usage: Usage;
stopReason: StopReason;
errorMessage?: string;
@@ -444,8 +445,8 @@ export interface OpenAICompat {
requiresThinkingAsText?: boolean;
/** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */
requiresMistralToolIds?: boolean;
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }, "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */
thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template";
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "zai" uses thinking: { type: "enabled" }, "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */
thinkingFormat?: "openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template";
/** Which reasoning content field to emit on assistant messages. Default: auto-detected. */
reasoningContentField?: "reasoning_content" | "reasoning" | "reasoning_text";
/** Whether assistant tool-call messages must include reasoning content. Default: false. */
+3
View File
@@ -28,6 +28,7 @@ export interface Args {
mode?: Mode;
noSession?: boolean;
sessionDir?: string;
fork?: string;
models?: string[];
tools?: string[];
noTools?: boolean;
@@ -79,6 +80,8 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
} else {
result.resume = true;
}
} else if (arg === "--fork" && i + 1 < args.length) {
result.fork = args[++i];
} else if (arg === "--provider" && i + 1 < args.length) {
result.provider = args[++i];
} else if (arg === "--model" && i + 1 < args.length) {
@@ -0,0 +1,58 @@
import type { ImageContent } from "@oh-my-pi/pi-ai";
import type { Args } from "./args";
export interface InitialMessageInput {
parsed: Args;
fileText?: string;
fileImages?: ImageContent[];
stdinContent?: string;
}
export interface InitialMessageResult {
initialMessage?: string;
initialImages?: ImageContent[];
}
/**
* Combine stdin content, @file text, and the first CLI message into a single
* initial prompt for non-interactive mode.
*/
export function buildInitialMessage({
parsed,
fileText,
fileImages,
stdinContent,
}: InitialMessageInput): InitialMessageResult {
const hasInitialContext = stdinContent !== undefined || fileText !== undefined || (fileImages?.length ?? 0) > 0;
if (!hasInitialContext) {
return {
initialImages: undefined,
};
}
let body = "";
if (fileText !== undefined) {
body += fileText;
}
if (parsed.messages.length > 0) {
body += parsed.messages[0];
parsed.messages.shift();
}
const initialMessage =
stdinContent !== undefined
? body.length > 0
? `${stdinContent}\n${body}`
: stdinContent
: body.length > 0
? body
: fileImages && fileImages.length > 0
? ""
: undefined;
return {
initialMessage,
initialImages: fileImages && fileImages.length > 0 ? fileImages : undefined,
};
}
+416 -211
View File
@@ -1,120 +1,438 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import * as path from "node:path";
import {
DEFAULT_EDITOR_KEYBINDINGS,
type EditorAction,
type EditorKeybindingsConfig,
EditorKeybindingsManager,
type Keybinding,
type KeybindingDefinitions,
type KeybindingsConfig,
type KeyId,
matchesKey,
setEditorKeybindings,
setKeybindings,
TUI_KEYBINDINGS,
KeybindingsManager as TuiKeybindingsManager,
} from "@oh-my-pi/pi-tui";
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
/**
* Application-level actions (coding agent specific).
* Application-level keybindings (coding agent specific).
* Values are always `true` — used for declaration merging.
*/
export type AppAction =
| "interrupt"
| "clear"
| "exit"
| "suspend"
| "cycleThinkingLevel"
| "cycleModelForward"
| "cycleModelBackward"
| "selectModel"
| "togglePlanMode"
| "expandTools"
| "toggleThinking"
| "externalEditor"
| "historySearch"
| "followUp"
| "dequeue"
| "pasteImage"
| "copyLine"
| "copyPrompt"
| "newSession"
| "tree"
| "fork"
| "resume"
| "toggleSTT";
interface AppKeybindings {
"app.interrupt": true;
"app.clear": true;
"app.exit": true;
"app.suspend": true;
"app.thinking.cycle": true;
"app.thinking.toggle": true;
"app.model.cycleForward": true;
"app.model.cycleBackward": true;
"app.model.select": true;
"app.tools.expand": true;
"app.editor.external": true;
"app.message.followUp": true;
"app.message.dequeue": true;
"app.clipboard.pasteImage": true;
"app.clipboard.copyLine": true;
"app.clipboard.copyPrompt": true;
"app.session.new": true;
"app.session.tree": true;
"app.session.fork": true;
"app.session.resume": true;
"app.session.togglePath": true;
"app.session.toggleSort": true;
"app.session.rename": true;
"app.session.delete": true;
"app.session.deleteNoninvasive": true;
"app.tree.foldOrUp": true;
"app.tree.unfoldOrDown": true;
"app.plan.toggle": true;
"app.history.search": true;
"app.stt.toggle": true;
}
export type AppKeybinding = keyof AppKeybindings;
declare module "@oh-my-pi/pi-tui" {
interface Keybindings extends AppKeybindings {}
}
/**
* All configurable actions.
* All keybindings definitions: TUI + app-specific.
*/
export type KeyAction = AppAction | EditorAction;
export const KEYBINDINGS = {
...TUI_KEYBINDINGS,
"app.interrupt": {
defaultKeys: "escape",
description: "Interrupt current operation",
},
"app.clear": {
defaultKeys: "ctrl+c",
description: "Clear screen or cancel",
},
"app.exit": {
defaultKeys: "ctrl+d",
description: "Exit application",
},
"app.suspend": {
defaultKeys: "ctrl+z",
description: "Suspend application",
},
"app.thinking.cycle": {
defaultKeys: "shift+tab",
description: "Cycle thinking level",
},
"app.thinking.toggle": {
defaultKeys: "ctrl+t",
description: "Toggle thinking mode",
},
"app.model.cycleForward": {
defaultKeys: "ctrl+p",
description: "Cycle to next model",
},
"app.model.cycleBackward": {
defaultKeys: "shift+ctrl+p",
description: "Cycle to previous model",
},
"app.model.select": {
defaultKeys: "ctrl+l",
description: "Select model",
},
"app.tools.expand": {
defaultKeys: "ctrl+o",
description: "Expand tools",
},
"app.editor.external": {
defaultKeys: "ctrl+g",
description: "Open external editor",
},
"app.message.followUp": {
defaultKeys: "ctrl+enter",
description: "Send follow-up message",
},
"app.message.dequeue": {
defaultKeys: "alt+up",
description: "Dequeue message",
},
"app.clipboard.pasteImage": {
defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
description: "Paste image from clipboard",
},
"app.clipboard.copyLine": {
defaultKeys: "alt+shift+l",
description: "Copy current line",
},
"app.clipboard.copyPrompt": {
defaultKeys: "alt+shift+c",
description: "Copy prompt",
},
"app.session.new": {
defaultKeys: [],
description: "Create new session",
},
"app.session.tree": {
defaultKeys: [],
description: "Show session tree",
},
"app.session.fork": {
defaultKeys: [],
description: "Fork session",
},
"app.session.resume": {
defaultKeys: [],
description: "Resume session",
},
"app.session.togglePath": {
defaultKeys: "ctrl+p",
description: "Toggle session path display",
},
"app.session.toggleSort": {
defaultKeys: "ctrl+s",
description: "Toggle session sort order",
},
"app.session.rename": {
defaultKeys: "ctrl+r",
description: "Rename session",
},
"app.session.delete": {
defaultKeys: "ctrl+d",
description: "Delete session",
},
"app.session.deleteNoninvasive": {
defaultKeys: "ctrl+backspace",
description: "Delete session (non-invasive)",
},
"app.tree.foldOrUp": {
defaultKeys: ["ctrl+left", "alt+left"],
description: "Fold or move up",
},
"app.tree.unfoldOrDown": {
defaultKeys: ["ctrl+right", "alt+right"],
description: "Unfold or move down",
},
"app.plan.toggle": {
defaultKeys: "alt+shift+p",
description: "Toggle plan mode",
},
"app.history.search": {
defaultKeys: "ctrl+r",
description: "Search history",
},
"app.stt.toggle": {
defaultKeys: "alt+h",
description: "Toggle speech-to-text",
},
} as const satisfies KeybindingDefinitions;
/**
* Full keybindings configuration (app + editor actions).
* Migration map from old keybinding names to new namespaced IDs.
*/
export type KeybindingsConfig = {
[K in KeyAction]?: KeyId | KeyId[];
};
const KEYBINDING_NAME_MIGRATIONS = {
// App-specific (old names)
interrupt: "app.interrupt",
clear: "app.clear",
exit: "app.exit",
suspend: "app.suspend",
cycleThinkingLevel: "app.thinking.cycle",
cycleModelForward: "app.model.cycleForward",
cycleModelBackward: "app.model.cycleBackward",
selectModel: "app.model.select",
togglePlanMode: "app.plan.toggle",
historySearch: "app.history.search",
expandTools: "app.tools.expand",
toggleThinking: "app.thinking.toggle",
externalEditor: "app.editor.external",
followUp: "app.message.followUp",
dequeue: "app.message.dequeue",
pasteImage: "app.clipboard.pasteImage",
copyLine: "app.clipboard.copyLine",
copyPrompt: "app.clipboard.copyPrompt",
newSession: "app.session.new",
tree: "app.session.tree",
fork: "app.session.fork",
resume: "app.session.resume",
toggleSTT: "app.stt.toggle",
// TUI editor (old names for backward compatibility)
cursorUp: "tui.editor.cursorUp",
cursorDown: "tui.editor.cursorDown",
cursorLeft: "tui.editor.cursorLeft",
cursorRight: "tui.editor.cursorRight",
cursorWordLeft: "tui.editor.cursorWordLeft",
cursorWordRight: "tui.editor.cursorWordRight",
cursorLineStart: "tui.editor.cursorLineStart",
cursorLineEnd: "tui.editor.cursorLineEnd",
jumpForward: "tui.editor.jumpForward",
jumpBackward: "tui.editor.jumpBackward",
pageUp: "tui.editor.pageUp",
pageDown: "tui.editor.pageDown",
deleteCharBackward: "tui.editor.deleteCharBackward",
deleteCharForward: "tui.editor.deleteCharForward",
deleteWordBackward: "tui.editor.deleteWordBackward",
deleteWordForward: "tui.editor.deleteWordForward",
deleteToLineStart: "tui.editor.deleteToLineStart",
deleteToLineEnd: "tui.editor.deleteToLineEnd",
yank: "tui.editor.yank",
yankPop: "tui.editor.yankPop",
undo: "tui.editor.undo",
// TUI input (old names for backward compatibility)
newLine: "tui.input.newLine",
submit: "tui.input.submit",
tab: "tui.input.tab",
copy: "tui.input.copy",
// TUI select (old names for backward compatibility)
selectUp: "tui.select.up",
selectDown: "tui.select.down",
selectPageUp: "tui.select.pageUp",
selectPageDown: "tui.select.pageDown",
selectConfirm: "tui.select.confirm",
selectCancel: "tui.select.cancel",
// Upstream additional migrations
toggleSessionNamedFilter: "app.session.togglePath",
} as const satisfies Record<string, Keybinding>;
/**
* Default application keybindings.
* Check if a key is a legacy keybinding name.
*/
export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
interrupt: "escape",
clear: "ctrl+c",
exit: "ctrl+d",
suspend: "ctrl+z",
cycleThinkingLevel: "shift+tab",
cycleModelForward: "ctrl+p",
cycleModelBackward: "shift+ctrl+p",
selectModel: "ctrl+l",
togglePlanMode: "alt+shift+p",
historySearch: "ctrl+r",
expandTools: "ctrl+o",
toggleThinking: "ctrl+t",
externalEditor: "ctrl+g",
followUp: "ctrl+enter",
dequeue: "alt+up",
pasteImage: "ctrl+v",
copyLine: "alt+shift+l",
copyPrompt: "alt+shift+c",
newSession: [],
tree: [],
fork: [],
resume: [],
toggleSTT: "alt+h",
};
function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAME_MIGRATIONS {
return key in KEYBINDING_NAME_MIGRATIONS;
}
/**
* All default keybindings (app + editor).
* Normalize input to KeybindingsConfig, validating types.
*/
export const DEFAULT_KEYBINDINGS: Required<KeybindingsConfig> = {
...DEFAULT_EDITOR_KEYBINDINGS,
...DEFAULT_APP_KEYBINDINGS,
};
function toKeybindingsConfig(value: unknown): KeybindingsConfig {
if (typeof value !== "object" || value === null) {
return {};
}
// App actions list for type checking
const APP_ACTIONS: AppAction[] = [
"interrupt",
"clear",
"exit",
"suspend",
"cycleThinkingLevel",
"cycleModelForward",
"cycleModelBackward",
"selectModel",
"togglePlanMode",
"historySearch",
"expandTools",
"toggleThinking",
"externalEditor",
"followUp",
"dequeue",
"pasteImage",
"copyLine",
"copyPrompt",
"newSession",
"tree",
"fork",
"resume",
"toggleSTT",
];
const config: KeybindingsConfig = {};
for (const [key, val] of Object.entries(value)) {
// Allow undefined, string (KeyId), or array of strings
if (val === undefined) {
config[key] = undefined;
} else if (typeof val === "string") {
config[key] = val as KeyId;
} else if (Array.isArray(val) && val.every(v => typeof v === "string")) {
config[key] = val as string[] as KeyId[];
}
// Silently skip invalid entries
}
return config;
}
function isAppAction(action: string): action is AppAction {
return APP_ACTIONS.includes(action as AppAction);
/**
* Migrate old keybinding names to new namespaced IDs.
* Returns both the migrated config and a flag indicating if migration occurred.
*/
function migrateKeybindingNames(rawConfig: unknown): {
config: KeybindingsConfig;
migrated: boolean;
} {
const config = toKeybindingsConfig(rawConfig);
const migrated: KeybindingsConfig = {};
let didMigrate = false;
for (const [key, value] of Object.entries(config)) {
if (isLegacyKeybindingName(key)) {
const newKey = KEYBINDING_NAME_MIGRATIONS[key];
migrated[newKey] = value;
didMigrate = true;
} else {
// Already a new-style key
migrated[key] = value;
}
}
return { config: migrated, migrated: didMigrate };
}
/**
* Order keybindings config to match KEYBINDINGS key order.
*/
function orderKeybindingsConfig(config: KeybindingsConfig): KeybindingsConfig {
const ordered: KeybindingsConfig = {};
for (const key of Object.keys(KEYBINDINGS)) {
const value = config[key];
if (value !== undefined) {
ordered[key] = value;
}
}
// Add any remaining keys that aren't in KEYBINDINGS
for (const key of Object.keys(config)) {
if (!(key in ordered)) {
ordered[key] = config[key];
}
}
return ordered;
}
/**
* Load raw config from a file synchronously.
* Returns parsed JSON or null if file doesn't exist or is invalid.
*/
function loadRawConfig(filePath: string): unknown {
try {
if (!existsSync(filePath)) {
return null;
}
const content = readFileSync(filePath, "utf-8");
return JSON.parse(content);
} catch (error) {
if (isEnoent(error)) {
return null;
}
logger.warn("Failed to parse keybindings config", { path: filePath, error: String(error) });
return null;
}
}
/**
* Migrate keybindings config file from old format to new.
* Reads from agentDir/keybindings.json, migrates old names, and writes back.
*/
function loadKeybindingsConfig(filePath: string, writeBack: boolean): KeybindingsConfig {
const rawConfig = loadRawConfig(filePath);
if (rawConfig === null) {
return {};
}
const { config: migratedConfig, migrated } = migrateKeybindingNames(rawConfig);
if (writeBack && migrated) {
const ordered = orderKeybindingsConfig(migratedConfig);
try {
writeFileSync(filePath, `${JSON.stringify(ordered, null, 2)}\n`, "utf-8");
logger.debug("Migrated keybindings config", { path: filePath });
} catch (error) {
logger.warn("Failed to write migrated keybindings config", { path: filePath, error: String(error) });
}
}
return migratedConfig;
}
function migrateKeybindingsConfigFile(agentDir: string): void {
const configPath = path.join(agentDir, "keybindings.json");
loadKeybindingsConfig(configPath, true);
}
/**
* Manages all keybindings (app + TUI).
* Extends the TUI KeybindingsManager with app-specific functionality.
*/
export class KeybindingsManager extends TuiKeybindingsManager {
#configPath: string | undefined;
constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
super(KEYBINDINGS, userBindings);
this.#configPath = configPath;
}
/**
* Create from config file at agentDir/keybindings.json.
*/
static create(agentDir: string = getAgentDir()): KeybindingsManager {
const configPath = path.join(agentDir, "keybindings.json");
const userBindings = KeybindingsManager.#loadFromFile(configPath);
const manager = new KeybindingsManager(userBindings, configPath);
// Set globally so getKeybindings() returns this manager
setKeybindings(manager);
return manager;
}
/**
* Create an in-memory keybindings manager without file persistence.
*/
static inMemory(userBindings: KeybindingsConfig = {}): KeybindingsManager {
return new KeybindingsManager(userBindings);
}
/**
* Reload keybindings from the config file.
*/
reload(): void {
if (!this.#configPath) return;
this.setUserBindings(KeybindingsManager.#loadFromFile(this.#configPath));
}
/**
* Get the effective resolved bindings (defaults + user overrides).
*/
getEffectiveConfig(): KeybindingsConfig {
return this.getResolvedBindings();
}
/**
* Get display string for a keybinding (e.g., "ctrl+c/escape").
*/
getDisplayString(keybinding: Keybinding): string {
const keys = this.getKeys(keybinding);
return formatKeyHints(keys.length === 0 ? [] : keys);
}
/**
* Load user bindings from a file, migrating old names if needed.
*/
static #loadFromFile(filePath: string): KeybindingsConfig {
return loadKeybindingsConfig(filePath, true);
}
}
/**
@@ -145,8 +463,6 @@ const KEY_LABELS: Record<string, string> = {
right: "Right",
};
const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId;
function formatKeyPart(part: string): string {
const lower = part.toLowerCase();
const modifier = MODIFIER_LABELS[lower];
@@ -166,116 +482,5 @@ export function formatKeyHints(keys: KeyId | KeyId[]): string {
return list.map(formatKeyHint).join("/");
}
/**
* Manages all keybindings (app + editor).
*/
export class KeybindingsManager {
#appActionToKeys: Map<AppAction, KeyId[]>;
private constructor(private readonly config: KeybindingsConfig) {
this.#appActionToKeys = new Map();
this.#buildMaps();
}
/**
* Create from config file and set up editor keybindings.
*/
static async create(agentDir: string = getAgentDir()): Promise<KeybindingsManager> {
const configPath = path.join(agentDir, "keybindings.json");
const config = await KeybindingsManager.#loadFromFile(configPath);
const manager = new KeybindingsManager(config);
// Set up editor keybindings globally
const editorConfig: EditorKeybindingsConfig = {};
for (const [action, keys] of Object.entries(config)) {
if (!isAppAction(action)) {
editorConfig[action as EditorAction] = keys;
}
}
setEditorKeybindings(new EditorKeybindingsManager(editorConfig));
return manager;
}
/**
* Create in-memory.
*/
static inMemory(config: KeybindingsConfig = {}): KeybindingsManager {
return new KeybindingsManager(config);
}
static async #loadFromFile(path: string): Promise<KeybindingsConfig> {
try {
return await Bun.file(path).json();
} catch (error) {
if (isEnoent(error)) return {};
logger.warn("Failed to parse keybindings config", { path, error: String(error) });
return {};
}
}
#buildMaps(): void {
this.#appActionToKeys.clear();
// Set defaults for app actions
for (const [action, keys] of Object.entries(DEFAULT_APP_KEYBINDINGS)) {
const keyArray = Array.isArray(keys) ? keys : [keys];
this.#appActionToKeys.set(
action as AppAction,
keyArray.map(key => normalizeKeyId(key as KeyId)),
);
}
// Override with user config (app actions only)
for (const [action, keys] of Object.entries(this.config)) {
if (keys === undefined || !isAppAction(action)) continue;
const keyArray = Array.isArray(keys) ? keys : [keys];
this.#appActionToKeys.set(
action,
keyArray.map(key => normalizeKeyId(key as KeyId)),
);
}
}
/**
* Check if input matches an app action.
*/
matches(data: string, action: AppAction): boolean {
const keys = this.#appActionToKeys.get(action);
if (!keys) return false;
for (const key of keys) {
if (matchesKey(data, key)) return true;
}
return false;
}
/**
* Get keys bound to an app action.
*/
getKeys(action: AppAction): KeyId[] {
return this.#appActionToKeys.get(action) ?? [];
}
/**
* Get display string for an action.
*/
getDisplayString(action: AppAction): string {
return formatKeyHints(this.getKeys(action));
}
/**
* Get the full effective config.
*/
getEffectiveConfig(): Required<KeybindingsConfig> {
const result = { ...DEFAULT_KEYBINDINGS };
for (const [action, keys] of Object.entries(this.config)) {
if (keys !== undefined) {
(result as KeybindingsConfig)[action as KeyAction] = keys;
}
}
return result;
}
}
// Re-export for convenience
export type { EditorAction, KeyId };
export type { Keybinding, KeybindingsConfig, KeyId };
export { migrateKeybindingsConfigFile };
@@ -90,6 +90,7 @@ const OpenAICompatSchema = Type.Object({
thinkingFormat: Type.Optional(
Type.Union([
Type.Literal("openai"),
Type.Literal("openrouter"),
Type.Literal("zai"),
Type.Literal("qwen"),
Type.Literal("qwen-chat-template"),
@@ -141,6 +141,55 @@ function isAlias(id: string): boolean {
return !datePattern.test(id);
}
/**
* Find an exact model reference match.
* Supports either a bare model id or a canonical provider/modelId reference.
* When matching by bare id, ambiguous matches across providers are rejected.
*/
export function findExactModelReferenceMatch(
modelReference: string,
availableModels: Model<Api>[],
): Model<Api> | undefined {
const trimmedReference = modelReference.trim();
if (!trimmedReference) {
return undefined;
}
const normalizedReference = trimmedReference.toLowerCase();
const canonicalMatches = availableModels.filter(
model => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference,
);
if (canonicalMatches.length === 1) {
return canonicalMatches[0];
}
if (canonicalMatches.length > 1) {
return undefined;
}
const slashIndex = trimmedReference.indexOf("/");
if (slashIndex !== -1) {
const provider = trimmedReference.substring(0, slashIndex).trim();
const modelId = trimmedReference.substring(slashIndex + 1).trim();
if (provider && modelId) {
const providerMatches = availableModels.filter(
model =>
model.provider.toLowerCase() === provider.toLowerCase() &&
model.id.toLowerCase() === modelId.toLowerCase(),
);
if (providerMatches.length === 1) {
return providerMatches[0];
}
if (providerMatches.length > 1) {
return undefined;
}
}
}
const idMatches = availableModels.filter(model => model.id.toLowerCase() === normalizedReference);
return idMatches.length === 1 ? idMatches[0] : undefined;
}
/**
* Try to match a pattern to a model from the available models list.
* Returns the matched model or undefined if no match found.
@@ -150,17 +199,17 @@ function tryMatchModel(
availableModels: Model<Api>[],
context: ModelPreferenceContext,
): Model<Api> | undefined {
// Check for provider/modelId format (provider is everything before the first /)
// Try exact reference match first (handles provider/modelId and bare id with ambiguity rejection)
const exactRefMatch = findExactModelReferenceMatch(modelPattern, availableModels);
if (exactRefMatch) {
return exactRefMatch;
}
// Check for provider/modelId format — fuzzy match within provider
const slashIndex = modelPattern.indexOf("/");
if (slashIndex !== -1) {
const provider = modelPattern.substring(0, slashIndex);
const modelId = modelPattern.substring(slashIndex + 1);
const providerMatch = availableModels.find(
m => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(),
);
if (providerMatch) {
return providerMatch;
}
const providerModels = availableModels.filter(m => m.provider.toLowerCase() === provider.toLowerCase());
if (providerModels.length > 0) {
@@ -187,10 +236,9 @@ function tryMatchModel(
return scored[0]?.model;
}
}
// No exact provider/model match - fall through to other matching
}
// Check for exact ID match (case-insensitive)
// Exact ID match (case-insensitive) — with ambiguity across providers handled by preference
const exactMatches = availableModels.filter(m => m.id.toLowerCase() === modelPattern.toLowerCase());
if (exactMatches.length > 0) {
return pickPreferredModel(exactMatches, context);
@@ -2,6 +2,10 @@
:root {
--line-height: 18px; /* 12px font * 1.5 */
--sidebar-width: 400px;
--sidebar-min-width: 240px;
--sidebar-max-width: 840px;
--sidebar-resizer-width: 6px;
}
body {
@@ -12,6 +16,11 @@
background: var(--body-bg);
}
body.sidebar-resizing {
cursor: col-resize;
user-select: none;
}
#app {
display: flex;
min-height: 100vh;
@@ -19,7 +28,9 @@
/* Sidebar */
#sidebar {
width: 400px;
width: var(--sidebar-width);
min-width: var(--sidebar-width);
max-width: var(--sidebar-width);
background: var(--container-bg);
flex-shrink: 0;
display: flex;
@@ -203,8 +214,28 @@
flex-shrink: 0;
}
#sidebar-resizer {
width: var(--sidebar-resizer-width);
flex-shrink: 0;
position: sticky;
top: 0;
height: 100vh;
cursor: col-resize;
touch-action: none;
background: transparent;
border-right: 1px solid transparent;
}
#sidebar-resizer:hover,
body.sidebar-resizing #sidebar-resizer {
background: var(--selectedBg);
border-right-color: var(--dim);
}
/* Main content */
#content {
flex: 1;
min-width: 0;
flex: 1;
overflow-y: auto;
padding: var(--line-height) calc(var(--line-height) * 2);
@@ -841,17 +872,19 @@
@media (max-width: 900px) {
#sidebar {
position: fixed;
left: -400px;
width: 400px;
transform: translateX(-100%);
width: min(var(--sidebar-width), 100vw);
min-width: 0;
max-width: 100vw;
top: 0;
bottom: 0;
height: 100vh;
z-index: 99;
transition: left 0.3s;
transition: transform 0.3s;
}
#sidebar.open {
left: 0;
transform: translateX(0);
}
#sidebar-overlay.open {
@@ -866,6 +899,10 @@
display: block;
}
#sidebar-resizer {
display: none;
}
#content {
padding: var(--line-height) 16px;
}
@@ -875,15 +912,8 @@
}
}
@media (max-width: 500px) {
#sidebar {
width: 100vw;
left: -100vw;
}
}
@media print {
#sidebar, #sidebar-toggle { display: none !important; }
#sidebar, #sidebar-toggle, #sidebar-resizer { display: none !important; }
body { background: white; color: black; }
#content { max-width: none; }
}
File diff suppressed because one or more lines are too long
@@ -28,6 +28,7 @@
<div class="tree-container" id="tree-container"></div>
<div class="tree-status" id="tree-status"></div>
</aside>
<div id="sidebar-resizer" role="separator" aria-orientation="vertical" aria-label="Resize session tree sidebar"></div>
<main id="content">
<div id="header-container"></div>
<div id="messages"></div>
@@ -1279,6 +1279,113 @@
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
const hamburger = document.getElementById('hamburger');
const sidebarResizer = document.getElementById('sidebar-resizer');
const SIDEBAR_WIDTH_STORAGE_KEY = 'pi-share:v1:sidebar-width';
const MIN_CONTENT_WIDTH = 320;
function isMobileLayout() {
return window.matchMedia('(max-width: 900px)').matches;
}
function getSidebarBounds() {
const rootStyles = getComputedStyle(document.documentElement);
const minWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-min-width')) || 240;
const maxWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-max-width')) || 720;
const viewportMaxWidth = window.innerWidth - MIN_CONTENT_WIDTH;
return {
minWidth,
maxWidth: Math.max(minWidth, Math.min(maxWidth, viewportMaxWidth))
};
}
function clampSidebarWidth(width) {
const { minWidth, maxWidth } = getSidebarBounds();
return Math.max(minWidth, Math.min(maxWidth, width));
}
function applySidebarWidth(width) {
document.documentElement.style.setProperty('--sidebar-width', `${Math.round(clampSidebarWidth(width))}px`);
}
function loadSidebarWidth() {
try {
const raw = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
if (raw === null) return null;
const width = Number(raw);
return Number.isFinite(width) ? width : null;
} catch {
return null;
}
}
function saveSidebarWidth(width) {
try {
localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(clampSidebarWidth(width))));
} catch {
// Ignore storage failures (e.g. private browsing restrictions)
}
}
function setupSidebarResize() {
const savedWidth = loadSidebarWidth();
if (savedWidth !== null) {
applySidebarWidth(savedWidth);
}
if (!sidebarResizer) return;
let cleanupDrag = null;
const stopDrag = (pointerId) => {
if (cleanupDrag) {
cleanupDrag(pointerId);
cleanupDrag = null;
}
};
sidebarResizer.addEventListener('pointerdown', (e) => {
if (isMobileLayout()) return;
e.preventDefault();
const startX = e.clientX;
const startWidth = sidebar.getBoundingClientRect().width;
document.body.classList.add('sidebar-resizing');
sidebarResizer.setPointerCapture?.(e.pointerId);
const onPointerMove = (event) => {
applySidebarWidth(startWidth + (event.clientX - startX));
};
cleanupDrag = (pointerIdToRelease) => {
document.body.classList.remove('sidebar-resizing');
sidebarResizer.releasePointerCapture?.(pointerIdToRelease);
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
saveSidebarWidth(sidebar.getBoundingClientRect().width);
};
const onPointerUp = (event) => stopDrag(event.pointerId);
const onPointerCancel = (event) => stopDrag(event.pointerId);
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerCancel);
});
sidebarResizer.addEventListener('dblclick', () => {
if (isMobileLayout()) return;
applySidebarWidth(400);
saveSidebarWidth(400);
});
window.addEventListener('resize', () => {
if (isMobileLayout()) return;
applySidebarWidth(sidebar.getBoundingClientRect().width);
});
}
setupSidebarResize();
hamburger.addEventListener('click', () => {
sidebar.classList.add('open');
@@ -56,7 +56,7 @@ import type { TodoItem } from "../../tools/todo-write";
import type { EventBus } from "../../utils/event-bus";
import type { SlashCommandInfo } from "../slash-commands";
export type { AppAction, KeybindingsManager } from "../../config/keybindings";
export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
export type { ExecOptions, ExecResult } from "../../exec/exec";
export type { AgentToolResult, AgentToolUpdateCallback };
+36 -42
View File
@@ -15,6 +15,7 @@ import { $env, getProjectDir, logger, postmortem, setProjectDir, VERSION } from
import chalk from "chalk";
import type { Args } from "./cli/args";
import { processFileArguments } from "./cli/file-processor";
import { buildInitialMessage } from "./cli/initial-message";
import { listModels } from "./cli/list-models";
import { selectSession } from "./cli/session-picker";
import { findConfigFile } from "./config";
@@ -137,7 +138,7 @@ async function runInteractiveMode(
}
}
if (initialMessage) {
if (initialMessage !== undefined) {
try {
await session.prompt(initialMessage, { images: initialImages });
} catch (error: unknown) {
@@ -161,33 +162,6 @@ async function runInteractiveMode(
}
}
async function prepareInitialMessage(
parsed: Args,
autoResizeImages: boolean,
): Promise<{
initialMessage?: string;
initialImages?: ImageContent[];
}> {
if (parsed.fileArgs.length === 0) {
return {};
}
const { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages });
let initialMessage: string;
if (parsed.messages.length > 0) {
initialMessage = text + parsed.messages[0];
parsed.messages.shift();
} else {
initialMessage = text;
}
return {
initialMessage,
initialImages: images.length > 0 ? images : undefined,
};
}
function normalizePathForComparison(value: string): string {
const resolved = path.resolve(value);
let realPath = resolved;
@@ -237,6 +211,21 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
}
async function createSessionManager(parsed: Args, cwd: string): Promise<SessionManager | undefined> {
if (parsed.fork) {
if (parsed.noSession) {
throw new Error("--fork requires session persistence");
}
const forkSource = parsed.fork;
if (forkSource.includes("/") || forkSource.includes("\\") || forkSource.endsWith(".jsonl")) {
return await SessionManager.forkFrom(forkSource, cwd, parsed.sessionDir);
}
const match = await resolveResumableSession(forkSource, cwd, parsed.sessionDir);
if (!match) {
throw new Error(`Session "${forkSource}" not found.`);
}
return await SessionManager.forkFrom(match.session.path, cwd, parsed.sessionDir);
}
if (parsed.noSession) {
return SessionManager.inMemory();
}
@@ -565,22 +554,27 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
if (parsedArgs.noPty) {
Bun.env.PI_NO_PTY = "1";
}
const {
pipedInput,
initialMessage: initMsg,
initialImages,
} = await logger.timeAsync("prepareInitialMessage", async () => {
const { pipedInput, fileText, fileImages } = await logger.timeAsync("prepareInitialMessage", async () => {
const pipedInput = await readPipedInput();
let { initialMessage, initialImages } = await prepareInitialMessage(
parsedArgs,
settings.get("images.autoResize"),
);
if (pipedInput) {
initialMessage = initialMessage ? `${initialMessage}\n${pipedInput}` : pipedInput;
if (parsedArgs.fileArgs.length === 0) {
return { pipedInput };
}
return { pipedInput, initialMessage, initialImages };
const { text, images } = await processFileArguments(parsedArgs.fileArgs, {
autoResizeImages: settings.get("images.autoResize"),
});
return {
pipedInput,
fileText: text,
fileImages: images,
};
});
const { initialMessage, initialImages } = buildInitialMessage({
parsed: parsedArgs,
fileText,
fileImages,
stdinContent: pipedInput,
});
const initialMessage = initMsg;
const autoPrint = pipedInput !== undefined && !parsedArgs.print && parsedArgs.mode === undefined;
const isInteractive = !parsedArgs.print && !autoPrint && parsedArgs.mode === undefined;
const mode = parsedArgs.mode || "text";
@@ -626,7 +620,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
let sessionManager = await logger.timeAsync("createSessionManager", () => createSessionManager(parsedArgs, cwd));
// Handle --resume (no value): show session picker
if (parsedArgs.resume === true) {
if (parsedArgs.resume === true && !parsedArgs.fork) {
const sessions = await logger.timeAsync("SessionManager.list", () =>
SessionManager.list(cwd, parsedArgs.sessionDir),
);
@@ -1,41 +1,41 @@
import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
import type { AppAction } from "../../config/keybindings";
import type { AppKeybinding } from "../../config/keybindings";
type ConfigurableEditorAction = Extract<
AppAction,
| "interrupt"
| "clear"
| "exit"
| "suspend"
| "cycleThinkingLevel"
| "cycleModelForward"
| "cycleModelBackward"
| "selectModel"
| "expandTools"
| "toggleThinking"
| "externalEditor"
| "historySearch"
| "dequeue"
| "pasteImage"
| "copyPrompt"
AppKeybinding,
| "app.interrupt"
| "app.clear"
| "app.exit"
| "app.suspend"
| "app.thinking.cycle"
| "app.model.cycleForward"
| "app.model.cycleBackward"
| "app.model.select"
| "app.tools.expand"
| "app.thinking.toggle"
| "app.editor.external"
| "app.history.search"
| "app.message.dequeue"
| "app.clipboard.pasteImage"
| "app.clipboard.copyPrompt"
>;
const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
interrupt: ["escape"],
clear: ["ctrl+c"],
exit: ["ctrl+d"],
suspend: ["ctrl+z"],
cycleThinkingLevel: ["shift+tab"],
cycleModelForward: ["ctrl+p"],
cycleModelBackward: ["shift+ctrl+p"],
selectModel: ["ctrl+l"],
expandTools: ["ctrl+o"],
toggleThinking: ["ctrl+t"],
externalEditor: ["ctrl+g"],
historySearch: ["ctrl+r"],
dequeue: ["alt+up"],
pasteImage: ["ctrl+v"],
copyPrompt: ["alt+shift+c"],
"app.interrupt": ["escape"],
"app.clear": ["ctrl+c"],
"app.exit": ["ctrl+d"],
"app.suspend": ["ctrl+z"],
"app.thinking.cycle": ["shift+tab"],
"app.model.cycleForward": ["ctrl+p"],
"app.model.cycleBackward": ["shift+ctrl+p"],
"app.model.select": ["ctrl+l"],
"app.tools.expand": ["ctrl+o"],
"app.thinking.toggle": ["ctrl+t"],
"app.editor.external": ["ctrl+g"],
"app.history.search": ["ctrl+r"],
"app.message.dequeue": ["alt+up"],
"app.clipboard.pasteImage": ["ctrl+v"],
"app.clipboard.copyPrompt": ["alt+shift+c"],
};
/**
@@ -115,13 +115,13 @@ export class CustomEditor extends Editor {
}
// Intercept configured image paste (async - fires and handles result)
if (this.#matchesAction(data, "pasteImage") && this.onPasteImage) {
if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) {
void this.onPasteImage();
return;
}
// Intercept configured external editor shortcut
if (this.#matchesAction(data, "externalEditor") && this.onExternalEditor) {
if (this.#matchesAction(data, "app.editor.external") && this.onExternalEditor) {
this.onExternalEditor();
return;
}
@@ -133,56 +133,56 @@ export class CustomEditor extends Editor {
}
// Intercept configured suspend shortcut
if (this.#matchesAction(data, "suspend") && this.onSuspend) {
if (this.#matchesAction(data, "app.suspend") && this.onSuspend) {
this.onSuspend();
return;
}
// Intercept configured thinking block visibility toggle
if (this.#matchesAction(data, "toggleThinking") && this.onToggleThinking) {
if (this.#matchesAction(data, "app.thinking.toggle") && this.onToggleThinking) {
this.onToggleThinking();
return;
}
// Intercept configured model selector shortcut
if (this.#matchesAction(data, "selectModel") && this.onSelectModel) {
if (this.#matchesAction(data, "app.model.select") && this.onSelectModel) {
this.onSelectModel();
return;
}
// Intercept configured history search shortcut
if (this.#matchesAction(data, "historySearch") && this.onHistorySearch) {
if (this.#matchesAction(data, "app.history.search") && this.onHistorySearch) {
this.onHistorySearch();
return;
}
// Intercept configured tool output expansion shortcut
if (this.#matchesAction(data, "expandTools") && this.onExpandTools) {
if (this.#matchesAction(data, "app.tools.expand") && this.onExpandTools) {
this.onExpandTools();
return;
}
// Intercept configured backward model cycling (check before forward cycling)
if (this.#matchesAction(data, "cycleModelBackward") && this.onCycleModelBackward) {
if (this.#matchesAction(data, "app.model.cycleBackward") && this.onCycleModelBackward) {
this.onCycleModelBackward();
return;
}
// Intercept configured forward model cycling
if (this.#matchesAction(data, "cycleModelForward") && this.onCycleModelForward) {
if (this.#matchesAction(data, "app.model.cycleForward") && this.onCycleModelForward) {
this.onCycleModelForward();
return;
}
// Intercept configured thinking level cycling
if (this.#matchesAction(data, "cycleThinkingLevel") && this.onCycleThinkingLevel) {
if (this.#matchesAction(data, "app.thinking.cycle") && this.onCycleThinkingLevel) {
this.onCycleThinkingLevel();
return;
}
// Intercept configured interrupt shortcut.
// Default behavior keeps autocomplete dismissal, but parent can prioritize global interrupt handling.
if (this.#matchesAction(data, "interrupt") && this.onEscape) {
if (this.#matchesAction(data, "app.interrupt") && this.onEscape) {
if (!this.isShowingAutocomplete() || this.shouldBypassAutocompleteOnEscape?.()) {
this.onEscape();
return;
@@ -190,13 +190,13 @@ export class CustomEditor extends Editor {
}
// Intercept configured clear shortcut
if (this.#matchesAction(data, "clear") && this.onClear) {
if (this.#matchesAction(data, "app.clear") && this.onClear) {
this.onClear();
return;
}
// Intercept configured exit shortcut (only when editor is empty)
if (this.#matchesAction(data, "exit")) {
if (this.#matchesAction(data, "app.exit")) {
if (this.getText().length === 0 && this.onExit) {
this.onExit();
}
@@ -205,13 +205,13 @@ export class CustomEditor extends Editor {
}
// Intercept configured dequeue shortcut (restore queued message to editor)
if (this.#matchesAction(data, "dequeue") && this.onDequeue) {
if (this.#matchesAction(data, "app.message.dequeue") && this.onDequeue) {
this.onDequeue();
return;
}
// Intercept configured copy-prompt shortcut
if (this.#matchesAction(data, "copyPrompt") && this.onCopyPrompt) {
if (this.#matchesAction(data, "app.clipboard.copyPrompt") && this.onCopyPrompt) {
this.onCopyPrompt();
return;
}
@@ -1,8 +1,8 @@
/**
* Utilities for formatting keybinding hints in the UI.
*/
import { type EditorAction, getEditorKeybindings, type KeyId } from "@oh-my-pi/pi-tui";
import type { AppAction, KeybindingsManager } from "../../config/keybindings";
import { getKeybindings, type Keybinding, type KeyId } from "@oh-my-pi/pi-tui";
import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
import { theme } from "../../modes/theme/theme";
/**
@@ -17,14 +17,14 @@ function formatKeys(keys: KeyId[]): string {
/**
* Get display string for an editor action.
*/
export function editorKey(action: EditorAction): string {
return formatKeys(getEditorKeybindings().getKeys(action));
export function editorKey(action: Keybinding): string {
return formatKeys(getKeybindings().getKeys(action));
}
/**
* Get display string for an app action.
*/
export function appKey(keybindings: KeybindingsManager, action: AppAction): string {
export function appKey(keybindings: KeybindingsManager, action: AppKeybinding): string {
return formatKeys(keybindings.getKeys(action));
}
@@ -32,11 +32,11 @@ export function appKey(keybindings: KeybindingsManager, action: AppAction): stri
* Format a keybinding hint with consistent styling: dim key, muted description.
* Looks up the key from editor keybindings automatically.
*
* @param action - Editor action name (e.g., "selectConfirm", "expandTools")
* @param action - Keybinding action name (e.g., "tui.select.confirm", "app.tools.expand")
* @param description - Description text (e.g., "to expand", "cancel")
* @returns Formatted string with dim key and muted description
*/
export function keyHint(action: EditorAction, description: string): string {
export function keyHint(action: Keybinding, description: string): string {
return theme.fg("dim", editorKey(action)) + theme.fg("muted", ` ${description}`);
}
@@ -45,11 +45,11 @@ export function keyHint(action: EditorAction, description: string): string {
* Requires the KeybindingsManager instance.
*
* @param keybindings - KeybindingsManager instance
* @param action - App action name (e.g., "interrupt", "externalEditor")
* @param action - App keybinding name (e.g., "app.interrupt", "app.editor.external")
* @param description - Description text
* @returns Formatted string with dim key and muted description
*/
export function appKeyHint(keybindings: KeybindingsManager, action: AppAction, description: string): string {
export function appKeyHint(keybindings: KeybindingsManager, action: AppKeybinding, description: string): string {
return theme.fg("dim", appKey(keybindings, action)) + theme.fg("muted", ` ${description}`);
}
@@ -1,5 +1,5 @@
import { getOAuthProviders } from "@oh-my-pi/pi-ai";
import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { Container, getKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { theme } from "../../modes/theme/theme";
import { openPath } from "../../utils/open";
import { DynamicBorder } from "./dynamic-border";
@@ -151,9 +151,9 @@ export class LoginDialogComponent extends Container {
}
handleInput(data: string): void {
const kb = getEditorKeybindings();
const kb = getKeybindings();
if (kb.matches(data, "selectCancel")) {
if (kb.matches(data, "tui.select.cancel")) {
this.#cancel();
return;
}
@@ -1,6 +1,11 @@
import { Container, Markdown, Spacer } from "@oh-my-pi/pi-tui";
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
// OSC 133 shell integration: marks prompt zones for terminal multiplexers
const OSC133_ZONE_START = "\x1b]133;A\x07";
const OSC133_ZONE_END = "\x1b]133;B\x07";
const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
/**
* Component that renders a user message
*/
@@ -19,4 +24,15 @@ export class UserMessageComponent extends Container {
}),
);
}
override render(width: number): string[] {
const lines = super.render(width);
if (lines.length === 0) {
return lines;
}
lines[0] = OSC133_ZONE_START + lines[0];
lines[lines.length - 1] = lines[lines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
return lines;
}
}
@@ -26,7 +26,7 @@ export class InputController {
constructor(private ctx: InteractiveModeContext) {}
setupKeyHandlers(): void {
this.ctx.editor.setActionKeys("interrupt", this.ctx.keybindings.getKeys("interrupt"));
this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
Boolean(
this.ctx.loadingAnimation ||
@@ -83,68 +83,74 @@ export class InputController {
}
};
this.ctx.editor.setActionKeys("clear", this.ctx.keybindings.getKeys("clear"));
this.ctx.editor.setActionKeys("app.clear", this.ctx.keybindings.getKeys("app.clear"));
this.ctx.editor.onClear = () => this.handleCtrlC();
this.ctx.editor.setActionKeys("exit", this.ctx.keybindings.getKeys("exit"));
this.ctx.editor.setActionKeys("app.exit", this.ctx.keybindings.getKeys("app.exit"));
this.ctx.editor.onExit = () => this.handleCtrlD();
this.ctx.editor.setActionKeys("suspend", this.ctx.keybindings.getKeys("suspend"));
this.ctx.editor.setActionKeys("app.suspend", this.ctx.keybindings.getKeys("app.suspend"));
this.ctx.editor.onSuspend = () => this.handleCtrlZ();
this.ctx.editor.setActionKeys("cycleThinkingLevel", this.ctx.keybindings.getKeys("cycleThinkingLevel"));
this.ctx.editor.setActionKeys("app.thinking.cycle", this.ctx.keybindings.getKeys("app.thinking.cycle"));
this.ctx.editor.onCycleThinkingLevel = () => this.cycleThinkingLevel();
this.ctx.editor.setActionKeys("cycleModelForward", this.ctx.keybindings.getKeys("cycleModelForward"));
this.ctx.editor.setActionKeys("app.model.cycleForward", this.ctx.keybindings.getKeys("app.model.cycleForward"));
this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel();
this.ctx.editor.setActionKeys("cycleModelBackward", this.ctx.keybindings.getKeys("cycleModelBackward"));
this.ctx.editor.setActionKeys("app.model.cycleBackward", this.ctx.keybindings.getKeys("app.model.cycleBackward"));
this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel({ temporary: true });
this.ctx.editor.onQuickSelectModel = () => this.ctx.showModelSelector({ temporaryOnly: true });
// Global debug handler on TUI (works regardless of focus)
this.ctx.ui.onDebug = () => this.ctx.showDebugSelector();
this.ctx.editor.setActionKeys("selectModel", this.ctx.keybindings.getKeys("selectModel"));
this.ctx.editor.setActionKeys("app.model.select", this.ctx.keybindings.getKeys("app.model.select"));
this.ctx.editor.onSelectModel = () => this.ctx.showModelSelector();
this.ctx.editor.setActionKeys("historySearch", this.ctx.keybindings.getKeys("historySearch"));
this.ctx.editor.setActionKeys("app.history.search", this.ctx.keybindings.getKeys("app.history.search"));
this.ctx.editor.onHistorySearch = () => this.ctx.showHistorySearch();
this.ctx.editor.setActionKeys("toggleThinking", this.ctx.keybindings.getKeys("toggleThinking"));
this.ctx.editor.setActionKeys("app.thinking.toggle", this.ctx.keybindings.getKeys("app.thinking.toggle"));
this.ctx.editor.onToggleThinking = () => this.ctx.toggleThinkingBlockVisibility();
this.ctx.editor.setActionKeys("externalEditor", this.ctx.keybindings.getKeys("externalEditor"));
this.ctx.editor.setActionKeys("app.editor.external", this.ctx.keybindings.getKeys("app.editor.external"));
this.ctx.editor.onExternalEditor = () => void this.openExternalEditor();
this.ctx.editor.onShowHotkeys = () => this.ctx.handleHotkeysCommand();
this.ctx.editor.setActionKeys("pasteImage", this.ctx.keybindings.getKeys("pasteImage"));
this.ctx.editor.setActionKeys(
"app.clipboard.pasteImage",
this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
);
this.ctx.editor.onPasteImage = () => this.handleImagePaste();
this.ctx.editor.setActionKeys("copyPrompt", this.ctx.keybindings.getKeys("copyPrompt"));
this.ctx.editor.setActionKeys(
"app.clipboard.copyPrompt",
this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"),
);
this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt();
this.ctx.editor.setActionKeys("expandTools", this.ctx.keybindings.getKeys("expandTools"));
this.ctx.editor.setActionKeys("app.tools.expand", this.ctx.keybindings.getKeys("app.tools.expand"));
this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion();
this.ctx.editor.setActionKeys("dequeue", this.ctx.keybindings.getKeys("dequeue"));
this.ctx.editor.setActionKeys("app.message.dequeue", this.ctx.keybindings.getKeys("app.message.dequeue"));
this.ctx.editor.onDequeue = () => this.handleDequeue();
this.ctx.editor.clearCustomKeyHandlers();
// Wire up extension shortcuts
this.registerExtensionShortcuts();
const planModeKeys = this.ctx.keybindings.getKeys("togglePlanMode");
const planModeKeys = this.ctx.keybindings.getKeys("app.plan.toggle");
for (const key of planModeKeys) {
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand());
}
for (const key of this.ctx.keybindings.getKeys("newSession")) {
for (const key of this.ctx.keybindings.getKeys("app.session.new")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.handleClearCommand());
}
for (const key of this.ctx.keybindings.getKeys("tree")) {
for (const key of this.ctx.keybindings.getKeys("app.session.tree")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showTreeSelector());
}
for (const key of this.ctx.keybindings.getKeys("fork")) {
for (const key of this.ctx.keybindings.getKeys("app.session.fork")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showUserMessageSelector());
}
for (const key of this.ctx.keybindings.getKeys("resume")) {
for (const key of this.ctx.keybindings.getKeys("app.session.resume")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionSelector());
}
for (const key of this.ctx.keybindings.getKeys("followUp")) {
for (const key of this.ctx.keybindings.getKeys("app.message.followUp")) {
this.ctx.editor.setCustomKeyHandler(key, () => void this.handleFollowUp());
}
for (const key of this.ctx.keybindings.getKeys("toggleSTT")) {
for (const key of this.ctx.keybindings.getKeys("app.stt.toggle")) {
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
}
for (const key of this.ctx.keybindings.getKeys("copyLine")) {
for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
}
@@ -273,7 +273,7 @@ export class InteractiveMode implements InteractiveModeContext {
async init(): Promise<void> {
if (this.isInitialized) return;
this.keybindings = await logger.timeAsync("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
this.keybindings = logger.time("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
// Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP)
this.#cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush());
@@ -146,7 +146,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
});
// Send initial message with attachments
if (initialMessage) {
if (initialMessage !== undefined) {
await session.prompt(initialMessage, { images: initialImages });
}
@@ -2,7 +2,7 @@ import {
type AutocompleteItem,
type AutocompleteProvider,
CombinedAutocompleteProvider,
getEditorKeybindings,
getKeybindings,
type SlashCommand,
} from "@oh-my-pi/pi-tui";
import { formatKeyHints, type KeybindingsManager } from "../config/keybindings";
@@ -174,26 +174,26 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
export function createPromptActionAutocompleteProvider(
options: PromptActionAutocompleteOptions,
): PromptActionAutocompleteProvider {
const editorKeybindings = getEditorKeybindings();
const editorKeybindings = getKeybindings();
const actions: PromptActionDefinition[] = [
{
id: "copy-line",
label: "Copy current line",
description: formatKeyHints(options.keybindings.getKeys("copyLine")),
description: formatKeyHints(options.keybindings.getKeys("app.clipboard.copyLine")),
keywords: ["copy", "line", "clipboard", "current"],
execute: options.copyCurrentLine,
},
{
id: "copy-prompt",
label: "Copy whole prompt",
description: formatKeyHints(options.keybindings.getKeys("copyPrompt")),
description: formatKeyHints(options.keybindings.getKeys("app.clipboard.copyPrompt")),
keywords: ["copy", "prompt", "clipboard", "message"],
execute: options.copyPrompt,
},
{
id: "undo",
label: "Undo",
description: formatKeyHints(editorKeybindings.getKeys("undo")),
description: formatKeyHints(editorKeybindings.getKeys("tui.editor.undo")),
keywords: ["undo", "revert", "edit", "history"],
execute: options.undo,
},
@@ -214,14 +214,14 @@ export function createPromptActionAutocompleteProvider(
{
id: "cursor-line-start",
label: "Move cursor to beginning of line",
description: formatKeyHints(editorKeybindings.getKeys("cursorLineStart")),
description: formatKeyHints(editorKeybindings.getKeys("tui.editor.cursorLineStart")),
keywords: ["move", "cursor", "line", "start", "beginning", "home"],
execute: options.moveCursorToLineStart,
},
{
id: "cursor-line-end",
label: "Move cursor to end of line",
description: formatKeyHints(editorKeybindings.getKeys("cursorLineEnd")),
description: formatKeyHints(editorKeybindings.getKeys("tui.editor.cursorLineEnd")),
keywords: ["move", "cursor", "line", "end"],
execute: options.moveCursorToLineEnd,
},
+54 -45
View File
@@ -1679,6 +1679,7 @@ export function getCurrentThemeName(): string | undefined {
var currentSymbolPresetOverride: SymbolPreset | undefined;
var currentColorBlindMode: boolean = false;
var themeWatcher: fs.FSWatcher | undefined;
var themeReloadTimer: NodeJS.Timeout | undefined;
var sigwinchHandler: (() => void) | undefined;
var autoDetectedTheme: boolean = false;
var autoDarkTheme: string = "dark";
@@ -1888,11 +1889,7 @@ export function isValidSymbolPreset(preset: string): preset is SymbolPreset {
}
async function startThemeWatcher(): Promise<void> {
// Stop existing watcher if any
if (themeWatcher) {
themeWatcher.close();
themeWatcher = undefined;
}
stopThemeWatcher();
// Only watch if it's a custom theme (not built-in)
if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") {
@@ -1900,54 +1897,62 @@ async function startThemeWatcher(): Promise<void> {
}
const customThemesDir = getCustomThemesDir();
const themeFile = path.join(customThemesDir, `${currentThemeName}.json`);
const watchedThemeName = currentThemeName;
const watchedFileName = `${watchedThemeName}.json`;
const themeFile = path.join(customThemesDir, watchedFileName);
// Only watch if the file exists
if (!fs.existsSync(themeFile)) {
return;
}
try {
themeWatcher = fs.watch(themeFile, eventType => {
if (eventType === "change") {
// Debounce rapid changes
setTimeout(() => {
loadTheme(currentThemeName!, getCurrentThemeOptions())
.then(loadedTheme => {
theme = loadedTheme;
if (onThemeChangeCallback) {
onThemeChangeCallback();
}
})
.catch(err => {
logger.debug("Theme reload error during file change", { error: String(err) });
});
}, 100);
} else if (eventType === "rename") {
// File was deleted or renamed - fall back to default theme
setTimeout(() => {
if (!fs.existsSync(themeFile)) {
currentThemeName = "dark";
loadTheme("dark", getCurrentThemeOptions())
.then(loadedTheme => {
theme = loadedTheme;
if (onThemeChangeCallback) {
onThemeChangeCallback();
}
})
.catch(err => {
logger.debug("Theme reload error during rename fallback", { error: String(err) });
});
if (themeWatcher) {
themeWatcher.close();
themeWatcher = undefined;
}
}
}, 100);
const scheduleReload = () => {
if (themeReloadTimer) {
clearTimeout(themeReloadTimer);
}
themeReloadTimer = setTimeout(() => {
themeReloadTimer = undefined;
// Ignore stale timers after switching themes or stopping the watcher
if (currentThemeName !== watchedThemeName) {
return;
}
// Keep the last successfully loaded theme active if the file is temporarily missing
if (!fs.existsSync(themeFile)) {
return;
}
loadTheme(watchedThemeName, getCurrentThemeOptions())
.then(loadedTheme => {
theme = loadedTheme;
if (onThemeChangeCallback) {
onThemeChangeCallback();
}
})
.catch(() => {
// Ignore errors (file might be in invalid state while being edited)
});
}, 100);
};
try {
themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => {
if (currentThemeName !== watchedThemeName) {
return;
}
if (!filename) {
scheduleReload();
return;
}
const changedFile = String(filename);
if (changedFile !== watchedFileName) {
return;
}
scheduleReload();
});
} catch (err) {
logger.debug("Failed to start theme watcher", { error: String(err) });
} catch {
// Ignore errors starting watcher
}
}
@@ -2023,6 +2028,10 @@ function stopSigwinchListener(): void {
}
export function stopThemeWatcher(): void {
if (themeReloadTimer) {
clearTimeout(themeReloadTimer);
themeReloadTimer = undefined;
}
if (themeWatcher) {
themeWatcher.close();
themeWatcher = undefined;
@@ -1,10 +1,10 @@
import type { AppAction, KeybindingsManager } from "../../config/keybindings";
import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
export interface HotkeysMarkdownBindings {
keybindings: Pick<KeybindingsManager, "getDisplayString">;
}
function appKey(bindings: HotkeysMarkdownBindings, action: AppAction): string {
function appKey(bindings: HotkeysMarkdownBindings, action: AppKeybinding): string {
return bindings.keybindings.getDisplayString(action) || "Disabled";
}
@@ -26,29 +26,29 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
"| `Ctrl+W` / `Option+Backspace` | Delete word backwards |",
"| `Ctrl+U` | Delete to start of line |",
"| `Ctrl+K` | Delete to end of line |",
`| \`${appKey(bindings, "copyLine")}\` | Copy current line |`,
`| \`${appKey(bindings, "copyPrompt")}\` | Copy whole prompt |`,
`| \`${appKey(bindings, "app.clipboard.copyLine")}\` | Copy current line |`,
`| \`${appKey(bindings, "app.clipboard.copyPrompt")}\` | Copy whole prompt |`,
"",
"**Other**",
"| Key | Action |",
"|-----|--------|",
"| `Tab` | Path completion / accept autocomplete |",
`| \`${appKey(bindings, "interrupt")}\` | Cancel autocomplete / interrupt active work |`,
`| \`${appKey(bindings, "clear")}\` | Clear editor (first) / exit (second) |`,
`| \`${appKey(bindings, "exit")}\` | Exit (when editor is empty) |`,
`| \`${appKey(bindings, "suspend")}\` | Suspend to background |`,
`| \`${appKey(bindings, "cycleThinkingLevel")}\` | Cycle thinking level |`,
`| \`${appKey(bindings, "cycleModelForward")}\` | Cycle role models (slow/default/smol) |`,
`| \`${appKey(bindings, "cycleModelBackward")}\` | Cycle role models (temporary) |`,
`| \`${appKey(bindings, "app.interrupt")}\` | Cancel autocomplete / interrupt active work |`,
`| \`${appKey(bindings, "app.clear")}\` | Clear editor (first) / exit (second) |`,
`| \`${appKey(bindings, "app.exit")}\` | Exit (when editor is empty) |`,
`| \`${appKey(bindings, "app.suspend")}\` | Suspend to background |`,
`| \`${appKey(bindings, "app.thinking.cycle")}\` | Cycle thinking level |`,
`| \`${appKey(bindings, "app.model.cycleForward")}\` | Cycle role models (slow/default/smol) |`,
`| \`${appKey(bindings, "app.model.cycleBackward")}\` | Cycle role models (temporary) |`,
"| `Alt+P` | Select model (temporary) |",
`| \`${appKey(bindings, "selectModel")}\` | Select model (set roles) |`,
`| \`${appKey(bindings, "togglePlanMode")}\` | Toggle plan mode |`,
`| \`${appKey(bindings, "historySearch")}\` | Search prompt history |`,
`| \`${appKey(bindings, "expandTools")}\` | Toggle tool output expansion |`,
`| \`${appKey(bindings, "toggleThinking")}\` | Toggle thinking block visibility |`,
`| \`${appKey(bindings, "externalEditor")}\` | Edit message in external editor |`,
`| \`${appKey(bindings, "pasteImage")}\` | Paste image from clipboard |`,
`| \`${appKey(bindings, "toggleSTT")}\` | Toggle speech-to-text recording |`,
`| \`${appKey(bindings, "app.model.select")}\` | Select model (set roles) |`,
`| \`${appKey(bindings, "app.plan.toggle")}\` | Toggle plan mode |`,
`| \`${appKey(bindings, "app.history.search")}\` | Search prompt history |`,
`| \`${appKey(bindings, "app.tools.expand")}\` | Toggle tool output expansion |`,
`| \`${appKey(bindings, "app.thinking.toggle")}\` | Toggle thinking block visibility |`,
`| \`${appKey(bindings, "app.editor.external")}\` | Edit message in external editor |`,
`| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image from clipboard |`,
`| \`${appKey(bindings, "app.stt.toggle")}\` | Toggle speech-to-text recording |`,
"| `#` | Open prompt actions |",
"| `/` | Slash commands |",
"| `!` | Run bash command |",
@@ -474,7 +474,7 @@ export class UiHelpers {
const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
}
const dequeueKey = this.ctx.keybindings.getDisplayString("dequeue") || "Alt+Up";
const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up";
const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`);
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
}
@@ -782,7 +782,6 @@ export class AgentSession {
attempt: this.#retryAttempt,
});
this.#retryAttempt = 0;
this.#resolveRetry();
}
}
@@ -858,6 +857,7 @@ export class AgentSession {
const didRetry = await this.#handleRetryableError(msg);
if (didRetry) return; // Retry was initiated, don't proceed to compaction
}
this.#resolveRetry();
if (msg.stopReason === "aborted" && this.#checkpointState) {
this.#checkpointState = undefined;
@@ -4495,7 +4495,9 @@ export class AgentSession {
}
#isTransientErrorMessage(errorMessage: string): boolean {
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|retry delay|stream stall/i.test(
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
// service unavailable, network/connection errors, fetch failed, terminated, retry delay exceeded
return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall/i.test(
errorMessage,
);
}
@@ -0,0 +1,88 @@
import type { ChildProcess } from "node:child_process";
const EXIT_STDIO_GRACE_MS = 100;
/**
* Wait for a child process to terminate without hanging on inherited stdio handles.
*
* Daemonized descendants can inherit the child's stdout/stderr pipe handles. In that
* case the child emits `exit`, but `close` can hang forever even though the original
* process is already gone. We wait briefly for stdio to end, then forcibly stop
* tracking the inherited handles.
*/
export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
const { promise, resolve, reject } = Promise.withResolvers<number | null>();
let settled = false;
let exited = false;
let exitCode: number | null = null;
let postExitTimer: NodeJS.Timeout | undefined;
let stdoutEnded = child.stdout === null;
let stderrEnded = child.stderr === null;
const cleanup = () => {
if (postExitTimer) {
clearTimeout(postExitTimer);
postExitTimer = undefined;
}
child.removeListener("error", onError);
child.removeListener("exit", onExit);
child.removeListener("close", onClose);
child.stdout?.removeListener("end", onStdoutEnd);
child.stderr?.removeListener("end", onStderrEnd);
};
const finalize = (code: number | null) => {
if (settled) return;
settled = true;
cleanup();
child.stdout?.destroy();
child.stderr?.destroy();
resolve(code);
};
const maybeFinalizeAfterExit = () => {
if (!exited || settled) return;
if (stdoutEnded && stderrEnded) {
finalize(exitCode);
}
};
const onStdoutEnd = () => {
stdoutEnded = true;
maybeFinalizeAfterExit();
};
const onStderrEnd = () => {
stderrEnded = true;
maybeFinalizeAfterExit();
};
const onError = (err: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(err);
};
const onExit = (code: number | null) => {
exited = true;
exitCode = code;
maybeFinalizeAfterExit();
if (!settled) {
postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS);
}
};
const onClose = (code: number | null) => {
finalize(code);
};
child.stdout?.once("end", onStdoutEnd);
child.stderr?.once("end", onStderrEnd);
child.once("error", onError);
child.once("exit", onExit);
child.once("close", onClose);
return promise;
}
+7
View File
@@ -86,6 +86,13 @@ describe("parseArgs", () => {
});
});
describe("--fork flag", () => {
test("parses --fork with session ID", () => {
const result = parseArgs(["--fork", "abc123"]);
expect(result.fork).toBe("abc123");
});
});
describe("flags with values", () => {
test("parses --provider", () => {
const result = parseArgs(["--provider", "openai"]);
@@ -246,7 +246,7 @@ describe("validateLineRef", () => {
describe("applyHashlineEdits — replace", () => {
it("replaces single line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nBBB\nccc");
@@ -255,7 +255,9 @@ describe("applyHashlineEdits — replace", () => {
it("range replace (shrink)", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["ONE"] }];
const edits: HashlineEdit[] = [
{ op: "replace_range", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["ONE"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nONE\nddd");
@@ -264,7 +266,7 @@ describe("applyHashlineEdits — replace", () => {
it("range replace (same count)", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["XXX", "YYY"] },
{ op: "replace_range", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: ["XXX", "YYY"] },
];
const result = applyHashlineEdits(content, edits);
@@ -274,7 +276,7 @@ describe("applyHashlineEdits — replace", () => {
it("replaces first line", () => {
const content = "first\nsecond\nthird";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(1, "first"), lines: ["FIRST"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(1, "first"), lines: ["FIRST"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("FIRST\nsecond\nthird");
@@ -283,7 +285,7 @@ describe("applyHashlineEdits — replace", () => {
it("replaces last line", () => {
const content = "first\nsecond\nthird";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "third"), lines: ["THIRD"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(3, "third"), lines: ["THIRD"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("first\nsecond\nTHIRD");
@@ -298,7 +300,7 @@ describe("applyHashlineEdits — replace", () => {
describe("applyHashlineEdits — delete", () => {
it("deletes single line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: [] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nccc");
@@ -307,7 +309,9 @@ describe("applyHashlineEdits — delete", () => {
it("deletes range of lines", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: [] }];
const edits: HashlineEdit[] = [
{ op: "replace_range", pos: makeTag(2, "bbb"), end: makeTag(3, "ccc"), lines: [] },
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nddd");
@@ -315,7 +319,7 @@ describe("applyHashlineEdits — delete", () => {
it("deletes first line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(1, "aaa"), lines: [] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(1, "aaa"), lines: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("bbb\nccc");
@@ -323,7 +327,7 @@ describe("applyHashlineEdits — delete", () => {
it("deletes last line", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "ccc"), lines: [] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(3, "ccc"), lines: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nbbb");
@@ -331,7 +335,7 @@ describe("applyHashlineEdits — delete", () => {
it("replaces line with blank line when lines is ['']", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: [""] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: [""] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\n\nccc");
@@ -380,7 +384,7 @@ describe("applyHashlineEdits — append", () => {
it("inserts at EOF without anchors", () => {
const content = "aaa\nbbb";
const edits = [{ op: "append", lines: ["NEW"] }] as unknown as HashlineEdit[];
const edits: HashlineEdit[] = [{ op: "append_eof", lines: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nbbb\nNEW");
@@ -389,7 +393,7 @@ describe("applyHashlineEdits — append", () => {
it("inserts at EOF into empty file without anchors", () => {
const content = "";
const edits = [{ op: "append", lines: ["NEW"] }] as unknown as HashlineEdit[];
const edits: HashlineEdit[] = [{ op: "append_eof", lines: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("NEW");
@@ -398,7 +402,7 @@ describe("applyHashlineEdits — append", () => {
it("insert at EOF with empty dst inserts a trailing empty line", () => {
const content = "aaa\nbbb";
const edits = [{ op: "append", lines: [] }] as unknown as HashlineEdit[];
const edits: HashlineEdit[] = [{ op: "append_eof", lines: [] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nbbb\n");
@@ -435,7 +439,7 @@ describe("applyHashlineEdits — prepend", () => {
it("prepends at BOF without anchor", () => {
const content = "aaa\nbbb";
const edits = [{ op: "prepend", lines: ["NEW"] }] as unknown as HashlineEdit[];
const edits: HashlineEdit[] = [{ op: "prepend_bof", lines: ["NEW"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("NEW\naaa\nbbb");
expect(result.firstChangedLine).toBe(1);
@@ -463,7 +467,7 @@ describe("applyHashlineEdits — prepend", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ op: "prepend", pos: makeTag(2, "bbb"), lines: ["BEFORE"] },
{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] },
{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nBEFORE\nBBB\nccc");
@@ -482,7 +486,7 @@ describe("applyHashlineEdits — heuristics", () => {
const srcHash = computeLineHash(2, "bbb");
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_line",
pos: parseTag(`2#${srcHash}export function foo(a, b) {}`), // comma in trailing content
lines: ["BBB"],
},
@@ -496,7 +500,7 @@ describe("applyHashlineEdits — heuristics", () => {
const content = ["import { foo } from 'x';", "import { bar } from 'y';", "const x = 1;"].join("\n");
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_range",
pos: makeTag(1, "import { foo } from 'x';"),
end: makeTag(2, "import { bar } from 'y';"),
lines: ["import {foo} from 'x';", "import { bar } from 'y';", "// added"],
@@ -514,7 +518,7 @@ describe("applyHashlineEdits — heuristics", () => {
it("treats same-line ranges as single-line replacements", () => {
const content = "aaa\nbbb\nccc";
const good = makeTag(2, "bbb");
const edits: HashlineEdit[] = [{ op: "replace", pos: good, end: good, lines: ["BBB"] }];
const edits: HashlineEdit[] = [{ op: "replace_range", pos: good, end: good, lines: ["BBB"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\nBBB\nccc");
});
@@ -523,7 +527,7 @@ describe("applyHashlineEdits — heuristics", () => {
const content = "if (ok) {\n run();\n}\nafter();";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_range",
pos: makeTag(1, "if (ok) {"),
end: makeTag(2, " run();"),
lines: ["if (ok) {", " runSafe();", "}"],
@@ -538,7 +542,7 @@ describe("applyHashlineEdits — heuristics", () => {
const content = "start\n oldCall();\nnextCall();\nafter();";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_range",
pos: makeTag(1, "start"),
end: makeTag(2, " oldCall();"),
lines: ["start", " newCall();", "nextCall();"],
@@ -553,7 +557,7 @@ describe("applyHashlineEdits — heuristics", () => {
const content = "if (x) {\n oldBody();\n}\nafter();";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_range",
pos: makeTag(2, " oldBody();"),
end: makeTag(3, "}"),
lines: ["if (x) {", " newBody();", "}"],
@@ -569,7 +573,9 @@ describe("applyHashlineEdits — heuristics", () => {
delete Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
try {
const content = "root\n\tchild\n\t\tvalue\nend";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }];
const edits: HashlineEdit[] = [
{ op: "replace_line", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("root\n\tchild\n\t\treplaced\nend");
expect(result.warnings).toHaveLength(1);
@@ -585,7 +591,9 @@ describe("applyHashlineEdits — heuristics", () => {
Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS = "0";
try {
const content = "root\n\tchild\n\t\tvalue\nend";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] }];
const edits: HashlineEdit[] = [
{ op: "replace_line", pos: makeTag(3, "\t\tvalue"), lines: ["\\t\\treplaced"] },
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("root\n\tchild\n\\t\\treplaced\nend");
expect(result.warnings).toBeUndefined();
@@ -602,7 +610,7 @@ describe("applyHashlineEdits — heuristics", () => {
const content = "root\n\tchild\n\t\tvalue\nend";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_line",
pos: makeTag(3, "\t\tvalue"),
lines: ["\t\talready-tab", "\\t\\tescaped-still-literal"],
},
@@ -618,7 +626,7 @@ describe("applyHashlineEdits — heuristics", () => {
it("warns on literal \\uDDDD without changing content", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "bbb"), lines: ["\\uDDDD"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["\\uDDDD"] }];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\n\\uDDDD\nccc");
expect(result.warnings).toHaveLength(1);
@@ -634,8 +642,8 @@ describe("applyHashlineEdits — multiple edits", () => {
it("applies two non-overlapping replaces (bottom-up safe)", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [
{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] },
{ op: "replace", pos: makeTag(4, "ddd"), lines: ["DDD"] },
{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] },
{ op: "replace_line", pos: makeTag(4, "ddd"), lines: ["DDD"] },
];
const result = applyHashlineEdits(content, edits);
@@ -646,8 +654,8 @@ describe("applyHashlineEdits — multiple edits", () => {
it("applies replace + delete in one call", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: HashlineEdit[] = [
{ op: "replace", pos: makeTag(2, "bbb"), lines: ["BBB"] },
{ op: "replace", pos: makeTag(4, "ddd"), lines: [] },
{ op: "replace_line", pos: makeTag(2, "bbb"), lines: ["BBB"] },
{ op: "replace_line", pos: makeTag(4, "ddd"), lines: [] },
];
const result = applyHashlineEdits(content, edits);
@@ -657,7 +665,7 @@ describe("applyHashlineEdits — multiple edits", () => {
it("applies replace + append in one call", () => {
const content = "aaa\nbbb\nccc";
const edits: HashlineEdit[] = [
{ op: "replace", pos: makeTag(3, "ccc"), lines: ["CCC"] },
{ op: "replace_line", pos: makeTag(3, "ccc"), lines: ["CCC"] },
{ op: "append", pos: makeTag(1, "aaa"), lines: ["INSERTED"] },
];
@@ -669,12 +677,12 @@ describe("applyHashlineEdits — multiple edits", () => {
const content = "one\ntwo\nthree\nfour\nfive\nsix";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_range",
pos: makeTag(2, "two"),
end: makeTag(3, "three"),
lines: ["TWO_THREE"],
},
{ op: "replace", pos: makeTag(6, "six"), lines: ["SIX"] },
{ op: "replace_line", pos: makeTag(6, "six"), lines: ["SIX"] },
];
const result = applyHashlineEdits(content, edits);
@@ -684,7 +692,9 @@ describe("applyHashlineEdits — multiple edits", () => {
it("single-line replace expanding to multiple lines is not a noop", () => {
const content = "aaa\n\nccc";
const blankHash = computeLineHash(2, "");
const edits: HashlineEdit[] = [{ op: "replace", pos: { line: 2, hash: blankHash }, lines: ["", "inserted", ""] }];
const edits: HashlineEdit[] = [
{ op: "replace_line", pos: { line: 2, hash: blankHash }, lines: ["", "inserted", ""] },
];
const result = applyHashlineEdits(content, edits);
expect(result.lines).toBe("aaa\n\ninserted\n\nccc");
expect(result.firstChangedLine).toBe(2);
@@ -706,13 +716,13 @@ describe("applyHashlineEdits — errors", () => {
it("rejects stale hash", () => {
const content = "aaa\nbbb\nccc";
// Use a hash that doesn't match any line (avoid 00 — ccc hashes to 00)
const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("2#QQ"), lines: ["BBB"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("2#QQ"), lines: ["BBB"] }];
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
});
it("stale hash error shows >>> markers with correct hashes", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("2#QQ"), lines: ["BBB"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("2#QQ"), lines: ["BBB"] }];
try {
applyHashlineEdits(content, edits);
@@ -736,8 +746,8 @@ describe("applyHashlineEdits — errors", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
// Use hashes that don't match any line (avoid 00 — ccc hashes to 00)
const edits: HashlineEdit[] = [
{ op: "replace", pos: parseTag("2#ZZ"), lines: ["BBB"] },
{ op: "replace", pos: parseTag("4#ZZ"), lines: ["DDD"] },
{ op: "replace_line", pos: parseTag("2#ZZ"), lines: ["BBB"] },
{ op: "replace_line", pos: parseTag("4#ZZ"), lines: ["DDD"] },
];
try {
@@ -758,7 +768,7 @@ describe("applyHashlineEdits — errors", () => {
it("does not relocate stale line refs even when hash uniquely matches another line", () => {
const content = "aaa\nbbb\nccc";
const staleButUnique = parseTag(`2#${computeLineHash(1, "ccc")}`);
const edits: HashlineEdit[] = [{ op: "replace", pos: staleButUnique, lines: ["CCC"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: staleButUnique, lines: ["CCC"] }];
try {
applyHashlineEdits(content, edits);
expect.unreachable("should have thrown");
@@ -772,21 +782,23 @@ describe("applyHashlineEdits — errors", () => {
it("does not relocate when expected hash is non-unique", () => {
const content = "dup\nmid\ndup";
const staleDuplicate = parseTag(`2#${computeLineHash(1, "dup")}`);
const edits: HashlineEdit[] = [{ op: "replace", pos: staleDuplicate, lines: ["DUP"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: staleDuplicate, lines: ["DUP"] }];
expect(() => applyHashlineEdits(content, edits)).toThrow(HashlineMismatchError);
});
it("rejects out-of-range line", () => {
const content = "aaa\nbbb";
const edits: HashlineEdit[] = [{ op: "replace", pos: parseTag("10#ZZ"), lines: ["X"] }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: parseTag("10#ZZ"), lines: ["X"] }];
expect(() => applyHashlineEdits(content, edits)).toThrow(/does not exist/);
});
it("rejects range with start > end", () => {
const content = "aaa\nbbb\nccc\nddd\neee";
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(5, "eee"), end: makeTag(2, "bbb"), lines: ["X"] }];
const edits: HashlineEdit[] = [
{ op: "replace_range", pos: makeTag(5, "eee"), end: makeTag(2, "bbb"), lines: ["X"] },
];
expect(() => applyHashlineEdits(content, edits)).toThrow();
});
@@ -977,7 +989,7 @@ describe("hashlineParseContent", () => {
const fileContent = "# Title\n- old item\n- old item 2\nfooter";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_line",
pos: makeTag(2, "- old item"),
lines: hashlineParseText("- [x] new item"),
},
@@ -990,7 +1002,7 @@ describe("hashlineParseContent", () => {
// All replacement lines start with '- ', triggering the 50% heuristic when '-' matched.
const fileContent = "- [x] done\n- [ ] pending\n- [ ] also pending";
const newContent = hashlineParseText("- [x] done");
const edits: HashlineEdit[] = [{ op: "replace", pos: makeTag(2, "- [ ] pending"), lines: newContent }];
const edits: HashlineEdit[] = [{ op: "replace_line", pos: makeTag(2, "- [ ] pending"), lines: newContent }];
const result = applyHashlineEdits(fileContent, edits);
expect(result.lines).toBe("- [x] done\n- [x] done\n- [ ] also pending");
});
@@ -1014,7 +1026,7 @@ describe("hashlineParseContent", () => {
const fileContent = [" # cuDNN section", " # Note: Using version 1.23.0", ' $Version = "1.23.0"'].join("\n");
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_line",
pos: makeTag(2, " # Note: Using version 1.23.0"),
lines: hashlineParseText([" # Note: Using version 1.24.x"]),
},
@@ -1029,7 +1041,7 @@ describe("hashlineParseContent", () => {
const fileContent = "const x = 1;\n// TODO: old\n# TODO: remove this\nconst y = 2;";
const edits: HashlineEdit[] = [
{
op: "replace",
op: "replace_line",
pos: makeTag(3, "# TODO: remove this"),
lines: hashlineParseText(["# TODO: remove this -- done"]),
},
@@ -0,0 +1,40 @@
import { describe, expect, it } from "bun:test";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import type { Args } from "../src/cli/args";
import { buildInitialMessage } from "../src/cli/initial-message";
function createArgs(messages: string[]): Args {
return {
messages,
fileArgs: [],
unknownFlags: new Map(),
};
}
describe("buildInitialMessage", () => {
it("combines stdin, file text, and the first CLI message", () => {
const parsed = createArgs(["first", "second"]);
const images: ImageContent[] = [{ type: "image", data: "abc123", mimeType: "image/png" }];
const result = buildInitialMessage({
parsed,
stdinContent: "stdin",
fileText: "file-",
fileImages: images,
});
expect(result.initialMessage).toBe("stdin\nfile-first");
expect(result.initialImages).toEqual(images);
expect(parsed.messages).toEqual(["second"]);
});
it("leaves plain CLI messages untouched when there is no initial file or stdin input", () => {
const parsed = createArgs(["first", "second"]);
const result = buildInitialMessage({ parsed });
expect(result.initialMessage).toBeUndefined();
expect(result.initialImages).toBeUndefined();
expect(parsed.messages).toEqual(["first", "second"]);
});
});
@@ -4,25 +4,25 @@ import { KeybindingsManager } from "../src/config/keybindings";
describe("KeybindingsManager.getDisplayString", () => {
it("formats a single binding as a human-readable key hint", () => {
const keybindings = KeybindingsManager.inMemory({
dequeue: "alt+up",
"app.message.dequeue": "alt+up",
});
expect(keybindings.getDisplayString("dequeue")).toBe("Alt+Up");
expect(keybindings.getDisplayString("app.message.dequeue")).toBe("Alt+Up");
});
it("formats multiple bindings with the existing separator", () => {
const keybindings = KeybindingsManager.inMemory({
copyPrompt: ["alt+shift+c", "ctrl+shift+c"],
"app.clipboard.copyPrompt": ["alt+shift+c", "ctrl+shift+c"],
});
expect(keybindings.getDisplayString("copyPrompt")).toBe("Alt+Shift+C/Ctrl+Shift+C");
expect(keybindings.getDisplayString("app.clipboard.copyPrompt")).toBe("Alt+Shift+C/Ctrl+Shift+C");
});
it("returns an empty string when the action has no binding", () => {
const keybindings = KeybindingsManager.inMemory({
copyPrompt: [],
"app.clipboard.copyPrompt": [],
});
expect(keybindings.getDisplayString("copyPrompt")).toBe("");
expect(keybindings.getDisplayString("app.clipboard.copyPrompt")).toBe("");
});
});
@@ -0,0 +1,50 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { setKeybindings } from "@oh-my-pi/pi-tui";
import { KeybindingsManager } from "../src/config/keybindings";
describe("KeybindingsManager.create", () => {
beforeEach(() => {
setKeybindings(KeybindingsManager.inMemory());
});
afterEach(() => {
setKeybindings(KeybindingsManager.inMemory());
});
it("migrates legacy keybinding names on disk during create", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-keybindings-"));
const configPath = path.join(agentDir, "keybindings.json");
await Bun.write(
configPath,
`${JSON.stringify(
{
fork: "ctrl+f",
selectConfirm: "enter",
cursorUp: "ctrl+p",
},
null,
2,
)}\n`,
);
try {
const manager = KeybindingsManager.create(agentDir);
const writtenConfig = await Bun.file(configPath).json();
expect(manager.getKeys("app.session.fork")).toEqual(["ctrl+f"]);
expect(manager.getKeys("tui.select.confirm")).toEqual(["enter"]);
expect(manager.getKeys("tui.editor.cursorUp")).toEqual(["ctrl+p"]);
expect(writtenConfig).toEqual({
"app.session.fork": "ctrl+f",
"tui.editor.cursorUp": "ctrl+p",
"tui.select.confirm": "enter",
});
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
});
@@ -4,23 +4,23 @@ import { buildHotkeysMarkdown } from "../../../src/modes/utils/hotkeys-markdown"
describe("buildHotkeysMarkdown", () => {
it("emits flush-left markdown so headings and tables are parsed instead of treated as indented text", () => {
const displayStrings: Record<string, string> = {
copyLine: "Alt+Shift+L",
copyPrompt: "Ctrl+Shift+P",
togglePlanMode: "Alt+M",
expandTools: "Ctrl+O",
interrupt: "Esc",
clear: "Ctrl+C",
exit: "Ctrl+D",
suspend: "Ctrl+Z",
cycleThinkingLevel: "Shift+Tab",
cycleModelForward: "Ctrl+P",
cycleModelBackward: "Shift+Ctrl+P",
selectModel: "Ctrl+L",
historySearch: "Ctrl+R",
toggleThinking: "Ctrl+T",
externalEditor: "Ctrl+G",
pasteImage: "Ctrl+V",
toggleSTT: "Alt+H",
"app.clipboard.copyLine": "Alt+Shift+L",
"app.clipboard.copyPrompt": "Ctrl+Shift+P",
"app.plan.toggle": "Alt+M",
"app.tools.expand": "Ctrl+O",
"app.interrupt": "Esc",
"app.clear": "Ctrl+C",
"app.exit": "Ctrl+D",
"app.suspend": "Ctrl+Z",
"app.thinking.cycle": "Shift+Tab",
"app.model.cycleForward": "Ctrl+P",
"app.model.cycleBackward": "Shift+Ctrl+P",
"app.model.select": "Ctrl+L",
"app.history.search": "Ctrl+R",
"app.thinking.toggle": "Ctrl+T",
"app.editor.external": "Ctrl+G",
"app.clipboard.pasteImage": "Ctrl+V",
"app.stt.toggle": "Alt+H",
};
const markdown = buildHotkeysMarkdown({
keybindings: {
@@ -1,30 +1,30 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { EditorKeybindingsManager, setEditorKeybindings } from "../../tui/src/keybindings";
import { KeybindingsManager } from "../src/config/keybindings";
import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui";
import { KeybindingsManager as AppKeybindingsManager } from "../src/config/keybindings";
import { createPromptActionAutocompleteProvider } from "../src/modes/prompt-action-autocomplete";
describe("prompt action autocomplete", () => {
beforeEach(() => {
setEditorKeybindings(
new EditorKeybindingsManager({
cursorLineStart: ["home", "f6"],
cursorLineEnd: "f7",
undo: "f8",
setKeybindings(
new KeybindingsManager({
"tui.editor.cursorLineStart": { defaultKeys: ["home", "f6"], description: "Move cursor to line start" },
"tui.editor.cursorLineEnd": { defaultKeys: "f7", description: "Move cursor to line end" },
"tui.editor.undo": { defaultKeys: "f8", description: "Undo" },
}),
);
});
afterEach(() => {
setEditorKeybindings(new EditorKeybindingsManager());
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
});
it("shows prompt actions with configured shortcut hints", async () => {
const provider = createPromptActionAutocompleteProvider({
commands: [],
basePath: "/tmp",
keybindings: KeybindingsManager.inMemory({
copyLine: "ctrl+shift+l",
copyPrompt: ["alt+shift+c", "ctrl+shift+c"],
keybindings: AppKeybindingsManager.inMemory({
"app.clipboard.copyLine": "ctrl+shift+l",
"app.clipboard.copyPrompt": ["alt+shift+c", "ctrl+shift+c"],
}),
copyCurrentLine: () => {},
copyPrompt: () => {},
@@ -64,7 +64,7 @@ describe("prompt action autocomplete", () => {
const provider = createPromptActionAutocompleteProvider({
commands: [],
basePath: "/tmp",
keybindings: KeybindingsManager.inMemory(),
keybindings: AppKeybindingsManager.inMemory(),
copyCurrentLine: () => {},
copyPrompt: () => {},
undo: prefix => {
@@ -97,7 +97,7 @@ describe("prompt action autocomplete", () => {
const provider = createPromptActionAutocompleteProvider({
commands: [],
basePath: "/tmp",
keybindings: KeybindingsManager.inMemory(),
keybindings: AppKeybindingsManager.inMemory(),
copyCurrentLine: () => {},
copyPrompt: () => {},
undo: () => {},
@@ -1,4 +1,4 @@
import { matchesKey } from "../keys";
import { getKeybindings } from "../keybindings";
import { Loader } from "./loader";
/**
@@ -27,7 +27,8 @@ export class CancellableLoader extends Loader {
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
const kb = getKeybindings();
if (kb.matches(data, "tui.select.cancel")) {
this.#abortController.abort();
this.onAbort?.();
}
+57 -58
View File
@@ -1,7 +1,7 @@
import { getProjectDir } from "@oh-my-pi/pi-utils";
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete";
import { BracketedPasteHandler } from "../bracketed-paste";
import { type EditorKeybindingsManager, getEditorKeybindings } from "../keybindings";
import { getKeybindings, type KeybindingsManager } from "../keybindings";
import { extractPrintableText, matchesKey } from "../keys";
import { KillRing } from "../kill-ring";
import type { SymbolTheme } from "../symbols";
@@ -15,7 +15,12 @@ import {
truncateToWidth,
visibleWidth,
} from "../utils";
import { SelectList, type SelectListTheme } from "./select-list";
import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 32,
};
const segmenter = getSegmenter();
@@ -691,12 +696,12 @@ export class Editor implements Component, Focusable {
}
handleInput(data: string): void {
const kb = getEditorKeybindings();
const kb = getKeybindings();
// Handle character jump mode (awaiting next character to jump to)
if (this.#jumpMode !== null) {
// Cancel if the hotkey is pressed again
if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) {
if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {
this.#jumpMode = null;
return;
}
@@ -728,12 +733,12 @@ export class Editor implements Component, Focusable {
// Handle special key combinations first
// Ctrl+C - Exit (let parent handle this)
if (matchesKey(data, "ctrl+c")) {
if (kb.matches(data, "tui.input.copy")) {
return;
}
// Undo
if (kb.matches(data, "undo")) {
if (kb.matches(data, "tui.editor.undo")) {
this.#applyUndo();
return;
}
@@ -741,27 +746,26 @@ export class Editor implements Component, Focusable {
// Handle autocomplete special keys first (but don't block other input)
if (this.#autocompleteState && this.#autocompleteList) {
// Escape - cancel autocomplete
if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
if (kb.matches(data, "tui.select.cancel")) {
this.#cancelAutocomplete(true);
return;
}
// Let the autocomplete list handle navigation and selection
else if (
matchesKey(data, "up") ||
matchesKey(data, "down") ||
matchesKey(data, "pageUp") ||
matchesKey(data, "pageDown") ||
matchesKey(data, "enter") ||
matchesKey(data, "return") ||
kb.matches(data, "tui.select.up") ||
kb.matches(data, "tui.select.down") ||
kb.matches(data, "tui.select.pageUp") ||
kb.matches(data, "tui.select.pageDown") ||
kb.matches(data, "tui.input.submit") ||
data === "\n" ||
matchesKey(data, "tab")
kb.matches(data, "tui.input.tab")
) {
// Only pass navigation keys to the list, not Enter/Tab (we handle those directly)
if (
matchesKey(data, "up") ||
matchesKey(data, "down") ||
matchesKey(data, "pageUp") ||
matchesKey(data, "pageDown")
kb.matches(data, "tui.select.up") ||
kb.matches(data, "tui.select.down") ||
kb.matches(data, "tui.select.pageUp") ||
kb.matches(data, "tui.select.pageDown")
) {
this.#autocompleteList.handleInput(data);
this.onAutocompleteUpdate?.();
@@ -769,7 +773,7 @@ export class Editor implements Component, Focusable {
}
// If Tab was pressed, always apply the selection
if (matchesKey(data, "tab")) {
if (kb.matches(data, "tui.input.tab")) {
const selected = this.#autocompleteList.getSelectedItem();
if (selected && this.#autocompleteProvider) {
const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
@@ -801,10 +805,7 @@ export class Editor implements Component, Focusable {
}
// If Enter was pressed on a slash command, apply completion and submit
if (
(matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") &&
this.#autocompletePrefix.startsWith("/")
) {
if ((kb.matches(data, "tui.input.submit") || data === "\n") && this.#autocompletePrefix.startsWith("/")) {
// Check for stale autocomplete state due to debounce
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
@@ -832,7 +833,7 @@ export class Editor implements Component, Focusable {
// Don't return - fall through to submission logic
}
// If Enter was pressed on a file path, apply completion
else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
else if (kb.matches(data, "tui.input.submit") || data === "\n") {
const selected = this.#autocompleteList.getSelectedItem();
if (selected && this.#autocompleteProvider) {
const result = this.#autocompleteProvider.applyCompletion(
@@ -863,7 +864,7 @@ export class Editor implements Component, Focusable {
}
// Tab key - context-aware completion (but not when already autocompleting)
if (matchesKey(data, "tab") && !this.#autocompleteState) {
if (kb.matches(data, "tui.input.tab") && !this.#autocompleteState) {
this.#handleTabCompletion();
return;
}
@@ -920,7 +921,7 @@ export class Editor implements Component, Focusable {
data === "\x1b[27;5;13~" || // Ctrl+Enter (legacy format)
data === "\x1b\r" || // Option+Enter in some terminals (legacy)
data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
matchesKey(data, "shift+enter") || // Shift+Enter (Kitty protocol, handles lock bits)
kb.matches(data, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
(data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
(data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
) {
@@ -932,7 +933,7 @@ export class Editor implements Component, Focusable {
this.#addNewLine();
}
// Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits)
else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
else if (kb.matches(data, "tui.input.submit") || data === "\n") {
// If submit is disabled, do nothing
if (this.disableSubmit) {
return;
@@ -941,17 +942,17 @@ export class Editor implements Component, Focusable {
this.#submitValue();
}
// Backspace (including Shift+Backspace)
else if (matchesKey(data, "backspace") || matchesKey(data, "shift+backspace")) {
else if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
this.#handleBackspace();
}
// Line navigation shortcuts (Home/End keys)
else if (matchesKey(data, "home")) {
else if (kb.matches(data, "tui.editor.cursorLineStart")) {
this.#moveToLineStart();
} else if (matchesKey(data, "end")) {
} else if (kb.matches(data, "tui.editor.cursorLineEnd")) {
this.#moveToLineEnd();
}
// Page navigation (PageUp/PageDown)
else if (matchesKey(data, "pageUp")) {
else if (kb.matches(data, "tui.editor.pageUp")) {
if (this.#isEditorEmpty()) {
this.#navigateHistory(-1);
} else if (this.#historyIndex > -1 && this.#isOnFirstVisualLine()) {
@@ -959,7 +960,7 @@ export class Editor implements Component, Focusable {
} else {
this.#pageScroll(-1);
}
} else if (matchesKey(data, "pageDown")) {
} else if (kb.matches(data, "tui.editor.pageDown")) {
if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) {
this.#navigateHistory(1);
} else {
@@ -967,21 +968,21 @@ export class Editor implements Component, Focusable {
}
}
// Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
else if (matchesKey(data, "delete") || matchesKey(data, "shift+delete")) {
else if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
this.#handleForwardDelete();
}
// Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
else if (matchesKey(data, "alt+left") || matchesKey(data, "ctrl+left")) {
else if (kb.matches(data, "tui.editor.cursorWordLeft")) {
// Word left
this.#resetKillSequence();
this.#moveWordBackwards();
} else if (matchesKey(data, "alt+right") || matchesKey(data, "ctrl+right")) {
} else if (kb.matches(data, "tui.editor.cursorWordRight")) {
// Word right
this.#resetKillSequence();
this.#moveWordForwards();
}
// Arrow keys
else if (matchesKey(data, "up")) {
else if (kb.matches(data, "tui.editor.cursorUp")) {
// Up - history navigation or cursor movement
if (this.#isEditorEmpty()) {
this.#navigateHistory(-1); // Start browsing history
@@ -993,7 +994,7 @@ export class Editor implements Component, Focusable {
} else {
this.#moveCursor(-1, 0); // Cursor movement (within text or history entry)
}
} else if (matchesKey(data, "down")) {
} else if (kb.matches(data, "tui.editor.cursorDown")) {
// Down - history navigation or cursor movement
if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) {
this.#navigateHistory(1); // Navigate to newer history entry or clear
@@ -1003,10 +1004,10 @@ export class Editor implements Component, Focusable {
} else {
this.#moveCursor(1, 0); // Cursor movement (within text or history entry)
}
} else if (matchesKey(data, "right")) {
} else if (kb.matches(data, "tui.editor.cursorRight")) {
// Right
this.#moveCursor(0, 1);
} else if (matchesKey(data, "left")) {
} else if (kb.matches(data, "tui.editor.cursorLeft")) {
// Left
this.#moveCursor(0, -1);
}
@@ -1015,9 +1016,9 @@ export class Editor implements Component, Focusable {
this.#insertCharacter(" ");
}
// Character jump mode triggers
else if (kb.matches(data, "jumpForward")) {
else if (kb.matches(data, "tui.editor.jumpForward")) {
this.#jumpMode = "forward";
} else if (kb.matches(data, "jumpBackward")) {
} else if (kb.matches(data, "tui.editor.jumpBackward")) {
this.#jumpMode = "backward";
}
// Printable keystrokes, including Kitty CSI-u text-producing sequences.
@@ -1393,10 +1394,10 @@ export class Editor implements Component, Focusable {
}
}
#shouldSubmitOnBackslashEnter(data: string, kb: EditorKeybindingsManager): boolean {
#shouldSubmitOnBackslashEnter(data: string, kb: KeybindingsManager): boolean {
if (this.disableSubmit) return false;
if (!matchesKey(data, "enter")) return false;
const submitKeys = kb.getKeys("submit");
const submitKeys = kb.getKeys("tui.input.submit");
const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
if (!hasShiftEnter) return false;
@@ -2184,11 +2185,7 @@ export class Editor implements Component, Focusable {
if (suggestions && suggestions.items.length > 0) {
this.#autocompletePrefix = suggestions.prefix;
this.#autocompleteList = new SelectList(
suggestions.items,
this.#autocompleteMaxVisible,
this.#theme.selectList,
);
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
this.#autocompleteState = "regular";
this.onAutocompleteUpdate?.();
} else {
@@ -2196,6 +2193,16 @@ export class Editor implements Component, Focusable {
this.onAutocompleteUpdate?.();
}
}
#createAutocompleteList(
prefix: string,
items: Array<{ value: string; label: string; description?: string }>,
): SelectList {
// Layout options prepared for future SelectList enhancements (e.g., for slash commands)
const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
// TODO: Pass layout to SelectList when constructor is updated to support it
void layout; // Use layout variable to avoid lint warnings
return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList);
}
#handleTabCompletion(): void {
if (!this.#autocompleteProvider) return;
@@ -2263,11 +2270,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
}
this.#autocompletePrefix = suggestions.prefix;
this.#autocompleteList = new SelectList(
suggestions.items,
this.#autocompleteMaxVisible,
this.#theme.selectList,
);
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
this.#autocompleteState = "force";
this.onAutocompleteUpdate?.();
} else {
@@ -2313,11 +2316,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
if (suggestions && suggestions.items.length > 0) {
this.#autocompletePrefix = suggestions.prefix;
// Always create new SelectList to ensure update
this.#autocompleteList = new SelectList(
suggestions.items,
this.#autocompleteMaxVisible,
this.#theme.selectList,
);
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
this.onAutocompleteUpdate?.();
} else {
this.#cancelAutocomplete();
+19 -19
View File
@@ -1,5 +1,5 @@
import { BracketedPasteHandler } from "../bracketed-paste";
import { getEditorKeybindings } from "../keybindings";
import { getKeybindings } from "../keybindings";
import { extractPrintableText } from "../keys";
import { KillRing } from "../kill-ring";
import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
@@ -65,69 +65,69 @@ export class Input implements Component, Focusable {
return;
}
const kb = getEditorKeybindings();
const kb = getKeybindings();
// Escape/Cancel
if (kb.matches(data, "selectCancel")) {
if (kb.matches(data, "tui.select.cancel")) {
if (this.onEscape) this.onEscape();
return;
}
// Undo
if (kb.matches(data, "undo")) {
if (kb.matches(data, "tui.editor.undo")) {
this.#undo();
return;
}
// Submit
if (kb.matches(data, "submit") || data === "\n") {
if (kb.matches(data, "tui.input.submit") || data === "\n") {
if (this.onSubmit) this.onSubmit(this.#value);
return;
}
// Deletion
if (kb.matches(data, "deleteCharBackward")) {
if (kb.matches(data, "tui.editor.deleteCharBackward")) {
this.#handleBackspace();
return;
}
if (kb.matches(data, "deleteCharForward")) {
if (kb.matches(data, "tui.editor.deleteCharForward")) {
this.#handleForwardDelete();
return;
}
if (kb.matches(data, "deleteWordBackward")) {
if (kb.matches(data, "tui.editor.deleteWordBackward")) {
this.#deleteWordBackwards();
return;
}
if (kb.matches(data, "deleteWordForward")) {
if (kb.matches(data, "tui.editor.deleteWordForward")) {
this.#deleteWordForward();
return;
}
if (kb.matches(data, "deleteToLineStart")) {
if (kb.matches(data, "tui.editor.deleteToLineStart")) {
this.#deleteToLineStart();
return;
}
if (kb.matches(data, "deleteToLineEnd")) {
if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
this.#deleteToLineEnd();
return;
}
// Kill ring actions
if (kb.matches(data, "yank")) {
if (kb.matches(data, "tui.editor.yank")) {
this.#yank();
return;
}
if (kb.matches(data, "yankPop")) {
if (kb.matches(data, "tui.editor.yankPop")) {
this.#yankPop();
return;
}
// Cursor movement
if (kb.matches(data, "cursorLeft")) {
if (kb.matches(data, "tui.editor.cursorLeft")) {
this.#lastAction = null;
if (this.#cursor > 0) {
const beforeCursor = this.#value.slice(0, this.#cursor);
@@ -138,7 +138,7 @@ export class Input implements Component, Focusable {
return;
}
if (kb.matches(data, "cursorRight")) {
if (kb.matches(data, "tui.editor.cursorRight")) {
this.#lastAction = null;
if (this.#cursor < this.#value.length) {
const afterCursor = this.#value.slice(this.#cursor);
@@ -149,24 +149,24 @@ export class Input implements Component, Focusable {
return;
}
if (kb.matches(data, "cursorLineStart")) {
if (kb.matches(data, "tui.editor.cursorLineStart")) {
this.#lastAction = null;
this.#cursor = 0;
return;
}
if (kb.matches(data, "cursorLineEnd")) {
if (kb.matches(data, "tui.editor.cursorLineEnd")) {
this.#lastAction = null;
this.#cursor = this.#value.length;
return;
}
if (kb.matches(data, "cursorWordLeft")) {
if (kb.matches(data, "tui.editor.cursorWordLeft")) {
this.#moveWordBackwards();
return;
}
if (kb.matches(data, "cursorWordRight")) {
if (kb.matches(data, "tui.editor.cursorWordRight")) {
this.#moveWordForwards();
return;
}
+18 -9
View File
@@ -306,7 +306,7 @@ export class Markdown implements Component {
styledHeading = this.#theme.heading(this.#theme.bold(headingPrefix + headingText));
}
lines.push(styledHeading);
if (nextTokenType !== "space") {
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after headings (unless space token follows)
}
break;
@@ -332,7 +332,7 @@ export class Markdown implements Component {
for (const asciiLine of Bun.stripANSI(ascii).split("\n")) {
lines.push(asciiLine);
}
if (nextTokenType !== "space") {
if (nextTokenType && nextTokenType !== "space") {
lines.push("");
}
break;
@@ -354,7 +354,7 @@ export class Markdown implements Component {
}
}
lines.push(this.#theme.codeBlockBorder("```"));
if (nextTokenType !== "space") {
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after code blocks (unless space token follows)
}
break;
@@ -369,7 +369,7 @@ export class Markdown implements Component {
}
case "table": {
const tableLines = this.#renderTable(token as TableToken, width, styleContext);
const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext);
lines.push(...tableLines);
break;
}
@@ -415,7 +415,7 @@ export class Markdown implements Component {
lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
}
}
if (nextTokenType !== "space") {
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after blockquotes (unless space token follows)
}
break;
@@ -423,7 +423,7 @@ export class Markdown implements Component {
case "hr":
lines.push(this.#theme.hr(this.#theme.symbols.hrChar.repeat(Math.min(width, 80))));
if (nextTokenType !== "space") {
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after horizontal rules (unless space token follows)
}
break;
@@ -669,7 +669,12 @@ export class Markdown implements Component {
* Render a table with width-aware cell wrapping.
* Cells that don't fit are wrapped to multiple lines.
*/
#renderTable(token: TableToken, availableWidth: number, styleContext?: InlineStyleContext): string[] {
#renderTable(
token: TableToken,
availableWidth: number,
nextTokenType?: string,
styleContext?: InlineStyleContext,
): string[] {
const lines: string[] = [];
const numCols = token.header.length;
@@ -684,7 +689,9 @@ export class Markdown implements Component {
if (availableForCells < numCols) {
// Too narrow to render a stable table. Fall back to raw markdown.
const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];
fallbackLines.push("");
if (nextTokenType && nextTokenType !== "space") {
fallbackLines.push("");
}
return fallbackLines;
}
@@ -834,7 +841,9 @@ export class Markdown implements Component {
const bottomBorderCells = columnWidths.map(w => h.repeat(w));
lines.push(`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`);
lines.push(""); // Add spacing after table
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after table
}
return lines;
}
}
+118 -76
View File
@@ -1,8 +1,21 @@
import { matchesKey } from "../keys";
import { getKeybindings } from "../keybindings";
import type { SymbolTheme } from "../symbols";
import type { Component } from "../tui";
import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
const PRIMARY_COLUMN_GAP = 2;
const MIN_DESCRIPTION_WIDTH = 10;
function sanitizeSingleLine(text: string): string {
return replaceTabs(text)
.replace(/[\r\n]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max));
export interface SelectItem {
value: string;
label: string;
@@ -20,11 +33,18 @@ export interface SelectListTheme {
symbols: SymbolTheme;
}
function sanitizeSingleLine(text: string): string {
return replaceTabs(text)
.replace(/[\r\n]+/g, " ")
.replace(/\s+/g, " ")
.trim();
export interface SelectListTruncatePrimaryContext {
text: string;
maxWidth: number;
columnWidth: number;
item: SelectItem;
isSelected: boolean;
}
export interface SelectListLayoutOptions {
minPrimaryColumnWidth?: number;
maxPrimaryColumnWidth?: number;
truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
}
export class SelectList implements Component {
@@ -39,6 +59,7 @@ export class SelectList implements Component {
private readonly items: ReadonlyArray<SelectItem>,
private readonly maxVisible: number,
private readonly theme: SelectListTheme,
private readonly layout: SelectListLayoutOptions = {},
) {
this.#filteredItems = items;
}
@@ -66,6 +87,8 @@ export class SelectList implements Component {
return lines;
}
const primaryColumnWidth = this.#getPrimaryColumnWidth();
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
@@ -79,71 +102,8 @@ export class SelectList implements Component {
if (!item) continue;
const isSelected = i === this.#selectedIndex;
const labelText = sanitizeSingleLine(item.label || item.value);
const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined;
let line = "";
if (isSelected) {
// Use arrow indicator for selection - entire line uses selectedText color
const prefix = `${this.theme.symbols.cursor} `;
const prefixWidth = visibleWidth(prefix);
const displayValue = labelText;
if (descriptionText && width > 40) {
// Calculate how much space we have for value + description
const maxValueWidth = Math.min(30, width - prefixWidth - 4);
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, Ellipsis.Omit);
const spacing = padding(Math.max(1, 32 - truncatedValue.length));
// Calculate remaining space for description using visible widths
const descriptionStart = prefixWidth + truncatedValue.length + spacing.length;
const remainingWidth = width - descriptionStart - 2; // -2 for safety
if (remainingWidth > 10) {
const truncatedDesc = truncateToWidth(descriptionText, remainingWidth, Ellipsis.Omit);
// Apply selectedText to entire line content
line = this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
} else {
// Not enough space for description
const maxWidth = width - prefixWidth - 2;
line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, Ellipsis.Omit)}`);
}
} else {
// No description or not enough width
const maxWidth = width - prefixWidth - 2;
line = this.theme.selectedText(`${prefix}${truncateToWidth(displayValue, maxWidth, Ellipsis.Omit)}`);
}
} else {
const displayValue = labelText;
const prefix = padding(visibleWidth(this.theme.symbols.cursor) + 1);
if (descriptionText && width > 40) {
// Calculate how much space we have for value + description
const maxValueWidth = Math.min(30, width - prefix.length - 4);
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, Ellipsis.Omit);
const spacing = padding(Math.max(1, 32 - truncatedValue.length));
// Calculate remaining space for description
const descriptionStart = prefix.length + truncatedValue.length + spacing.length;
const remainingWidth = width - descriptionStart - 2; // -2 for safety
if (remainingWidth > 10) {
const truncatedDesc = truncateToWidth(descriptionText, remainingWidth, Ellipsis.Omit);
const descText = this.theme.description(spacing + truncatedDesc);
line = prefix + truncatedValue + descText;
} else {
// Not enough space for description
const maxWidth = width - prefix.length - 2;
line = prefix + truncateToWidth(displayValue, maxWidth, Ellipsis.Omit);
}
} else {
// No description or not enough width
const maxWidth = width - prefix.length - 2;
line = prefix + truncateToWidth(displayValue, maxWidth, Ellipsis.Omit);
}
}
lines.push(line);
lines.push(this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth));
}
// Add scroll indicators if needed
@@ -158,41 +118,123 @@ export class SelectList implements Component {
handleInput(keyData: string): void {
if (this.#filteredItems.length === 0) return;
const kb = getKeybindings();
// Up arrow - wrap to bottom when at top
if (matchesKey(keyData, "up")) {
if (kb.matches(keyData, "tui.select.up")) {
this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;
this.#notifySelectionChange();
}
// Down arrow - wrap to top when at bottom
else if (matchesKey(keyData, "down")) {
else if (kb.matches(keyData, "tui.select.down")) {
this.#selectedIndex = this.#selectedIndex === this.#filteredItems.length - 1 ? 0 : this.#selectedIndex + 1;
this.#notifySelectionChange();
}
// PageUp - jump up by one visible page
else if (matchesKey(keyData, "pageUp")) {
else if (kb.matches(keyData, "tui.select.pageUp")) {
this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisible);
this.#notifySelectionChange();
}
// PageDown - jump down by one visible page
else if (matchesKey(keyData, "pageDown")) {
else if (kb.matches(keyData, "tui.select.pageDown")) {
this.#selectedIndex = Math.min(this.#filteredItems.length - 1, this.#selectedIndex + this.maxVisible);
this.#notifySelectionChange();
}
// Enter
else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
const selectedItem = this.#filteredItems[this.#selectedIndex];
if (selectedItem && this.onSelect) {
this.onSelect(selectedItem);
}
}
// Escape or Ctrl+C
else if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesKey(keyData, "ctrl+c")) {
else if (kb.matches(keyData, "tui.select.cancel")) {
if (this.onCancel) {
this.onCancel();
}
}
}
#renderItem(
item: SelectItem,
isSelected: boolean,
width: number,
descriptionSingleLine: string | undefined,
primaryColumnWidth: number,
): string {
const prefix = isSelected
? `${this.theme.symbols.cursor} `
: padding(visibleWidth(this.theme.symbols.cursor) + 1);
const prefixWidth = visibleWidth(prefix);
if (descriptionSingleLine && width > 40) {
const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));
const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP);
const truncatedValue = this.#truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth);
const truncatedValueWidth = visibleWidth(truncatedValue);
const spacing = padding(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth));
const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length;
const remainingWidth = width - descriptionStart - 2; // -2 for safety
if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
if (isSelected) {
return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
}
const descText = this.theme.description(spacing + truncatedDesc);
return prefix + truncatedValue + descText;
}
}
const maxWidth = width - prefixWidth - 2;
const truncatedValue = this.#truncatePrimary(item, isSelected, maxWidth, maxWidth);
if (isSelected) {
return this.theme.selectedText(`${prefix}${truncatedValue}`);
}
return prefix + truncatedValue;
}
#getPrimaryColumnWidth(): number {
const { min, max } = this.#getPrimaryColumnBounds();
const widestPrimary = this.#filteredItems.reduce((widest, item) => {
return Math.max(widest, visibleWidth(this.#getDisplayValue(item)) + PRIMARY_COLUMN_GAP);
}, 0);
return clamp(widestPrimary, min, max);
}
#getPrimaryColumnBounds(): { min: number; max: number } {
const rawMin =
this.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
const rawMax =
this.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
return {
min: Math.max(1, Math.min(rawMin, rawMax)),
max: Math.max(1, Math.max(rawMin, rawMax)),
};
}
#truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string {
const displayValue = this.#getDisplayValue(item);
const truncatedValue = this.layout.truncatePrimary
? this.layout.truncatePrimary({
text: displayValue,
maxWidth,
columnWidth,
item,
isSelected,
})
: truncateToWidth(displayValue, maxWidth, Ellipsis.Omit);
return truncateToWidth(truncatedValue, maxWidth, Ellipsis.Omit);
}
#getDisplayValue(item: SelectItem): string {
return sanitizeSingleLine(item.label || item.value);
}
#notifySelectionChange(): void {
const selectedItem = this.#filteredItems[this.#selectedIndex];
if (selectedItem && this.onSelectionChange) {
+6 -5
View File
@@ -1,4 +1,4 @@
import { matchesKey } from "../keys";
import { getKeybindings } from "../keybindings";
import type { Component } from "../tui";
import { Ellipsis, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
@@ -148,13 +148,14 @@ export class SettingsList implements Component {
}
// Main list input handling
if (matchesKey(data, "up")) {
const kb = getKeybindings();
if (kb.matches(data, "tui.select.up")) {
this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1;
} else if (matchesKey(data, "down")) {
} else if (kb.matches(data, "tui.select.down")) {
this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1;
} else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n" || data === " ") {
} else if (kb.matches(data, "tui.select.confirm") || data === " " || data === "\n") {
this.#activateItem();
} else if (matchesKey(data, "escape") || matchesKey(data, "esc") || matchesKey(data, "ctrl+c")) {
} else if (kb.matches(data, "tui.select.cancel")) {
this.#onCancel();
}
}
+216 -134
View File
@@ -1,95 +1,145 @@
import { type KeyId, matchesKey, parseKey } from "./keys";
/**
* Editor actions that can be bound to keys.
* Global keybinding registry.
* Downstream packages can add keybindings via declaration merging.
*/
export type EditorAction =
// Cursor movement
| "cursorUp"
| "cursorDown"
| "cursorLeft"
| "cursorRight"
| "cursorWordLeft"
| "cursorWordRight"
| "cursorLineStart"
| "cursorLineEnd"
| "jumpForward"
| "jumpBackward"
// Deletion
| "deleteCharBackward"
| "deleteCharForward"
| "deleteWordBackward"
| "deleteWordForward"
| "deleteToLineStart"
| "deleteToLineEnd"
// Text input
| "newLine"
| "submit"
| "tab"
// Selection/autocomplete
| "selectUp"
| "selectDown"
| "selectPageUp"
| "selectPageDown"
| "selectConfirm"
| "selectCancel"
// Clipboard
| "copy"
// Kill ring / undo
| "undo"
| "yank"
| "yankPop";
export interface Keybindings {
// Editor navigation and editing
"tui.editor.cursorUp": true;
"tui.editor.cursorDown": true;
"tui.editor.cursorLeft": true;
"tui.editor.cursorRight": true;
"tui.editor.cursorWordLeft": true;
"tui.editor.cursorWordRight": true;
"tui.editor.cursorLineStart": true;
"tui.editor.cursorLineEnd": true;
"tui.editor.jumpForward": true;
"tui.editor.jumpBackward": true;
"tui.editor.pageUp": true;
"tui.editor.pageDown": true;
"tui.editor.deleteCharBackward": true;
"tui.editor.deleteCharForward": true;
"tui.editor.deleteWordBackward": true;
"tui.editor.deleteWordForward": true;
"tui.editor.deleteToLineStart": true;
"tui.editor.deleteToLineEnd": true;
"tui.editor.yank": true;
"tui.editor.yankPop": true;
"tui.editor.undo": true;
// Generic input actions
"tui.input.newLine": true;
"tui.input.submit": true;
"tui.input.tab": true;
"tui.input.copy": true;
// Generic selection actions
"tui.select.up": true;
"tui.select.down": true;
"tui.select.pageUp": true;
"tui.select.pageDown": true;
"tui.select.confirm": true;
"tui.select.cancel": true;
}
export type Keybinding = keyof Keybindings;
// Re-export KeyId from keys.ts
export type { KeyId };
/**
* Editor keybindings configuration.
*/
export type EditorKeybindingsConfig = {
[K in EditorAction]?: KeyId | KeyId[];
};
export interface KeybindingDefinition {
defaultKeys: KeyId | KeyId[];
description?: string;
}
/**
* Default editor keybindings.
*/
export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
// Cursor movement
cursorUp: "up",
cursorDown: "down",
cursorLeft: ["left", "ctrl+b"],
cursorRight: ["right", "ctrl+f"],
cursorWordLeft: ["alt+left", "ctrl+left", "alt+b"],
cursorWordRight: ["alt+right", "ctrl+right", "alt+f"],
cursorLineStart: ["home", "ctrl+a"],
cursorLineEnd: ["end", "ctrl+e"],
jumpForward: "ctrl+]",
jumpBackward: "ctrl+alt+]",
// Deletion
deleteCharBackward: "backspace",
deleteCharForward: ["delete", "ctrl+d"],
deleteWordBackward: ["ctrl+w", "alt+backspace", "ctrl+backspace"],
deleteWordForward: ["alt+delete", "alt+d"],
deleteToLineStart: "ctrl+u",
deleteToLineEnd: "ctrl+k",
// Text input
newLine: "shift+enter",
submit: "enter",
tab: "tab",
// Selection/autocomplete
selectUp: "up",
selectDown: "down",
selectPageUp: "pageUp",
selectPageDown: "pageDown",
selectConfirm: "enter",
selectCancel: ["escape", "ctrl+c"],
// Clipboard
copy: "ctrl+c",
// Kill ring / undo
undo: ["ctrl+-", "ctrl+_"],
yank: "ctrl+y",
yankPop: "alt+y",
};
export type KeybindingDefinitions = Record<string, KeybindingDefinition>;
export type KeybindingsConfig = Record<string, KeyId | KeyId[] | undefined>;
export const TUI_KEYBINDINGS = {
"tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" },
"tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" },
"tui.editor.cursorLeft": {
defaultKeys: ["left", "ctrl+b"],
description: "Move cursor left",
},
"tui.editor.cursorRight": {
defaultKeys: ["right", "ctrl+f"],
description: "Move cursor right",
},
"tui.editor.cursorWordLeft": {
defaultKeys: ["alt+left", "ctrl+left", "alt+b"],
description: "Move cursor word left",
},
"tui.editor.cursorWordRight": {
defaultKeys: ["alt+right", "ctrl+right", "alt+f"],
description: "Move cursor word right",
},
"tui.editor.cursorLineStart": {
defaultKeys: ["home", "ctrl+a"],
description: "Move to line start",
},
"tui.editor.cursorLineEnd": {
defaultKeys: ["end", "ctrl+e"],
description: "Move to line end",
},
"tui.editor.jumpForward": {
defaultKeys: "ctrl+]",
description: "Jump forward to character",
},
"tui.editor.jumpBackward": {
defaultKeys: "ctrl+alt+]",
description: "Jump backward to character",
},
"tui.editor.pageUp": { defaultKeys: "pageUp", description: "Page up" },
"tui.editor.pageDown": { defaultKeys: "pageDown", description: "Page down" },
"tui.editor.deleteCharBackward": {
defaultKeys: "backspace",
description: "Delete character backward",
},
"tui.editor.deleteCharForward": {
defaultKeys: ["delete", "ctrl+d"],
description: "Delete character forward",
},
"tui.editor.deleteWordBackward": {
defaultKeys: ["ctrl+w", "alt+backspace", "ctrl+backspace"],
description: "Delete word backward",
},
"tui.editor.deleteWordForward": {
defaultKeys: ["alt+delete", "alt+d"],
description: "Delete word forward",
},
"tui.editor.deleteToLineStart": {
defaultKeys: "ctrl+u",
description: "Delete to line start",
},
"tui.editor.deleteToLineEnd": {
defaultKeys: "ctrl+k",
description: "Delete to line end",
},
"tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" },
"tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" },
"tui.editor.undo": { defaultKeys: ["ctrl+-", "ctrl+_"], description: "Undo" },
"tui.input.newLine": { defaultKeys: "shift+enter", description: "Insert newline" },
"tui.input.submit": { defaultKeys: "enter", description: "Submit input" },
"tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" },
"tui.input.copy": { defaultKeys: "ctrl+c", description: "Copy selection" },
"tui.select.up": { defaultKeys: "up", description: "Move selection up" },
"tui.select.down": { defaultKeys: "down", description: "Move selection down" },
"tui.select.pageUp": { defaultKeys: "pageUp", description: "Selection page up" },
"tui.select.pageDown": {
defaultKeys: "pageDown",
description: "Selection page down",
},
"tui.select.confirm": { defaultKeys: "enter", description: "Confirm selection" },
"tui.select.cancel": {
defaultKeys: ["escape", "ctrl+c"],
description: "Cancel selection",
},
} as const satisfies KeybindingDefinitions;
export interface KeybindingConflict {
key: KeyId;
keybindings: string[];
}
const SHIFTED_SYMBOL_KEYS = new Set<string>([
"!",
@@ -116,50 +166,67 @@ const SHIFTED_SYMBOL_KEYS = new Set<string>([
const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId;
/**
* Manages keybindings for the editor.
*/
export class EditorKeybindingsManager {
#actionToKeys: Map<EditorAction, KeyId[]>;
function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
if (keys === undefined) return [];
const keyList = Array.isArray(keys) ? keys : [keys];
const seen = new Set<KeyId>();
const result: KeyId[] = [];
for (const key of keyList) {
const normalized = normalizeKeyId(key);
if (!seen.has(normalized)) {
seen.add(normalized);
result.push(normalized);
}
}
return result;
}
constructor(config: EditorKeybindingsConfig = {}) {
this.#actionToKeys = new Map();
this.#buildMaps(config);
export class KeybindingsManager {
#definitions: KeybindingDefinitions;
#userBindings: KeybindingsConfig;
#keysById = new Map<Keybinding, KeyId[]>();
#conflicts: KeybindingConflict[] = [];
constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
this.#definitions = definitions;
this.#userBindings = userBindings;
this.#rebuild();
}
#buildMaps(config: EditorKeybindingsConfig): void {
this.#actionToKeys.clear();
#rebuild(): void {
this.#keysById.clear();
this.#conflicts = [];
// Start with defaults
for (const [action, keys] of Object.entries(DEFAULT_EDITOR_KEYBINDINGS)) {
const keyArray = Array.isArray(keys) ? keys : [keys];
this.#actionToKeys.set(
action as EditorAction,
keyArray.map(key => normalizeKeyId(key as KeyId)),
);
const userClaims = new Map<KeyId, Set<Keybinding>>();
for (const [keybinding, keys] of Object.entries(this.#userBindings)) {
if (!(keybinding in this.#definitions)) continue;
for (const key of normalizeKeys(keys)) {
const claimants = userClaims.get(key) ?? new Set<Keybinding>();
claimants.add(keybinding as Keybinding);
userClaims.set(key, claimants);
}
}
// Override with user config
for (const [action, keys] of Object.entries(config)) {
if (keys === undefined) continue;
const keyArray = Array.isArray(keys) ? keys : [keys];
this.#actionToKeys.set(
action as EditorAction,
keyArray.map(key => normalizeKeyId(key as KeyId)),
);
for (const [key, keybindings] of userClaims) {
if (keybindings.size > 1) {
this.#conflicts.push({ key, keybindings: [...keybindings] });
}
}
for (const [id, definition] of Object.entries(this.#definitions)) {
const userKeys = this.#userBindings[id];
const keys = userKeys === undefined ? normalizeKeys(definition.defaultKeys) : normalizeKeys(userKeys);
this.#keysById.set(id as Keybinding, keys);
}
}
/**
* Check if input matches a specific action.
*/
matches(data: string, action: EditorAction): boolean {
const keys = this.#actionToKeys.get(action);
if (!keys) return false;
matches(data: string, keybinding: Keybinding): boolean {
const keys = this.#keysById.get(keybinding) ?? [];
for (const key of keys) {
if (matchesKey(data, key)) return true;
}
// Handle shifted symbol keys (e.g., shift+- produces _ on US layout)
const parsed = parseKey(data);
if (!parsed || !parsed.startsWith("shift+")) return false;
const keyName = parsed.slice("shift+".length);
@@ -167,31 +234,46 @@ export class EditorKeybindingsManager {
return keys.includes(keyName as KeyId);
}
/**
* Get keys bound to an action.
*/
getKeys(action: EditorAction): KeyId[] {
return this.#actionToKeys.get(action) ?? [];
getKeys(keybinding: Keybinding): KeyId[] {
return [...(this.#keysById.get(keybinding) ?? [])];
}
/**
* Update configuration.
*/
setConfig(config: EditorKeybindingsConfig): void {
this.#buildMaps(config);
getDefinition(keybinding: Keybinding): KeybindingDefinition {
return this.#definitions[keybinding];
}
getConflicts(): KeybindingConflict[] {
return this.#conflicts.map(conflict => ({ ...conflict, keybindings: [...conflict.keybindings] }));
}
setUserBindings(userBindings: KeybindingsConfig): void {
this.#userBindings = userBindings;
this.#rebuild();
}
getUserBindings(): KeybindingsConfig {
return { ...this.#userBindings };
}
getResolvedBindings(): KeybindingsConfig {
const resolved: KeybindingsConfig = {};
for (const id of Object.keys(this.#definitions)) {
const keys = this.#keysById.get(id as Keybinding) ?? [];
resolved[id] = keys.length === 1 ? keys[0]! : [...keys];
}
return resolved;
}
}
// Global instance
let globalEditorKeybindings: EditorKeybindingsManager | null = null;
let globalKeybindings: KeybindingsManager | null = null;
export function getEditorKeybindings(): EditorKeybindingsManager {
if (!globalEditorKeybindings) {
globalEditorKeybindings = new EditorKeybindingsManager();
export function setKeybindings(keybindings: KeybindingsManager): void {
globalKeybindings = keybindings;
}
export function getKeybindings(): KeybindingsManager {
if (!globalKeybindings) {
globalKeybindings = new KeybindingsManager(TUI_KEYBINDINGS);
}
return globalEditorKeybindings;
}
export function setEditorKeybindings(manager: EditorKeybindingsManager): void {
globalEditorKeybindings = manager;
return globalKeybindings;
}
+28
View File
@@ -25,6 +25,34 @@ import {
parseKittySequence as parseKittySequenceNative,
} from "@oh-my-pi/pi-natives";
// =============================================================================
// Platform Detection
// =============================================================================
function isWindowsTerminalSession(): boolean {
return (
Boolean(process.env.WT_SESSION) && !process.env.SSH_CONNECTION && !process.env.SSH_CLIENT && !process.env.SSH_TTY
);
}
/**
* Raw 0x08 (BS) is ambiguous in legacy terminals.
*
* - Windows Terminal uses it for Ctrl+Backspace.
* - Some legacy terminals and tmux setups send it for plain Backspace.
*
* Prefer explicit Kitty / CSI-u / modifyOtherKeys sequences whenever they are
* available. Fall back to a Windows Terminal heuristic only for raw BS bytes.
*/
function matchesRawBackspace(data: string, expectedModifier: number): boolean {
if (data === "\x7f") return expectedModifier === 0;
if (data !== "\x08") return false;
// On Windows Terminal, 0x08 = Ctrl+Backspace. On others, it's plain Backspace.
return isWindowsTerminalSession() ? expectedModifier === 4 : expectedModifier === 0;
}
export { isWindowsTerminalSession, matchesRawBackspace };
// =============================================================================
// Global Kitty Protocol State
// =============================================================================
+23 -4
View File
@@ -108,6 +108,10 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
return undefined;
}
function isTermuxSession(): boolean {
return Boolean(process.env.TERMUX_VERSION);
}
/**
* Options for overlay positioning and sizing.
* Values can be absolute numbers or percentage strings (e.g., "50%").
@@ -204,6 +208,7 @@ export class TUI extends Container {
terminal: Terminal;
#previousLines: string[] = [];
#previousWidth = 0;
#previousHeight = 0;
#focusedComponent: Component | null = null;
#inputListeners = new Set<InputListener>();
@@ -559,6 +564,7 @@ export class TUI extends Container {
if (force) {
this.#previousLines = [];
this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
this.#cursorRow = 0;
this.#hardwareCursorRow = 0;
this.#viewportTopRow = 0;
@@ -995,12 +1001,13 @@ export class TUI extends Container {
// Width changed - need full re-render (line wrapping changes)
const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
// Helper to clear scrollback and viewport and render all new lines
const fullRender = (clear: boolean): void => {
this.#fullRedrawCount += 1;
let buffer = "\x1b[?2026h"; // Begin synchronized output
if (clear) buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home
if (clear) buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
const reset = SEGMENT_RESET;
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer += "\r\n";
@@ -1021,6 +1028,7 @@ export class TUI extends Container {
this.#positionHardwareCursor(cursorPos, newLines.length);
this.#previousLines = newLines;
this.#previousWidth = width;
this.#previousHeight = height;
};
const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
@@ -1032,15 +1040,24 @@ export class TUI extends Container {
};
// First render - just output everything without clearing (assumes clean screen)
if (this.#previousLines.length === 0 && !widthChanged) {
if (this.#previousLines.length === 0 && !widthChanged && !heightChanged) {
logRedraw("first render");
fullRender(false);
return;
}
// Width changed - full re-render (line wrapping changes)
// Width changes always need a full re-render because wrapping changes.
if (widthChanged) {
logRedraw(`width changed (${this.#previousWidth} -> ${width})`);
logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
fullRender(true);
return;
}
// Height changes normally need a full re-render to keep the visible viewport aligned,
// but Termux changes height when the software keyboard shows or hides.
// In that environment, a full redraw causes the entire history to replay on every toggle.
if (heightChanged && !isTermuxSession()) {
logRedraw(`terminal height changed (${this.#previousHeight} -> ${height})`);
fullRender(true);
return;
}
@@ -1122,6 +1139,7 @@ export class TUI extends Container {
this.#positionHardwareCursor(cursorPos, newLines.length);
this.#previousLines = newLines;
this.#previousWidth = width;
this.#previousHeight = height;
this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
return;
}
@@ -1270,6 +1288,7 @@ export class TUI extends Container {
this.#previousLines = newLines;
this.#previousWidth = width;
this.#previousHeight = height;
}
/**
+11 -1
View File
@@ -35,15 +35,25 @@ export function getSegmenter(): Intl.Segmenter {
/**
* Calculate the visible width of a string in terminal columns.
*/
function _isPrintableAscii(str: string): boolean {
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code < 0x20 || code > 0x7e) {
return false;
}
}
return true;
}
export function visibleWidthRaw(str: string): number {
if (!str) {
return 0;
}
// Fast path: pure ASCII printable
let isPureAscii = true;
let tabLength = 0;
const tabWidth = getDefaultTabWidth();
let isPureAscii = true;
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code === 9) {
+5 -5
View File
@@ -3,12 +3,12 @@ import { stripVTControlCharacters } from "node:util";
import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete";
import { Editor } from "@oh-my-pi/pi-tui/components/editor";
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
import { EditorKeybindingsManager, setEditorKeybindings } from "../src/keybindings";
import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings";
import { defaultEditorTheme } from "./test-themes";
describe("Editor component", () => {
afterEach(() => {
setEditorKeybindings(new EditorKeybindingsManager());
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
});
describe("Prompt history navigation", () => {
@@ -1340,9 +1340,9 @@ describe("Editor component", () => {
});
it("uses the configured undo binding", () => {
setEditorKeybindings(
new EditorKeybindingsManager({
undo: "f8",
setKeybindings(
new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.editor.undo": "f8",
}),
);
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "bun:test";
import { KeybindingsManager, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui/keybindings";
describe("KeybindingsManager", () => {
it("does not evict selector confirm when input submit is rebound", () => {
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.input.submit": ["enter", "ctrl+enter"],
});
expect(keybindings.getKeys("tui.input.submit")).toEqual(["enter", "ctrl+enter"]);
expect(keybindings.getKeys("tui.select.confirm")).toEqual(["enter"]);
});
it("does not evict cursor bindings when another action reuses the same key", () => {
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.select.up": ["up", "ctrl+p"],
});
expect(keybindings.getKeys("tui.select.up")).toEqual(["up", "ctrl+p"]);
expect(keybindings.getKeys("tui.editor.cursorUp")).toEqual(["up"]);
});
it("still reports direct user binding conflicts without evicting defaults", () => {
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.input.submit": "ctrl+x",
"tui.select.confirm": "ctrl+x",
});
expect(keybindings.getConflicts()).toEqual([
{
key: "ctrl+x",
keybindings: ["tui.input.submit", "tui.select.confirm"],
},
]);
expect(keybindings.getKeys("tui.editor.cursorLeft")).toEqual(["left", "ctrl+b"]);
});
});
+78
View File
@@ -471,6 +471,22 @@ describe("Markdown component", () => {
const tableRow = plainLines.find(line => line.includes("|"));
expect(tableRow?.startsWith(" "), "Table should have left padding").toBeTruthy();
});
it("should not add a trailing blank line when table is the last rendered block", () => {
const markdown = new Markdown(
`| Name |
| --- |
| Alice |`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
expect(plainLines.at(-1)).not.toBe("");
});
});
describe("Combined features", () => {
@@ -624,6 +640,44 @@ again, hello world`,
`Expected 1 empty line after code block, but found ${emptyLineCount}. Lines after backticks: ${JSON.stringify(afterBackticks.slice(0, 5))}`,
).toBe(1);
});
it("should normalize paragraph and code block spacing to one blank line", () => {
const cases = [
`hello this is text
\`\`\`
code block
\`\`\`
more text`,
`hello this is text
\`\`\`
code block
\`\`\`
more text`,
];
const expectedLines = ["hello this is text", "", "```", " code block", "```", "", "more text"];
for (const text of cases) {
const markdown = new Markdown(text, 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
expect(plainLines).toEqual(expectedLines);
}
});
it("should not add a trailing blank line when code block is the last rendered block", () => {
const cases = ["```js\nconst hello = 'world';\n```", "hello world\n\n```js\nconst hello = 'world';\n```"];
for (const text of cases) {
const markdown = new Markdown(text, 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
expect(plainLines.at(-1)).not.toBe("");
}
});
});
describe("Spacing after dividers", () => {
@@ -653,6 +707,14 @@ again, hello world`,
`Expected 1 empty line after divider, but found ${emptyLineCount}. Lines after divider: ${JSON.stringify(afterDivider.slice(0, 5))}`,
).toBe(1);
});
it("should not add a trailing blank line when divider is the last rendered block", () => {
const markdown = new Markdown("---", 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
expect(plainLines.at(-1)).not.toBe("");
});
});
describe("Spacing after headings", () => {
@@ -680,6 +742,14 @@ This is a paragraph`,
`Expected 1 empty line after heading, but found ${emptyLineCount}. Lines after heading: ${JSON.stringify(afterHeading.slice(0, 5))}`,
).toBe(1);
});
it("should not add a trailing blank line when heading is the last rendered block", () => {
const markdown = new Markdown("# Hello", 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
expect(plainLines.at(-1)).not.toBe("");
});
});
describe("Spacing after blockquotes", () => {
@@ -709,6 +779,14 @@ again, hello world`,
`Expected 1 empty line after blockquote, but found ${emptyLineCount}. Lines after quote: ${JSON.stringify(afterQuote.slice(0, 5))}`,
).toBe(1);
});
it("should not add a trailing blank line when blockquote is the last rendered block", () => {
const markdown = new Markdown("> This is a quote", 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
expect(plainLines.at(-1)).not.toBe("");
});
});
describe("Blockquotes with multiline content", () => {
+171
View File
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
import { SelectList } from "../src/components/select-list";
import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings";
const testTheme = {
selectedPrefix: (text: string) => text,
selectedText: (text: string) => text,
description: (text: string) => text,
scrollInfo: (text: string) => text,
noMatch: (text: string) => text,
symbols: {
cursor: "→",
inputCursor: "|",
hrChar: "─",
quoteBorder: "│",
boxRound: { topLeft: "╭", topRight: "╮", bottomLeft: "╰", bottomRight: "╯", horizontal: "─", vertical: "│" },
boxSharp: {
topLeft: "┌",
topRight: "┐",
bottomLeft: "└",
bottomRight: "┘",
horizontal: "─",
vertical: "│",
teeDown: "┬",
teeUp: "┴",
teeLeft: "┤",
teeRight: "├",
cross: "┼",
},
table: {
topLeft: "┌",
topRight: "┐",
bottomLeft: "└",
bottomRight: "┘",
horizontal: "─",
vertical: "│",
teeDown: "┬",
teeUp: "┴",
teeLeft: "┤",
teeRight: "├",
cross: "┼",
},
spinnerFrames: ["|"],
},
};
const visibleIndexOf = (line: string, text: string): number => {
const index = line.indexOf(text);
expect(index).not.toBe(-1);
return visibleWidth(line.slice(0, index));
};
describe("SelectList", () => {
beforeEach(() => {
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
});
afterEach(() => {
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
});
it("normalizes multiline descriptions to single line", () => {
const items = [
{
value: "test",
label: "test",
description: "Line one\nLine two\nLine three",
},
];
const list = new SelectList(items, 5, testTheme);
const rendered = list.render(80);
expect(rendered.length).toBeGreaterThanOrEqual(1);
expect(rendered[0]).not.toContain("\n");
expect(rendered[0]).toContain("Line one Line two Line three");
});
it("keeps descriptions aligned when the primary text is truncated", () => {
const items = [
{ value: "short", label: "short", description: "short description" },
{
value: "very-long-command-name-that-needs-truncation",
label: "very-long-command-name-that-needs-truncation",
description: "long description",
},
];
const list = new SelectList(items, 5, testTheme);
const rendered = list.render(80);
expect(visibleIndexOf(rendered[0], "short description")).toBe(visibleIndexOf(rendered[1], "long description"));
});
it("uses the configured minimum primary column width", () => {
const items = [
{ value: "a", label: "a", description: "first" },
{ value: "bb", label: "bb", description: "second" },
];
const list = new SelectList(items, 5, testTheme, {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 20,
});
const rendered = list.render(80);
expect(rendered[0].indexOf("first")).toBe(14);
expect(rendered[1].indexOf("second")).toBe(14);
});
it("uses the configured maximum primary column width", () => {
const items = [
{
value: "very-long-command-name-that-needs-truncation",
label: "very-long-command-name-that-needs-truncation",
description: "first",
},
{ value: "short", label: "short", description: "second" },
];
const list = new SelectList(items, 5, testTheme, {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 20,
});
const rendered = list.render(80);
expect(visibleIndexOf(rendered[0], "first")).toBe(22);
expect(visibleIndexOf(rendered[1], "second")).toBe(22);
});
it("allows overriding primary truncation while preserving description alignment", () => {
const items = [
{
value: "very-long-command-name-that-needs-truncation",
label: "very-long-command-name-that-needs-truncation",
description: "first",
},
{ value: "short", label: "short", description: "second" },
];
const list = new SelectList(items, 5, testTheme, {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 12,
truncatePrimary: ({ text, maxWidth }) => {
if (text.length <= maxWidth) {
return text;
}
return `${text.slice(0, Math.max(0, maxWidth - 1))}…`;
},
});
const rendered = list.render(80);
expect(rendered[0]).toContain("…");
expect(visibleIndexOf(rendered[0], "first")).toBe(visibleIndexOf(rendered[1], "second"));
});
it("confirms the selected item when Enter arrives as LF", () => {
const items = [{ value: "run", label: "run" }];
const list = new SelectList(items, 5, testTheme);
let selectedValue: string | undefined;
list.onSelect = item => {
selectedValue = item.value;
};
list.handleInput("\n");
expect(selectedValue).toBe("run");
});
});
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { SettingsList, type SettingsListTheme } from "../src/components/settings-list";
import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings";
const testTheme: SettingsListTheme = {
label: (text: string) => text,
value: (text: string) => text,
description: (text: string) => text,
cursor: "→ ",
hint: (text: string) => text,
};
describe("SettingsList", () => {
beforeEach(() => {
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
});
afterEach(() => {
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
});
it("cycles the selected value when Enter arrives as LF", () => {
const changes: Array<[string, string]> = [];
const list = new SettingsList(
[
{
id: "mode",
label: "Mode",
currentValue: "off",
values: ["off", "on"],
},
],
5,
testTheme,
(id, value) => {
changes.push([id, value]);
},
() => {
throw new Error("cancel should not be called");
},
);
list.handleInput("\n");
expect(changes).toEqual([["mode", "on"]]);
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from "bun:test";
import { Ellipsis, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui/utils";
describe("truncateToWidth", () => {
it("keeps output within width for very large unicode input", () => {
const text = "🙂界".repeat(100_000);
const truncated = truncateToWidth(text, 40, Ellipsis.Unicode);
expect(visibleWidth(truncated)).toBeLessThanOrEqual(40);
});
it("preserves ANSI styling for kept text", () => {
const text = `\x1b[31m${"hello ".repeat(1000)}\x1b[0m`;
const truncated = truncateToWidth(text, 20, Ellipsis.Unicode);
expect(visibleWidth(truncated)).toBeLessThanOrEqual(20);
expect(truncated.includes("\x1b[31m")).toBe(true);
});
it("handles malformed ANSI escape prefixes without hanging", () => {
const text = `abc\x1bnot-ansi ${"🙂".repeat(1000)}`;
// Should complete without hanging — the exact width depends on how the
// native implementation classifies the malformed escape prefix.
const truncated = truncateToWidth(text, 20, Ellipsis.Unicode);
expect(typeof truncated).toBe("string");
});
it("returns the original text when it already fits", () => {
expect(truncateToWidth("a", 2, Ellipsis.Unicode)).toBe("a");
expect(truncateToWidth("界", 2, Ellipsis.Unicode)).toBe("界");
});
it("pads truncated output to requested width", () => {
const truncated = truncateToWidth("🙂界🙂界🙂界", 8, Ellipsis.Unicode, true);
expect(visibleWidth(truncated)).toBe(8);
});
it("adds a trailing reset when truncating without an ellipsis", () => {
const truncated = truncateToWidth(`\x1b[31m${"hello".repeat(100)}`, 10, Ellipsis.Omit);
expect(visibleWidth(truncated)).toBeLessThanOrEqual(10);
expect(truncated.endsWith("\x1b[0m")).toBe(true);
});
});
describe("visibleWidth", () => {
it("counts tabs inline and skips ANSI inline", () => {
expect(visibleWidth("\t\x1b[31m界\x1b[0m")).toBe(5);
});
});