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