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:
@@ -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
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user