fix(coding-agent): backported pi-mono changes (5133697..15e0957b0)

packages/ai:
- fix: improve GitHub Copilot OAuth polling and Codex stream recovery
- fix: allow google-vertex authentication via GOOGLE_CLOUD_API_KEY
- fix: send Gemini/Claude provider-specific thinking headers and thought-signature fallbacks correctly
- test: added coverage for Gemini CLI alignment, Codex streaming, and stream edge cases

packages/coding-agent:
- feat: add treeFilterMode setting for the session tree selector default
- fix: prefer later-loaded explicit extensions when commands conflict
- fix: truncate serialized tool results during compaction summarization to prevent overflow
- fix: normalize CRLF in write tool previews
- fix: use shell-based external editor launch on Windows
- test: added compaction serialization and extension runner precedence coverage

packages/tui:
- fix: chain slash-command argument autocomplete after tab-completing the command name
- fix: normalize pasted tabs in Input using configured indentation
- fix: render blockquote lists, tables, and fenced code blocks correctly
- fix: ignore unsupported Kitty CSI-u modifiers and enable modifyOtherKeys fallback cleanup
- test: added editor, input, keys, and markdown regression coverage
This commit is contained in:
can1357
2026-03-10 07:27:23 +01:00
parent bf1af712fe
commit acf4b45062
32 changed files with 709 additions and 139 deletions
-1
View File
@@ -1363,7 +1363,6 @@ mod tests {
assert_eq!(parse_key_inner(b"\x1b[99;9u", true).as_deref(), None);
}
#[test]
fn parse_key_ignores_kitty_release_events() {
assert_eq!(parse_key_inner(b"\x1b[127u", true).as_deref(), Some("backspace"));
+3 -3
View File
@@ -5,15 +5,15 @@ Use it for any merge: single file, feature branch, or full release sync.
## Last Sync Point
**Commit:** `5133697`
**Date:** 2026-02-16
**Commit:** `15e0957b045d9e0d49253b2285cb585cf3a75c55`
**Date:** 2026-03-09
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 82d7da878..HEAD --stdout > changes.patch
git format-patch 15e0957b045d9e0d49253b2285cb585cf3a75c55..HEAD --stdout > changes.patch
```
## 0) Define the scope
+2 -2
View File
@@ -1595,7 +1595,8 @@ export class AuthStorage {
if (leftPlanPriority !== rightPlanPriority) return leftPlanPriority - rightPlanPriority;
}
if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1;
if (left.secondaryDrainRate !== right.secondaryDrainRate) return left.secondaryDrainRate - right.secondaryDrainRate;
if (left.secondaryDrainRate !== right.secondaryDrainRate)
return left.secondaryDrainRate - right.secondaryDrainRate;
if (left.secondaryUsed !== right.secondaryUsed) return left.secondaryUsed - right.secondaryUsed;
if (left.primaryDrainRate !== right.primaryDrainRate) return left.primaryDrainRate - right.primaryDrainRate;
if (left.primaryUsed !== right.primaryUsed) return left.primaryUsed - right.primaryUsed;
@@ -1636,7 +1637,6 @@ export class AuthStorage {
// with the most headroom proactively and fall back intelligently when rate-limited.
const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
const sessionPreferredIsAvailable =
sessionPreferredIndex !== undefined && !this.#isCredentialBlocked(providerKey, sessionPreferredIndex);
const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
const candidates = shouldRank
@@ -240,9 +240,8 @@ function isClaudeModel(modelId: string): boolean {
return modelId.toLowerCase().includes("claude");
}
function isClaudeThinkingModel(modelId: string): boolean {
const normalized = modelId.toLowerCase();
return normalized.includes("claude") && normalized.includes("thinking");
function needsClaudeThinkingBetaHeader(model: Model<"google-gemini-cli">): boolean {
return model.provider === "google-antigravity" && model.id.startsWith("claude-") && model.reasoning;
}
function shouldInjectAntigravitySystemInstruction(modelId: string): boolean {
@@ -496,9 +495,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
"Content-Type": "application/json",
Accept: "text/event-stream",
...headers,
...(!isAntigravity && isClaudeThinkingModel(model.id)
? { "anthropic-beta": CLAUDE_THINKING_BETA_HEADER }
: {}),
...(needsClaudeThinkingBetaHeader(model) ? { "anthropic-beta": CLAUDE_THINKING_BETA_HEADER } : {}),
...(options?.headers ?? {}),
};
const requestBodyJson = JSON.stringify(requestBody);
+6 -18
View File
@@ -45,6 +45,8 @@ export function retainThoughtSignature(existing: string | undefined, incoming: s
// Thought signatures must be base64 for Google APIs (TYPE_BYTES).
const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/;
const SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
function isValidThoughtSignature(signature: string | undefined): boolean {
if (!signature) return false;
if (signature.length % 4 !== 0) return false;
@@ -150,22 +152,8 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, contex
}
} else if (block.type === "toolCall") {
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
if (isGemini3Model(model.id) && !thoughtSignature) {
const params = Object.entries(block.arguments ?? {})
.map(([key, value]) => {
const valueStr = typeof value === "string" ? value : JSON.stringify(value, null, 2);
return `<parameter name="${key}">${valueStr}</parameter>`;
})
.join("\n");
parts.push({
text: `<call_record tool="${block.name}">
<critical>Historical context only. You cannot invoke tools this way—use proper function calling.</critical>
${params}
</call_record>`.toWellFormed(),
});
continue;
}
const effectiveSignature =
thoughtSignature || (isGemini3Model(model.id) ? SKIP_THOUGHT_SIGNATURE : undefined);
const part: Part = {
functionCall: {
@@ -177,8 +165,8 @@ ${params}
if (model.provider === "google-vertex" && part?.functionCall?.id) {
delete part.functionCall.id; // Vertex AI does not support 'id' in functionCall
}
if (thoughtSignature) {
part.thoughtSignature = thoughtSignature;
if (effectiveSignature) {
part.thoughtSignature = effectiveSignature;
}
parts.push(part);
}
+27 -12
View File
@@ -93,9 +93,10 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
let rawRequestDump: RawHttpRequestDump | undefined;
try {
const project = resolveProject(options);
const location = resolveLocation(options);
const client = createClient(model, project, location);
const apiKey = resolveApiKey(options);
const project = apiKey ? undefined : resolveProject(options);
const location = apiKey ? undefined : resolveLocation(options);
const client = apiKey ? createClientWithApiKey(model, apiKey) : createClient(model, project!, location!);
const params = buildParams(model, context, options);
options?.onPayload?.(params);
rawRequestDump = {
@@ -103,7 +104,9 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
api: output.api,
model: model.id,
method: "POST",
url: `https://${location}-aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`,
url: apiKey
? `https://aiplatform.googleapis.com/${API_VERSION}/publishers/google/models/${model.id}:streamGenerateContent`
: `https://${location}-aiplatform.googleapis.com/${API_VERSION}/projects/${project}/locations/${location}/publishers/google/models/${model.id}:streamGenerateContent`,
body: params,
};
const googleStream = await client.models.generateContentStream(params);
@@ -308,24 +311,36 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
return stream;
};
function createClient(model: Model<"google-vertex">, project: string, location: string): GoogleGenAI {
const httpOptions: { headers?: Record<string, string> } = {};
if (model.headers) {
httpOptions.headers = { ...model.headers };
function buildHttpOptions(model: Model<"google-vertex">): { headers?: Record<string, string> } | undefined {
if (!model.headers) {
return undefined;
}
return { headers: { ...model.headers } };
}
const hasHttpOptions = Object.values(httpOptions).some(Boolean);
function createClient(model: Model<"google-vertex">, project: string, location: string): GoogleGenAI {
return new GoogleGenAI({
vertexai: true,
project,
location,
apiVersion: API_VERSION,
httpOptions: hasHttpOptions ? httpOptions : undefined,
httpOptions: buildHttpOptions(model),
});
}
function createClientWithApiKey(model: Model<"google-vertex">, apiKey: string): GoogleGenAI {
return new GoogleGenAI({
vertexai: true,
apiKey,
apiVersion: API_VERSION,
httpOptions: buildHttpOptions(model),
});
}
function resolveApiKey(options?: GoogleVertexOptions): string | undefined {
return options?.apiKey || $env.GOOGLE_CLOUD_API_KEY;
}
function resolveProject(options?: GoogleVertexOptions): string {
const project = options?.project || $env.GOOGLE_CLOUD_PROJECT || $env.GCLOUD_PROJECT;
if (!project) {
@@ -266,7 +266,9 @@ function isCodexWebSocketRetryableStreamError(error: unknown): boolean {
message.includes("websocket closed before response completion") ||
message.includes("websocket connection is unavailable") ||
message.includes("idle timeout waiting for websocket") ||
message.includes("timeout waiting for first websocket event")
message.includes("timeout waiting for first websocket event") ||
message.includes("syntaxerror") ||
message.includes("json")
);
}
@@ -521,14 +523,24 @@ async function openInitialCodexEventStream(
options: OpenAICodexResponsesOptions | undefined,
requestSetup: CodexRequestSetup,
requestContext: CodexRequestContext,
): Promise<{ eventStream: AsyncGenerator<Record<string, unknown>>; requestBodyForState: RequestBody; transport: CodexTransport }> {
): Promise<{
eventStream: AsyncGenerator<Record<string, unknown>>;
requestBodyForState: RequestBody;
transport: CodexTransport;
}> {
const { transformedBody, websocketState } = requestContext;
if (websocketState && shouldUseCodexWebSocket(model, websocketState, options?.preferWebsockets)) {
const websocketRetryBudget = getCodexWebSocketRetryBudget();
let websocketRetries = 0;
while (true) {
try {
return await openCodexWebSocketTransport(requestContext, requestSetup, options, websocketState, websocketRetries);
return await openCodexWebSocketTransport(
requestContext,
requestSetup,
options,
websocketState,
websocketRetries,
);
} catch (error) {
const websocketError = error instanceof Error ? error : new Error(String(error));
const isFatal = isCodexWebSocketFatalError(websocketError);
@@ -558,7 +570,11 @@ async function openCodexWebSocketTransport(
options: OpenAICodexResponsesOptions | undefined,
websocketState: CodexWebSocketSessionState,
retry: number,
): Promise<{ eventStream: AsyncGenerator<Record<string, unknown>>; requestBodyForState: RequestBody; transport: CodexTransport }> {
): Promise<{
eventStream: AsyncGenerator<Record<string, unknown>>;
requestBodyForState: RequestBody;
transport: CodexTransport;
}> {
const websocketRequest = buildCodexWebSocketRequest(requestContext.transformedBody, websocketState);
const websocketHeaders = createCodexHeaders(
requestContext.requestHeaders,
@@ -596,7 +612,11 @@ async function openCodexSseTransport(
options: OpenAICodexResponsesOptions | undefined,
state: CodexWebSocketSessionState | undefined,
body = requestContext.transformedBody,
): Promise<{ eventStream: AsyncGenerator<Record<string, unknown>>; requestBodyForState: RequestBody; transport: CodexTransport }> {
): Promise<{
eventStream: AsyncGenerator<Record<string, unknown>>;
requestBodyForState: RequestBody;
transport: CodexTransport;
}> {
const eventStream = requestSetup.wrapCodexSseStream(
await openCodexSseEventStream(
requestContext.url,
@@ -665,7 +685,10 @@ function createCodexStreamRuntime(initial: {
};
}
async function processCodexResponseStream(context: CodexStreamProcessingContext, runtime: CodexStreamRuntime): Promise<CodexStreamCompletion> {
async function processCodexResponseStream(
context: CodexStreamProcessingContext,
runtime: CodexStreamRuntime,
): Promise<CodexStreamCompletion> {
const { output, stream } = context;
stream.push({ type: "start", partial: output });
@@ -742,12 +765,28 @@ function handleCodexStreamEvent(args: {
}
if (eventType === "response.output_text.delta") {
handleMessageTextDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex, "output_text");
handleMessageTextDelta(
runtime.currentItem,
runtime.currentBlock,
rawEvent,
stream,
output,
blockIndex,
"output_text",
);
return firstTokenTime;
}
if (eventType === "response.refusal.delta") {
handleMessageTextDelta(runtime.currentItem, runtime.currentBlock, rawEvent, stream, output, blockIndex, "refusal");
handleMessageTextDelta(
runtime.currentItem,
runtime.currentBlock,
rawEvent,
stream,
output,
blockIndex,
"refusal",
);
return firstTokenTime;
}
@@ -933,7 +972,9 @@ function handleOutputItemDone(
}
if (item.type === "message" && runtime.currentBlock?.type === "text") {
runtime.currentBlock.text = item.content.map(content => (content.type === "output_text" ? content.text : content.refusal)).join("");
runtime.currentBlock.text = item.content
.map(content => (content.type === "output_text" ? content.text : content.refusal))
.join("");
runtime.currentBlock.textSignature = item.id;
stream.push({
type: "text_end",
@@ -1066,7 +1107,10 @@ async function tryReplayWebsocketFailureOverSse(
if (!activateFallback) {
runtime.websocketStreamRetries += 1;
await abortableSleep(getCodexWebSocketRetryDelayMs(runtime.websocketStreamRetries), context.requestSetup.requestSignal);
await abortableSleep(
getCodexWebSocketRetryDelayMs(runtime.websocketStreamRetries),
context.requestSetup.requestSignal,
);
await reopenCodexWebSocketRuntimeStream(context, runtime, state);
return true;
}
@@ -1223,7 +1267,8 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
stream.end();
} catch (error) {
const failureContext =
processingContext ?? {
processingContext ??
({
model,
output,
stream,
@@ -1246,7 +1291,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
},
},
startTime,
} satisfies CodexStreamProcessingContext;
} satisfies CodexStreamProcessingContext);
const failure = await handleCodexStreamFailure(failureContext, error);
stream.push({ type: "error", reason: failure.stopReason as "error" | "aborted", error: failure });
stream.end();
@@ -1511,7 +1556,10 @@ class CodexWebSocketConnection {
}
close(reason = "done"): void {
if (this.#socket && (this.#socket.readyState === WebSocket.OPEN || this.#socket.readyState === WebSocket.CONNECTING)) {
if (
this.#socket &&
(this.#socket.readyState === WebSocket.OPEN || this.#socket.readyState === WebSocket.CONNECTING)
) {
this.#socket.close(1000, reason);
}
this.#socket = null;
@@ -1618,7 +1666,10 @@ class CodexWebSocketConnection {
}
}
async *streamRequest(request: Record<string, unknown>, signal?: AbortSignal): AsyncGenerator<Record<string, unknown>> {
async *streamRequest(
request: Record<string, unknown>,
signal?: AbortSignal,
): AsyncGenerator<Record<string, unknown>> {
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
throw createCodexWebSocketTransportError("websocket connection is unavailable");
}
@@ -1918,7 +1969,12 @@ function redactHeaders(headers: Headers): Record<string, string> {
redacted[key] = "Bearer [redacted]";
continue;
}
if (lower.includes("account") || lower.includes("session") || lower.includes("conversation") || lower === "cookie") {
if (
lower.includes("account") ||
lower.includes("session") ||
lower.includes("conversation") ||
lower === "cookie"
) {
redacted[key] = "[redacted]";
continue;
}
+4 -2
View File
@@ -93,9 +93,11 @@ const serviceProviderMap: Record<string, KeyResolver> = {
? $pickenv("ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY")
: $pickenv("ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"),
"gitlab-duo": "GITLAB_TOKEN",
// Vertex AI uses Application Default Credentials, not API keys.
// Auth is configured via `gcloud auth application-default login`.
// Vertex AI supports either GOOGLE_CLOUD_API_KEY or Application Default Credentials.
"google-vertex": () => {
if ($env.GOOGLE_CLOUD_API_KEY) {
return $env.GOOGLE_CLOUD_API_KEY;
}
const hasCredentials = hasVertexAdcCredentials();
const hasProject = !!($env.GOOGLE_CLOUD_PROJECT || $env.GCLOUD_PROJECT);
const hasLocation = !!$env.GOOGLE_CLOUD_LOCATION;
+34 -24
View File
@@ -15,6 +15,8 @@ const COPILOT_HEADERS = {
"Copilot-Integration-Id": "vscode-chat",
} as const;
const INITIAL_POLL_INTERVAL_MULTIPLIER = 1.2;
const SLOW_DOWN_POLL_INTERVAL_MULTIPLIER = 1.4;
type DeviceCodeResponse = {
device_code: string;
user_code: string;
@@ -98,10 +100,10 @@ async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "GitHubCopilotChat/0.35.0",
},
body: JSON.stringify({
body: new URLSearchParams({
client_id: CLIENT_ID,
scope: "read:user",
}),
@@ -136,6 +138,14 @@ async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
};
}
async function sleepForGitHubAccessTokenPoll(ms: number, signal?: AbortSignal): Promise<void> {
try {
await abortableSleep(ms, signal);
} catch {
throw new Error("Login cancelled");
}
}
async function pollForGitHubAccessToken(
domain: string,
deviceCode: string,
@@ -146,20 +156,26 @@ async function pollForGitHubAccessToken(
const urls = getUrls(domain);
const deadline = Date.now() + expiresIn * 1000;
let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000));
let intervalMultiplier = INITIAL_POLL_INTERVAL_MULTIPLIER;
let slowDownResponses = 0;
while (Date.now() < deadline) {
if (signal?.aborted) {
throw new Error("Login cancelled");
}
const remainingMs = deadline - Date.now();
const waitMs = Math.min(Math.ceil(intervalMs * intervalMultiplier), remainingMs);
await sleepForGitHubAccessTokenPoll(waitMs, signal);
const raw = await fetchJson(urls.accessTokenUrl, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "GitHubCopilotChat/0.35.0",
},
body: JSON.stringify({
body: new URLSearchParams({
client_id: CLIENT_ID,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
@@ -171,34 +187,28 @@ async function pollForGitHubAccessToken(
}
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
const err = (raw as DeviceTokenErrorResponse).error;
if (err === "authorization_pending") {
try {
await abortableSleep(intervalMs, signal);
} catch {
throw new Error("Login cancelled");
}
const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse;
if (error === "authorization_pending") {
continue;
}
if (err === "slow_down") {
intervalMs += 5000;
try {
await abortableSleep(intervalMs, signal);
} catch {
throw new Error("Login cancelled");
}
if (error === "slow_down") {
slowDownResponses += 1;
intervalMs =
typeof interval === "number" && interval > 0 ? interval * 1000 : Math.max(1000, intervalMs + 5000);
intervalMultiplier = SLOW_DOWN_POLL_INTERVAL_MULTIPLIER;
continue;
}
throw new Error(`Device flow failed: ${err}`);
const descriptionSuffix = description ? `: ${description}` : "";
throw new Error(`Device flow failed: ${error}${descriptionSuffix}`);
}
}
try {
await abortableSleep(intervalMs, signal);
} catch {
throw new Error("Login cancelled");
}
if (slowDownResponses > 0) {
throw new Error(
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.",
);
}
throw new Error("Device flow timed out");
@@ -317,7 +317,6 @@ describe("AuthStorage codex oauth ranking", () => {
expect(apiKey).toBe("api-acct-pro");
});
test("times out slow usage ranking instead of blocking first account selection", async () => {
if (!store) throw new Error("test setup failed");
@@ -176,6 +176,31 @@ describe("Google Gemini CLI alignment", () => {
expect(parameters).toBeDefined();
expect(JSON.stringify(parameters)).not.toContain('"patternProperties"');
});
it("adds anthropic-beta for Antigravity Claude reasoning models without relying on id suffix", async () => {
let requestHeaders: Headers | undefined;
using _hook = hookFetch(async (_url, init) => {
requestHeaders = new Headers(init?.headers);
return new Response('{"error":{"message":"bad request"}}', { status: 400 });
});
const model: Model<"google-gemini-cli"> = {
...createModel("google-antigravity"),
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.6",
reasoning: true,
};
const result = await streamGoogleGeminiCli(model, createContext(), {
apiKey: JSON.stringify({ token: "token", projectId: "proj-123" }),
}).result();
expect(result.stopReason).toBe("error");
expect(requestHeaders).toBeDefined();
expect(requestHeaders!.get("anthropic-beta")).toBe("interleaved-thinking-2025-05-14");
expect(requestHeaders!.get("X-Goog-Api-Client")).toBeNull();
expect(requestHeaders!.get("Client-Metadata")).toBeNull();
});
describe("retry guardrails", () => {
afterEach(() => {
vi.restoreAllMocks();
+40 -12
View File
@@ -2104,7 +2104,9 @@ describe("openai-codex streaming", () => {
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_sse", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Recovered over SSE" }] } })}`,
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
].join("\n\n")}\n\n`;
const fetchMock = vi.fn(async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }));
const fetchMock = vi.fn(
async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }),
);
global.fetch = fetchMock as unknown as typeof fetch;
type WsListener = (event: Event) => void;
@@ -2167,10 +2169,18 @@ describe("openai-codex streaming", () => {
contextWindow: 128000,
maxTokens: 128000,
};
const result = await streamOpenAICodexResponses(model, {
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
}, { apiKey: token, sessionId: "ws-malformed-json-session", providerSessionState: new Map<string, ProviderSessionState>() }).result();
const result = await streamOpenAICodexResponses(
model,
{
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
},
{
apiKey: token,
sessionId: "ws-malformed-json-session",
providerSessionState: new Map<string, ProviderSessionState>(),
},
).result();
expect(result.stopReason).toBe("stop");
expect(result.content.find(c => c.type === "text")?.text).toBe("Recovered over SSE");
@@ -2196,7 +2206,9 @@ describe("openai-codex streaming", () => {
`data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "message", id: "msg_sse_replay", role: "assistant", status: "completed", content: [{ type: "output_text", text: "Replay succeeded" }] } })}`,
`data: ${JSON.stringify({ type: "response.completed", response: { status: "completed", usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8, input_tokens_details: { cached_tokens: 0 } } } })}`,
].join("\n\n")}\n\n`;
const fetchMock = vi.fn(async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }));
const fetchMock = vi.fn(
async () => new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }),
);
global.fetch = fetchMock as unknown as typeof fetch;
type WsListener = (event: Event) => void;
@@ -2230,7 +2242,16 @@ describe("openai-codex streaming", () => {
send(): void {
this.#emit("message", {
data: JSON.stringify({ type: "response.output_item.added", item: { type: "message", id: "msg_ws_partial", role: "assistant", status: "in_progress", content: [] } }),
data: JSON.stringify({
type: "response.output_item.added",
item: {
type: "message",
id: "msg_ws_partial",
role: "assistant",
status: "in_progress",
content: [],
},
}),
} as unknown as Event);
this.#emit("message", {
data: JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } }),
@@ -2269,17 +2290,24 @@ describe("openai-codex streaming", () => {
contextWindow: 128000,
maxTokens: 128000,
};
const result = await streamOpenAICodexResponses(model, {
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
}, { apiKey: token, sessionId: "ws-buffered-close-session", providerSessionState: new Map<string, ProviderSessionState>() }).result();
const result = await streamOpenAICodexResponses(
model,
{
systemPrompt: "You are a helpful assistant.",
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
},
{
apiKey: token,
sessionId: "ws-buffered-close-session",
providerSessionState: new Map<string, ProviderSessionState>(),
},
).result();
expect(result.stopReason).toBe("stop");
expect(result.content.find(c => c.type === "text")?.text).toBe("Replay succeeded");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("resets append state and stale turn headers when websocket requests diverge", async () => {
const tempDir = TempDir.createSync("@pi-codex-stream-");
setAgentDir(tempDir.path());
+78 -1
View File
@@ -4,7 +4,7 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import { Effort } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-ai/models";
import { complete, stream } from "@oh-my-pi/pi-ai/stream";
import { complete, getEnvApiKey, stream } from "@oh-my-pi/pi-ai/stream";
import type { Api, Context, ImageContent, Model, OptionsForApi, Tool, ToolResultMessage } from "@oh-my-pi/pi-ai/types";
import { StringEnum } from "@oh-my-pi/pi-ai/utils/schema";
import { Type } from "@sinclair/typebox";
@@ -404,13 +404,90 @@ describe("Generate E2E Tests", () => {
);
});
describe("google-vertex env auth", () => {
it("treats GOOGLE_CLOUD_API_KEY as a configured google-vertex credential", () => {
const originalApiKey = Bun.env.GOOGLE_CLOUD_API_KEY;
const originalProject = Bun.env.GOOGLE_CLOUD_PROJECT;
const originalGcloudProject = Bun.env.GCLOUD_PROJECT;
const originalLocation = Bun.env.GOOGLE_CLOUD_LOCATION;
const originalApplicationCredentials = Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
try {
Bun.env.GOOGLE_CLOUD_API_KEY = "vertex-test-key";
delete Bun.env.GOOGLE_CLOUD_PROJECT;
delete Bun.env.GCLOUD_PROJECT;
delete Bun.env.GOOGLE_CLOUD_LOCATION;
delete Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
expect(getEnvApiKey("google-vertex")).toBe("vertex-test-key");
} finally {
if (originalApiKey === undefined) delete Bun.env.GOOGLE_CLOUD_API_KEY;
else Bun.env.GOOGLE_CLOUD_API_KEY = originalApiKey;
if (originalProject === undefined) delete Bun.env.GOOGLE_CLOUD_PROJECT;
else Bun.env.GOOGLE_CLOUD_PROJECT = originalProject;
if (originalGcloudProject === undefined) delete Bun.env.GCLOUD_PROJECT;
else Bun.env.GCLOUD_PROJECT = originalGcloudProject;
if (originalLocation === undefined) delete Bun.env.GOOGLE_CLOUD_LOCATION;
else Bun.env.GOOGLE_CLOUD_LOCATION = originalLocation;
if (originalApplicationCredentials === undefined) delete Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
else Bun.env.GOOGLE_APPLICATION_CREDENTIALS = originalApplicationCredentials;
}
});
it("allows explicit Vertex API keys without requiring project or location", async () => {
const originalApiKey = Bun.env.GOOGLE_CLOUD_API_KEY;
const originalProject = Bun.env.GOOGLE_CLOUD_PROJECT;
const originalGcloudProject = Bun.env.GCLOUD_PROJECT;
const originalLocation = Bun.env.GOOGLE_CLOUD_LOCATION;
const llm = getBundledModel("google-vertex", "gemini-3-flash-preview");
const controller = new AbortController();
controller.abort();
try {
delete Bun.env.GOOGLE_CLOUD_API_KEY;
delete Bun.env.GOOGLE_CLOUD_PROJECT;
delete Bun.env.GCLOUD_PROJECT;
delete Bun.env.GOOGLE_CLOUD_LOCATION;
const response = await complete(
llm,
{ messages: [{ role: "user", content: "Hello", timestamp: Date.now() }] },
{ apiKey: "vertex-test-key", signal: controller.signal },
);
expect(response.stopReason).toBe("aborted");
expect(response.errorMessage).toBeTruthy();
expect(response.errorMessage).not.toContain("Vertex AI requires a project ID");
expect(response.errorMessage).not.toContain("Vertex AI requires a location");
} finally {
if (originalApiKey === undefined) delete Bun.env.GOOGLE_CLOUD_API_KEY;
else Bun.env.GOOGLE_CLOUD_API_KEY = originalApiKey;
if (originalProject === undefined) delete Bun.env.GOOGLE_CLOUD_PROJECT;
else Bun.env.GOOGLE_CLOUD_PROJECT = originalProject;
if (originalGcloudProject === undefined) delete Bun.env.GCLOUD_PROJECT;
else Bun.env.GCLOUD_PROJECT = originalGcloudProject;
if (originalLocation === undefined) delete Bun.env.GOOGLE_CLOUD_LOCATION;
else Bun.env.GOOGLE_CLOUD_LOCATION = originalLocation;
}
});
});
describe("Google Vertex Provider (gemini-3-flash-preview)", () => {
const vertexApiKey = Bun.env.GOOGLE_CLOUD_API_KEY;
const vertexProject = Bun.env.GOOGLE_CLOUD_PROJECT || Bun.env.GCLOUD_PROJECT;
const vertexLocation = Bun.env.GOOGLE_CLOUD_LOCATION;
const isVertexConfigured = Boolean(vertexProject && vertexLocation);
const vertexOptions = { project: vertexProject, location: vertexLocation } as const;
const llm = getBundledModel("google-vertex", "gemini-3-flash-preview");
it.skipIf(!vertexApiKey)(
"should complete basic text generation with Vertex API key",
async () => {
await basicTextGeneration(llm, { apiKey: vertexApiKey! });
},
{ retry: 3 },
);
it.skipIf(!isVertexConfigured)(
"should complete basic text generation",
async () => {
@@ -242,6 +242,16 @@ export const SETTINGS_SCHEMA = {
description: "Action when pressing Escape twice with empty editor",
},
},
treeFilterMode: {
type: "enum",
values: ["default", "no-tools", "user-only", "labeled-only", "all"] as const,
default: "default",
ui: {
tab: "input",
label: "Tree filter mode",
description: "Default filter mode when opening the session tree",
},
},
shellPath: { type: "string", default: undefined },
collapseChangelog: {
type: "boolean",
@@ -1310,6 +1320,9 @@ export type StatusLinePreset = SettingValue<"statusLine.preset">;
/** Status line separator style - derived from schema */
export type StatusLineSeparatorStyle = SettingValue<"statusLine.separator">;
/** Tree selector filter mode - derived from schema */
export type TreeFilterMode = SettingValue<"treeFilterMode">;
// ═══════════════════════════════════════════════════════════════════════════
// Typed Group Definitions
// ═══════════════════════════════════════════════════════════════════════════
@@ -339,7 +339,7 @@ export class ExtensionRunner {
getRegisteredCommands(reserved?: Set<string>): RegisteredCommand[] {
this.#commandDiagnostics = [];
const commands: RegisteredCommand[] = [];
const commands = new Map<string, RegisteredCommand>();
for (const ext of this.extensions) {
for (const command of ext.commands.values()) {
if (reserved?.has(command.name)) {
@@ -351,10 +351,10 @@ export class ExtensionRunner {
continue;
}
commands.push(command);
commands.set(command.name, command);
}
}
return commands;
return [...commands.values()];
}
getCommandDiagnostics(): Array<{ type: string; message: string; path: string }> {
@@ -362,8 +362,8 @@ export class ExtensionRunner {
}
getCommand(name: string): RegisteredCommand | undefined {
for (const ext of this.extensions) {
const command = ext.commands.get(name);
for (let index = this.extensions.length - 1; index >= 0; index -= 1) {
const command = this.extensions[index]?.commands.get(name);
if (command) {
return command;
}
@@ -10,6 +10,7 @@ import {
TruncatedText,
truncateToWidth,
} from "@oh-my-pi/pi-tui";
import type { TreeFilterMode } from "../../config/settings-schema";
import { theme } from "../../modes/theme/theme";
import type { SessionTreeNode } from "../../session/session-manager";
import { shortenPath } from "../../tools/render-utils";
@@ -37,7 +38,7 @@ interface FlatNode {
}
/** Filter mode for tree display */
type FilterMode = "default" | "no-tools" | "user-only" | "labeled-only" | "all";
type FilterMode = TreeFilterMode;
/**
* Tree list component with selection and ASCII art visualization
@@ -52,7 +53,7 @@ class TreeList implements Component {
#flatNodes: FlatNode[] = [];
#filteredNodes: FlatNode[] = [];
#selectedIndex = 0;
#filterMode: FilterMode = "default";
#filterMode: FilterMode;
#searchQuery = "";
#toolCallMap: Map<string, ToolCallInfo> = new Map();
#multipleRoots = false;
@@ -67,8 +68,10 @@ class TreeList implements Component {
tree: SessionTreeNode[],
private readonly currentLeafId: string | null,
private readonly maxVisibleLines: number,
initialFilterMode: FilterMode = "default",
initialSelectedId?: string,
) {
this.#filterMode = initialFilterMode;
this.#multipleRoots = tree.length > 1;
this.#flatNodes = this.#flattenTree(tree);
this.#buildActivePath();
@@ -828,11 +831,12 @@ export class TreeSelectorComponent extends Container {
onSelect: (entryId: string) => void,
onCancel: () => void,
private readonly onLabelChangeCallback?: (entryId: string, label: string | undefined) => void,
initialFilterMode: FilterMode = "default",
) {
super();
const maxVisibleLines = Math.max(5, Math.floor(terminalHeight / 2));
this.#treeList = new TreeList(tree, currentLeafId, maxVisibleLines);
this.#treeList = new TreeList(tree, currentLeafId, maxVisibleLines, initialFilterMode);
this.#treeList.onSelect = onSelect;
this.#treeList.onCancel = onCancel;
this.#treeList.onLabelEdit = (entryId, currentLabel) => this.#showLabelInput(entryId, currentLabel);
@@ -578,6 +578,7 @@ export class SelectorController {
this.ctx.sessionManager.appendLabelChange(entryId, label);
this.ctx.ui.requestRender();
},
settings.get("treeFilterMode"),
);
return { component: selector, focus: selector };
});
@@ -104,6 +104,19 @@ export function upsertFileOperations(summary: string, readFiles: string[], modif
// Message Serialization
// ============================================================================
/** Maximum characters for a tool result in serialized summaries. */
const TOOL_RESULT_MAX_CHARS = 2000;
/**
* Truncate text to a maximum character length for summarization.
* Keeps the beginning and appends a truncation marker.
*/
function truncateForSummary(text: string, maxChars: number): string {
if (text.length <= maxChars) return text;
const truncatedChars = text.length - maxChars;
return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`;
}
/**
* Serialize LLM messages to text for summarization.
* This prevents the model from treating it as a conversation to continue.
@@ -156,7 +169,7 @@ export function serializeConversation(messages: Message[]): string {
.map(c => c.text)
.join("");
if (content) {
parts.push(`[Tool result]: ${content}`);
parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`);
}
}
}
+6 -2
View File
@@ -152,9 +152,13 @@ function formatMetadataLine(lineCount: number | null, language: string | undefin
return uiTheme.fg("dim", `${icon}`);
}
function normalizeDisplayText(text: string): string {
return text.replace(/\r/g, "");
}
function formatStreamingContent(content: string, uiTheme: Theme): string {
if (!content) return "";
const lines = content.split("\n");
const lines = normalizeDisplayText(content).split("\n");
const displayLines = lines.slice(-WRITE_STREAMING_PREVIEW_LINES);
const hidden = lines.length - displayLines.length;
@@ -171,7 +175,7 @@ function formatStreamingContent(content: string, uiTheme: Theme): string {
function renderContentPreview(content: string, expanded: boolean, uiTheme: Theme): string {
if (!content) return "";
const lines = content.split("\n");
const lines = normalizeDisplayText(content).split("\n");
const maxLines = expanded ? lines.length : Math.min(lines.length, WRITE_PREVIEW_LINES);
const displayLines = expanded ? lines : lines.slice(-maxLines);
const hidden = lines.length - displayLines.length;
@@ -39,7 +39,7 @@ export async function openInEditor(
const [editor, ...editorArgs] = editorCmd.split(" ");
const stdio = options?.stdio ?? ["inherit", "inherit", "inherit"];
const child = spawn(editor, [...editorArgs, tmpFile], { stdio });
const child = spawn(editor, [...editorArgs, tmpFile], { stdio, shell: process.platform === "win32" });
const exitCode = await new Promise<number>((resolve, reject) => {
child.once("exit", (code, signal) => resolve(code ?? (signal ? -1 : 0)));
child.once("error", error => reject(error));
@@ -0,0 +1,78 @@
import { describe, expect, it } from "bun:test";
import type { Message } from "@oh-my-pi/pi-ai";
import { serializeConversation } from "../src/session/compaction/utils";
describe("serializeConversation", () => {
it("truncates long tool results in serialized summaries", () => {
const longContent = "x".repeat(5000);
const messages: Message[] = [
{
role: "toolResult",
toolCallId: "tc1",
toolName: "read",
content: [{ type: "text", text: longContent }],
isError: false,
timestamp: Date.now(),
},
];
const result = serializeConversation(messages);
expect(result).toContain("[Tool result]:");
expect(result).toContain("[... 3000 more characters truncated]");
expect(result).toContain("x".repeat(2000));
expect(result).not.toContain("x".repeat(3000));
});
it("does not truncate short tool results", () => {
const shortContent = "x".repeat(1500);
const messages: Message[] = [
{
role: "toolResult",
toolCallId: "tc1",
toolName: "read",
content: [{ type: "text", text: shortContent }],
isError: false,
timestamp: Date.now(),
},
];
const result = serializeConversation(messages);
expect(result).toBe(`[Tool result]: ${shortContent}`);
expect(result).not.toContain("truncated");
});
it("does not truncate assistant or user messages", () => {
const longText = "y".repeat(5000);
const messages: Message[] = [
{
role: "user",
content: [{ type: "text", text: longText }],
timestamp: Date.now(),
},
{
role: "assistant",
content: [{ type: "text", text: longText }],
api: "anthropic",
provider: "anthropic",
model: "test",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
},
];
const result = serializeConversation(messages);
expect(result).not.toContain("truncated");
expect(result).toContain(longText);
});
});
@@ -32,8 +32,8 @@ describe("ExtensionRunner", () => {
tempDir.removeSync();
});
const loadTestExtensions = async () => {
const result = await discoverAndLoadExtensions([], tempDir.path());
const loadTestExtensions = async (configuredPaths: string[] = []) => {
const result = await discoverAndLoadExtensions(configuredPaths, tempDir.path());
return {
...result,
extensions: filterUserExtensions(result.extensions),
@@ -199,6 +199,37 @@ describe("ExtensionRunner", () => {
const missing = runner.getCommand("not-exists");
expect(missing).toBeUndefined();
});
it("prefers later-loaded explicit extensions for conflicting commands", async () => {
const deployCommand = (description: string) => `
export default function(pi) {
pi.registerCommand("deploy", {
description: "${description}",
handler: async () => {},
});
}
`;
fs.writeFileSync(path.join(extensionsDir, "discovered-deploy.ts"), deployCommand("Discovered deploy"));
const explicitExtensionPath = path.join(tempDir.path(), "explicit-deploy.ts");
fs.writeFileSync(explicitExtensionPath, deployCommand("Explicit deploy"));
const result = await loadTestExtensions([explicitExtensionPath]);
const runner = new ExtensionRunner(
result.extensions,
result.runtime,
tempDir.path(),
sessionManager,
modelRegistry,
);
const commands = runner.getRegisteredCommands();
expect(commands).toHaveLength(1);
expect(commands[0]?.description).toBe("Explicit deploy");
const command = runner.getCommand("deploy");
expect(command?.description).toBe("Explicit deploy");
});
});
describe("error handling", () => {
@@ -38,6 +38,7 @@ describe("ModelSelector role badge thinking display", () => {
const modelRegistry = {
getAll: () => [model],
getDiscoverableProviders: () => [],
} as unknown as ModelRegistry;
const ui = {
requestRender: vi.fn(),
+25
View File
@@ -772,6 +772,7 @@ export class Editor implements Component, Focusable {
if (matchesKey(data, "tab")) {
const selected = this.#autocompleteList.getSelectedItem();
if (selected && this.#autocompleteProvider) {
const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
const result = this.#autocompleteProvider.applyCompletion(
this.#state.lines,
this.#state.cursorLine,
@@ -791,6 +792,10 @@ export class Editor implements Component, Focusable {
}
result.onApplied?.();
if (shouldChainSlashCommandAutocomplete && this.#isCompletedSlashCommandAtCursor()) {
void this.#tryTriggerAutocomplete();
}
}
return;
}
@@ -2037,6 +2042,26 @@ export class Editor implements Component, Focusable {
return beforeCursor.trim() === "" || beforeCursor.trim() === "/";
}
#isSlashCommandNameAutocompleteSelection(): boolean {
if (this.#autocompleteState !== "regular") {
return false;
}
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol).trimStart();
return textBeforeCursor.startsWith("/") && !textBeforeCursor.includes(" ");
}
#isCompletedSlashCommandAtCursor(): boolean {
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
if (this.#state.cursorCol !== currentLine.length) {
return false;
}
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol).trimStart();
return /^\/\S+ $/.test(textBeforeCursor);
}
// Autocomplete methods
async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise<void> {
if (!this.#autocompleteProvider) return;
+3 -2
View File
@@ -9,6 +9,7 @@ import {
moveWordLeft,
moveWordRight,
padding,
replaceTabs,
sliceWithWidth,
visibleWidth,
} from "../utils";
@@ -356,8 +357,8 @@ export class Input implements Component, Focusable {
this.#lastAction = null;
this.#pushUndo();
// Clean the pasted text - remove newlines and carriage returns
const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "");
// Clean the pasted text - remove newlines and carriage returns, then normalize tabs.
const cleanText = replaceTabs(pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, ""));
// Insert at cursor position
this.#value = this.#value.slice(0, this.#cursor) + cleanText + this.#value.slice(this.#cursor);
+54 -26
View File
@@ -57,6 +57,10 @@ interface InlineStyleContext {
stylePrefix: string;
}
type ListToken = Token & { items: Array<{ tokens?: Token[] }>; ordered: boolean; start?: number };
type TableCellToken = { tokens?: Token[] };
type TableToken = Token & { header: TableCellToken[]; rows: TableCellToken[][]; raw?: string };
function formatHyperlink(text: string, target: string): string {
if (!TERMINAL.hyperlinks || !target) {
return text;
@@ -285,14 +289,14 @@ export class Markdown implements Component {
};
}
#renderToken(token: Token, width: number, nextTokenType?: string): string[] {
#renderToken(token: Token, width: number, nextTokenType?: string, styleContext?: InlineStyleContext): string[] {
const lines: string[] = [];
switch (token.type) {
case "heading": {
const headingLevel = token.depth;
const headingPrefix = `${"#".repeat(headingLevel)} `;
const headingText = this.#renderInlineTokens(token.tokens || []);
const headingText = this.#renderInlineTokens(token.tokens || [], styleContext);
let styledHeading: string;
if (headingLevel === 1) {
styledHeading = this.#theme.heading(this.#theme.bold(this.#theme.underline(headingText)));
@@ -309,7 +313,7 @@ export class Markdown implements Component {
}
case "paragraph": {
const paragraphText = this.#renderInlineTokens(token.tokens || []);
const paragraphText = this.#renderInlineTokens(token.tokens || [], styleContext);
lines.push(paragraphText);
// Don't add spacing if next token is space or list
if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
@@ -357,7 +361,7 @@ export class Markdown implements Component {
}
case "list": {
const listLines = this.#renderList(token as any, 0);
const listLines = this.#renderList(token as ListToken, 0, styleContext);
lines.push(...listLines);
// Don't add spacing after lists if a space token follows
// (the space token will handle it)
@@ -365,26 +369,48 @@ export class Markdown implements Component {
}
case "table": {
const tableLines = this.#renderTable(token as any, width);
const tableLines = this.#renderTable(token as TableToken, width, styleContext);
lines.push(...tableLines);
break;
}
case "blockquote": {
const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
const quoteStyleContext: InlineStyleContext = {
applyText: quoteStyle,
stylePrefix: this.#getStylePrefix(quoteStyle),
const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
const applyQuoteStyle = (line: string): string => {
if (!quoteStylePrefix) {
return quoteStyle(line);
}
const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`);
return quoteStyle(lineWithReappliedStyle);
};
const quoteText = this.#renderInlineTokens(token.tokens || [], quoteStyleContext);
const quoteLines = quoteText.split("\n");
// Calculate available width for quote content (subtract border + space = 2 chars)
// Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render
// children recursively and keep default message styling out of nested content.
const quoteInlineStyleContext: InlineStyleContext = {
applyText: (text: string) => text,
stylePrefix: "",
};
const quoteContentWidth = Math.max(1, width - 2);
const quoteTokens = token.tokens || [];
const renderedQuoteLines: string[] = [];
for (const quoteLine of quoteLines) {
// Wrap the styled line, then add border to each wrapped line
const wrappedLines = wrapTextWithAnsi(quoteLine, quoteContentWidth);
for (let i = 0; i < quoteTokens.length; i++) {
const quoteToken = quoteTokens[i];
const nextQuoteToken = quoteTokens[i + 1];
renderedQuoteLines.push(
...this.#renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext),
);
}
while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
renderedQuoteLines.pop();
}
for (const quoteLine of renderedQuoteLines) {
const styledLine = applyQuoteStyle(quoteLine);
const wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth);
for (const wrappedLine of wrappedLines) {
lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
}
@@ -514,7 +540,7 @@ export class Markdown implements Component {
/**
* Render a list with proper nesting support
*/
#renderList(token: Token & { items: any[]; ordered: boolean; start?: number }, depth: number): string[] {
#renderList(token: ListToken, depth: number, styleContext?: InlineStyleContext): string[] {
const lines: string[] = [];
const indent = " ".repeat(depth);
// Use the list's start property (defaults to 1 for ordered lists)
@@ -525,7 +551,7 @@ export class Markdown implements Component {
const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
// Process item tokens to handle nested lists
const itemLines = this.#renderListItem(item.tokens || [], depth);
const itemLines = this.#renderListItem(item.tokens || [], depth, styleContext);
if (itemLines.length > 0) {
// First line - check if it's a nested list
@@ -566,23 +592,25 @@ export class Markdown implements Component {
* Render list item tokens, handling nested lists
* Returns lines WITHOUT the parent indent (renderList will add it)
*/
#renderListItem(tokens: Token[], parentDepth: number): string[] {
#renderListItem(tokens: Token[], parentDepth: number, styleContext?: InlineStyleContext): string[] {
const lines: string[] = [];
for (const token of tokens) {
if (token.type === "list") {
// Nested list - render with one additional indent level
// These lines will have their own indent, so we just add them as-is
const nestedLines = this.#renderList(token as any, parentDepth + 1);
const nestedLines = this.#renderList(token as ListToken, parentDepth + 1, styleContext);
lines.push(...nestedLines);
} else if (token.type === "text") {
// Text content (may have inline tokens)
const text =
token.tokens && token.tokens.length > 0 ? this.#renderInlineTokens(token.tokens) : token.text || "";
token.tokens && token.tokens.length > 0
? this.#renderInlineTokens(token.tokens, styleContext)
: token.text || "";
lines.push(text);
} else if (token.type === "paragraph") {
// Paragraph in list item
const text = this.#renderInlineTokens(token.tokens || []);
const text = this.#renderInlineTokens(token.tokens || [], styleContext);
lines.push(text);
} else if (token.type === "code") {
// Code block in list item
@@ -602,7 +630,7 @@ export class Markdown implements Component {
lines.push(this.#theme.codeBlockBorder("```"));
} else {
// Other token types - try to render as inline
const text = this.#renderInlineTokens([token]);
const text = this.#renderInlineTokens([token], styleContext);
if (text) {
lines.push(text);
}
@@ -641,7 +669,7 @@ 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: Token & { header: any[]; rows: any[][]; raw?: string }, availableWidth: number): string[] {
#renderTable(token: TableToken, availableWidth: number, styleContext?: InlineStyleContext): string[] {
const lines: string[] = [];
const numCols = token.header.length;
@@ -666,13 +694,13 @@ export class Markdown implements Component {
const naturalWidths: number[] = [];
const minWordWidths: number[] = [];
for (let i = 0; i < numCols; i++) {
const headerText = this.#renderInlineTokens(token.header[i].tokens || []);
const headerText = this.#renderInlineTokens(token.header[i].tokens || [], styleContext);
naturalWidths[i] = visibleWidth(headerText);
minWordWidths[i] = Math.max(1, this.#getLongestWordWidth(headerText, maxUnbrokenWordWidth));
}
for (const row of token.rows) {
for (let i = 0; i < row.length; i++) {
const cellText = this.#renderInlineTokens(row[i].tokens || []);
const cellText = this.#renderInlineTokens(row[i].tokens || [], styleContext);
naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText));
minWordWidths[i] = Math.max(
minWordWidths[i] || 1,
@@ -761,7 +789,7 @@ export class Markdown implements Component {
// Render header with wrapping
const headerCellLines: string[][] = token.header.map((cell, i) => {
const text = this.#renderInlineTokens(cell.tokens || []);
const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
return this.#wrapCellText(text, columnWidths[i]);
});
const headerLineCount = Math.max(...headerCellLines.map(c => c.length));
@@ -784,7 +812,7 @@ export class Markdown implements Component {
for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
const row = token.rows[rowIndex];
const rowCellLines: string[][] = row.map((cell, i) => {
const text = this.#renderInlineTokens(cell.tokens || []);
const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
return this.#wrapCellText(text, columnWidths[i]);
});
const rowLineCount = Math.max(...rowCellLines.map(c => c.length));
+2
View File
@@ -277,7 +277,9 @@ function decodeKittyPrintable(data: string): string | undefined {
const modValue = match[4] ? Number.parseInt(match[4], 10) : 1;
const modifier = Number.isFinite(modValue) ? modValue - 1 : 0;
const effectiveMod = modifier & ~(64 + 128);
const supportedModifierMask = KITTY_MOD_SHIFT | KITTY_MOD_ALT | KITTY_MOD_CTRL;
if (effectiveMod & ~supportedModifierMask) return undefined;
if (effectiveMod & (KITTY_MOD_ALT | KITTY_MOD_CTRL)) return undefined;
const textField = match[6];
+31
View File
@@ -32,6 +32,7 @@ export function emergencyTerminalRestore(): void {
"\x1b[?2004l" + // Disable bracketed paste
"\x1b[?2031l" + // Disable Mode 2031 appearance notifications
"\x1b[<u" + // Pop kitty keyboard protocol
"\x1b[>4;0m" + // Disable modifyOtherKeys fallback
"\x1b[?25h", // Show cursor
);
if (process.stdin.setRawMode) {
@@ -103,6 +104,8 @@ export class ProcessTerminal implements Terminal {
#inputHandler?: (data: string) => void;
#resizeHandler?: () => void;
#kittyProtocolActive = false;
#modifyOtherKeysActive = false;
#modifyOtherKeysTimeout?: ReturnType<typeof setTimeout>;
#stdinBuffer?: StdinBuffer;
#stdinDataHandler?: (data: string) => void;
#dead = false;
@@ -242,6 +245,10 @@ export class ProcessTerminal implements Terminal {
if (!this.#kittyProtocolActive) {
const match = sequence.match(kittyResponsePattern);
if (match) {
if (this.#modifyOtherKeysTimeout) {
clearTimeout(this.#modifyOtherKeysTimeout);
this.#modifyOtherKeysTimeout = undefined;
}
this.#kittyProtocolActive = true;
setKittyProtocolActive(true);
@@ -302,6 +309,14 @@ export class ProcessTerminal implements Terminal {
this.#setupStdinBuffer();
process.stdin.on("data", this.#stdinDataHandler!);
this.#safeWrite("\x1b[?u");
this.#modifyOtherKeysTimeout = setTimeout(() => {
this.#modifyOtherKeysTimeout = undefined;
if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
return;
}
this.#safeWrite("\x1b[>4;2m");
this.#modifyOtherKeysActive = true;
}, 150);
}
async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
@@ -312,6 +327,14 @@ export class ProcessTerminal implements Terminal {
this.#kittyProtocolActive = false;
setKittyProtocolActive(false);
}
if (this.#modifyOtherKeysTimeout) {
clearTimeout(this.#modifyOtherKeysTimeout);
this.#modifyOtherKeysTimeout = undefined;
}
if (this.#modifyOtherKeysActive) {
this.#safeWrite("\x1b[>4;0m");
this.#modifyOtherKeysActive = false;
}
const previousHandler = this.#inputHandler;
this.#inputHandler = undefined;
@@ -357,6 +380,14 @@ export class ProcessTerminal implements Terminal {
this.#kittyProtocolActive = false;
setKittyProtocolActive(false);
}
if (this.#modifyOtherKeysTimeout) {
clearTimeout(this.#modifyOtherKeysTimeout);
this.#modifyOtherKeysTimeout = undefined;
}
if (this.#modifyOtherKeysActive) {
this.#safeWrite("\x1b[>4;0m");
this.#modifyOtherKeysActive = false;
}
this.#restoreWindowsVTInput();
// Clean up StdinBuffer
+69
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
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 { defaultEditorTheme } from "./test-themes";
@@ -296,6 +297,74 @@ describe("Editor component", () => {
await expect(promise).resolves.toBe("@");
});
it("chains into argument completions after tab-completing slash command names", async () => {
const editor = new Editor(defaultEditorTheme);
editor.setAutocompleteProvider(
new CombinedAutocompleteProvider(
[
{
name: "model",
description: "Select a model",
getArgumentCompletions() {
return [{ label: "claude-opus", value: "claude-opus" }];
},
},
{ name: "help", description: "Show help" },
],
"/tmp",
),
);
editor.handleInput("/");
await Bun.sleep(0);
editor.handleInput("m");
editor.handleInput("o");
editor.handleInput("d");
await Bun.sleep(110);
editor.handleInput(" ");
await Bun.sleep(0);
expect(editor.getText()).toBe("/model ");
expect(editor.isShowingAutocomplete()).toBe(true);
editor.handleInput(" ");
expect(editor.getText()).toBe("/model claude-opus");
expect(editor.isShowingAutocomplete()).toBe(false);
});
it("does not show argument completions when command has no argument completer", async () => {
const editor = new Editor(defaultEditorTheme);
editor.setAutocompleteProvider(
new CombinedAutocompleteProvider(
[
{
name: "model",
description: "Select a model",
getArgumentCompletions() {
return [{ label: "claude-opus", value: "claude-opus" }];
},
},
{ name: "help", description: "Show help" },
],
"/tmp",
),
);
editor.handleInput("/");
await Bun.sleep(0);
editor.handleInput("h");
editor.handleInput("e");
await Bun.sleep(110);
editor.handleInput(" ");
await Bun.sleep(0);
expect(editor.getText()).toBe("/help ");
expect(editor.isShowingAutocomplete()).toBe(false);
});
});
describe("Unicode text editing behavior", () => {
+14
View File
@@ -3,6 +3,7 @@ import { CURSOR_MARKER } from "@oh-my-pi/pi-tui";
import { Input } from "@oh-my-pi/pi-tui/components/input";
import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
import { getIndentation } from "@oh-my-pi/pi-utils";
function renderedWidth(input: Input, width: number): number {
const [line] = input.render(width);
@@ -142,6 +143,19 @@ describe("Input component", () => {
setKittyProtocolActive(false);
});
it("normalizes tabs in buffered bracketed paste using configured indentation", () => {
const input = setupAtEnd("");
input.handleInput("\x1b[200~a\t");
expect(input.getValue()).toBe("");
input.handleInput("b\r\n");
expect(input.getValue()).toBe("");
input.handleInput("c\x1b[201~");
expect(input.getValue()).toBe(`a${getIndentation()}bc`);
});
it("never renders a line wider than the terminal width (wide chars)", () => {
const input = new Input();
input.focused = true;
+15
View File
@@ -92,6 +92,12 @@ describe("parseKey", () => {
expect(parseKey("\x1b[57400;133u")).toBe("ctrl+end");
setKittyProtocolActive(false);
});
it("ignores Kitty sequences with unsupported modifiers", () => {
setKittyProtocolActive(true);
expect(parseKey("\x1b[99;9u")).toBeUndefined();
setKittyProtocolActive(false);
});
});
describe("extractPrintableText", () => {
@@ -102,4 +108,13 @@ describe("extractPrintableText", () => {
it("does not treat modified NumLock keypad navigation keys as text", () => {
expect(extractPrintableText("\x1b[57400;133u")).toBeUndefined();
});
it("ignores unsupported modifiers on Kitty CSI-u text", () => {
expect(extractPrintableText("\x1b[99;9u")).toBeUndefined();
expect(extractPrintableText("\x1b[97;9;229u")).toBeUndefined();
});
it("preserves Kitty CSI-u text-field decoding for supported modifiers", () => {
expect(extractPrintableText("\x1b[97;1;229u")).toBe("å");
});
});
+44
View File
@@ -851,6 +851,50 @@ bar`,
// Should have italic from quote styling (\x1b[3m)
expect(allOutput.includes("\x1b[3m")).toBeTruthy();
});
it("should render list content inside blockquotes", () => {
const markdown = new Markdown("> 1. bla bla\n> - nested bullet", 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
const quotedLines = plainLines.filter(line => line.startsWith("│ "));
expect(quotedLines.some(line => line.includes("1. bla bla"))).toBeTruthy();
expect(quotedLines.some(line => line.includes("- nested bullet"))).toBeTruthy();
});
it("should render table content inside blockquotes", () => {
const markdown = new Markdown("> | A | B |\n> | --- | --- |\n> | 1 | 2 |", 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
const quotedLines = plainLines.filter(line => line.startsWith("│ "));
const quotedOutput = quotedLines.join("\n");
expect(quotedOutput.includes("A")).toBeTruthy();
expect(quotedOutput.includes("B")).toBeTruthy();
expect(quotedOutput.includes("1")).toBeTruthy();
expect(quotedOutput.includes("2")).toBeTruthy();
expect(quotedOutput.includes("+---+")).toBeTruthy();
expect(quotedOutput.includes("| A")).toBeTruthy();
});
it("should render fenced code blocks inside blockquotes without applying default text color", () => {
const markdown = new Markdown("> ```js\n> console.log(1)\n> ```", 0, 0, defaultMarkdownTheme, {
color: text => chalk.magenta(text),
});
const lines = markdown.render(80);
const plainLines = lines.map(line => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
const quotedLines = plainLines.filter(line => line.startsWith("│ "));
const output = lines.join("\n");
const plainOutput = quotedLines.join("\n");
expect(plainOutput.includes("```js")).toBeTruthy();
expect(plainOutput.includes("console.log(1)")).toBeTruthy();
expect(plainOutput.includes("```")).toBeTruthy();
expect(output.includes("\x1b[35m")).toBeFalsy();
expect(output.includes("\x1b[3m")).toBeTruthy();
});
});
const stripTerminalSequences = (line: string): string =>