From 19e1d202ef4ef5d6a9aee1e6cfd39b70b5fd717a Mon Sep 17 00:00:00 2001 From: kamafozilov Date: Sat, 4 Apr 2026 03:18:43 +0500 Subject: [PATCH 01/14] feat(ai): add gemini-3.1-flash-lite-preview to google-gemini-cli catalog The model exists in the upstream Gemini CLI but was missing from the google-gemini-cli provider in models.json. Closes #546 --- packages/ai/src/models.json | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/ai/src/models.json b/packages/ai/src/models.json index 456c78258..aa5dd5259 100644 --- a/packages/ai/src/models.json +++ b/packages/ai/src/models.json @@ -6813,6 +6813,31 @@ "minLevel": "low", "maxLevel": "high" } + }, + "gemini-3.1-flash-lite-preview": { + "id": "gemini-3.1-flash-lite-preview", + "name": "Gemini 3.1 Flash Lite Preview", + "api": "google-gemini-cli", + "provider": "google-gemini-cli", + "baseUrl": "https://cloudcode-pa.googleapis.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 65536, + "thinking": { + "mode": "google-level", + "minLevel": "minimal", + "maxLevel": "high" + } } }, "google-vertex": { From 17bfadcb5f5eb9acf9080af0473036ddc0d1b07a Mon Sep 17 00:00:00 2001 From: kamafozilov Date: Sat, 4 Apr 2026 03:29:23 +0500 Subject: [PATCH 02/14] fix(coding-agent): submit plan refinement directly instead of requiring double enter When selecting 'Refine plan' during plan review, the refinement text was placed into the editor via setText() but not submitted. The user had to press Enter a second time to actually send the message. After session.abort() at the start of handleExitPlanModeTool, the main loop cycles back to getUserInput() which sets onInputCallback before the hook selector and input can resolve. By the time the user finishes typing the refinement, onInputCallback is available. Submit the refinement directly through onInputCallback + startPendingSubmission, matching the pattern already used by handlePlanModeCommand for initial prompts. Falls back to editor.setText() if onInputCallback is unexpectedly absent. Fixes #597 --- packages/coding-agent/src/modes/interactive-mode.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index ea9e5dbb7..fd578c9a5 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -928,9 +928,13 @@ export class InteractiveMode implements InteractiveModeContext { return; } if (choice === "Refine plan") { - const refinement = await this.showHookInput("What should be refined?"); + const refinement = (await this.showHookInput("What should be refined?"))?.trim(); if (refinement) { - this.editor.setText(refinement); + if (this.onInputCallback) { + this.onInputCallback(this.startPendingSubmission({ text: refinement })); + } else { + this.editor.setText(refinement); + } } } } From fba6cc278b98316239feba4b950af9202acb4e86 Mon Sep 17 00:00:00 2001 From: DeprecatedLuke <16418011+DeprecatedLuke@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:18:57 +0100 Subject: [PATCH 03/14] feat(coding-agent): truncate web search source lines --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/web/search/render.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8daef5ebd..872d48b6a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -46,6 +46,7 @@ ### Fixed +- Fixed web search source rendering to truncate long title, metadata, and URL lines before they overflow the UI. - Fixed `read` output for file-backed internal URLs like `local://...` to include hashline prefixes in hashline edit mode, preserving usable line refs for follow-up edits - Fixed the plan review selector to support the external editor shortcut for opening and updating the current plan from the approval screen diff --git a/packages/coding-agent/src/web/search/render.ts b/packages/coding-agent/src/web/search/render.ts index f22b3b91a..116198cf6 100644 --- a/packages/coding-agent/src/web/search/render.ts +++ b/packages/coding-agent/src/web/search/render.ts @@ -188,7 +188,7 @@ export function renderSearchResult( : typeof src.url === "string" && src.url.trim() ? src.url : "Untitled"; - const title = truncateToWidth(titleText, 70); + const title = truncateToWidth(titleText, MAX_SNIPPET_LINE_LEN); const url = typeof src.url === "string" ? src.url : ""; const domain = url ? getDomain(url) : ""; const age = @@ -196,11 +196,13 @@ export function renderSearchResult( const metaParts: string[] = []; if (domain) metaParts.push(theme.fg("dim", `(${domain})`)); if (typeof src.author === "string" && src.author.trim()) - metaParts.push(theme.fg("muted", src.author)); + metaParts.push(theme.fg("muted", truncateToWidth(src.author.trim(), 40))); if (age) metaParts.push(theme.fg("muted", age)); const metaSep = theme.fg("dim", theme.sep.dot); const metaSuffix = metaParts.length > 0 ? ` ${metaParts.join(metaSep)}` : ""; - const srcLines: string[] = [`${theme.fg("accent", title)}${metaSuffix}`]; + const srcLines: string[] = [ + truncateToWidth(`${theme.fg("accent", title)}${metaSuffix}`, MAX_SNIPPET_LINE_LEN), + ]; const snippetText = typeof src.snippet === "string" ? src.snippet : ""; if (snippetText.trim()) { const snippetLines = getPreviewLines(snippetText, MAX_SNIPPET_LINES, MAX_SNIPPET_LINE_LEN); @@ -208,7 +210,7 @@ export function renderSearchResult( srcLines.push(theme.fg("muted", `${theme.format.dash} ${snippetLine}`)); } } - if (url) srcLines.push(theme.fg("mdLinkUrl", url)); + if (url) srcLines.push(theme.fg("mdLinkUrl", truncateToWidth(url, MAX_SNIPPET_LINE_LEN))); return srcLines; }, }, From 013e52da7df7812ac1da5feac451bcff10dc1f0d Mon Sep 17 00:00:00 2001 From: DeprecatedLuke <16418011+DeprecatedLuke@users.noreply.github.com> Date: Sat, 4 Apr 2026 11:20:55 +0100 Subject: [PATCH 04/14] feat(ai): add OpenAI Codex env auth fallback --- packages/ai/CHANGELOG.md | 2 ++ packages/ai/src/stream.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b9e7397ce..f7cbba101 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,7 +1,9 @@ # Changelog ## [Unreleased] +### Added +- Added environment-based credential fallback for the OpenAI Codex provider. ## [13.17.6] - 2026-04-01 ### Fixed diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index b3988794f..8c79a75dc 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -78,6 +78,7 @@ const serviceProviderMap: Record = { "opencode-go": "OPENCODE_API_KEY", "opencode-zen": "OPENCODE_API_KEY", cursor: "CURSOR_ACCESS_TOKEN", + "openai-codex": () => $pickenv("OPENAI_CODEX_OAUTH_TOKEN", "OPENAI_CODEX_API_KEY"), "azure-openai-responses": "AZURE_OPENAI_API_KEY", exa: "EXA_API_KEY", jina: "JINA_API_KEY", From 4d641a52b497d9eeb1dee84910753ae8ed5f9167 Mon Sep 17 00:00:00 2001 From: Patrik Sundberg Date: Sat, 21 Mar 2026 22:55:21 +0000 Subject: [PATCH 05/14] feat(coding-agent): add session observer (Ctrl+S) to view subagent sessions Wire EventBus through task runtime so subagent lifecycle and progress events propagate to the TUI. Add SessionObserverRegistry to track active sessions, and a session observer overlay accessible via Ctrl+S that shows a picker of running subagents and a read-only transcript viewer that reads the subagent's session JSONL file to display thinking, text, tool calls, and results. - Add TASK_SUBAGENT_LIFECYCLE_CHANNEL for start/end events - Add SubagentProgressPayload.sessionFile for session file tracking - Pass eventBus through sdk.ts -> main.ts -> InteractiveMode - SessionObserverRegistry with multi-listener onChange pattern - Overlay picker preserves selection position across live refreshes - Viewer renders full transcript from session JSONL (thinking, text, tool calls with smart arg summaries, inline tool results) --- .../coding-agent/src/config/keybindings.ts | 13 +- packages/coding-agent/src/main.ts | 15 +- .../components/session-observer-overlay.ts | 428 ++++++++++++++++++ .../src/modes/controllers/input-controller.ts | 3 + .../modes/controllers/selector-controller.ts | 26 ++ .../src/modes/interactive-mode.ts | 27 ++ .../src/modes/session-observer-registry.ts | 140 ++++++ packages/coding-agent/src/modes/types.ts | 1 + packages/coding-agent/src/sdk.ts | 3 + packages/coding-agent/src/task/executor.ts | 29 ++ packages/coding-agent/src/task/index.ts | 21 +- packages/coding-agent/src/task/types.ts | 25 + .../task/executor-subagent-reminders.test.ts | 2 + 13 files changed, 721 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/src/modes/components/session-observer-overlay.ts create mode 100644 packages/coding-agent/src/modes/session-observer-registry.ts diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index c7b92e832..3b80149c4 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -37,6 +37,7 @@ interface AppKeybindings { "app.session.tree": true; "app.session.fork": true; "app.session.resume": true; + "app.session.observe": true; "app.session.togglePath": true; "app.session.toggleSort": true; "app.session.rename": true; @@ -144,6 +145,10 @@ export const KEYBINDINGS = { defaultKeys: [], description: "Resume session", }, + "app.session.observe": { + defaultKeys: "ctrl+s", + description: "Observe subagent sessions", + }, "app.session.togglePath": { defaultKeys: "ctrl+p", description: "Toggle session path display", @@ -214,6 +219,7 @@ const KEYBINDING_NAME_MIGRATIONS = { tree: "app.session.tree", fork: "app.session.fork", resume: "app.session.resume", + observeSessions: "app.session.observe", toggleSTT: "app.stt.toggle", // TUI editor (old names for backward compatibility) cursorUp: "tui.editor.cursorUp", @@ -260,9 +266,6 @@ function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAM return key in KEYBINDING_NAME_MIGRATIONS; } -/** - * Normalize input to KeybindingsConfig, validating types. - */ function toKeybindingsConfig(value: unknown): KeybindingsConfig { if (typeof value !== "object" || value === null) { return {}; @@ -270,15 +273,13 @@ function toKeybindingsConfig(value: unknown): KeybindingsConfig { const config: KeybindingsConfig = {}; for (const [key, val] of Object.entries(value)) { - // Allow undefined, string (KeyId), or array of strings if (val === undefined) { config[key] = undefined; } else if (typeof val === "string") { config[key] = val as KeyId; } else if (Array.isArray(val) && val.every(v => typeof v === "string")) { - config[key] = val as string[] as KeyId[]; + config[key] = val as KeyId[]; } - // Silently skip invalid entries } return config; } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index fa15b2d6f..c344e9d2c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -48,6 +48,7 @@ import type { AgentSession } from "./session/agent-session"; import { resolveResumableSession, type SessionInfo, SessionManager } from "./session/session-manager"; import { resolvePromptInput } from "./system-prompt"; import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog"; +import type { EventBus } from "./utils/event-bus"; async function checkForNewVersion(currentVersion: string): Promise { if (!settings.get("startup.checkUpdate")) { @@ -119,10 +120,19 @@ async function runInteractiveMode( setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void, lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }> | undefined, mcpManager: MCPManager | undefined, + eventBus?: EventBus, initialMessage?: string, initialImages?: ImageContent[], ): Promise { - const mode = new InteractiveMode(session, version, changelogMarkdown, setExtensionUIContext, lspServers, mcpManager); + const mode = new InteractiveMode( + session, + version, + changelogMarkdown, + setExtensionUIContext, + lspServers, + mcpManager, + eventBus, + ); await mode.init(); @@ -718,7 +728,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise createAgentSession(sessionOptions), ); @@ -806,6 +816,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise viewer + * - shortcut while in viewer -> back to picker + * - Esc from viewer -> back to picker + * - Esc from picker -> close overlay + * - Enter on main session -> close overlay (jump back) + */ +import type { AssistantMessage, ToolResultMessage } from "@oh-my-pi/pi-ai"; +import { Container, Markdown, matchesKey, type SelectItem, SelectList, Spacer, Text } from "@oh-my-pi/pi-tui"; +import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils"; +import type { KeyId } from "../../config/keybindings"; +import type { FileEntry, SessionMessageEntry } from "../../session/session-manager"; +import { parseSessionEntries } from "../../session/session-manager"; +import { replaceTabs, shortenPath, truncateToWidth } from "../../tools/render-utils"; +import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry"; +import { getMarkdownTheme, getSelectListTheme, theme } from "../theme/theme"; +import { DynamicBorder } from "./dynamic-border"; + +type Mode = "picker" | "viewer"; + +/** Max thinking characters to show (long thinking can be huge) */ +const MAX_THINKING_CHARS = 600; +/** Max tool args characters to display */ +const MAX_TOOL_ARGS_CHARS = 200; +/** Max tool result text to display */ +const MAX_TOOL_RESULT_CHARS = 300; + +export class SessionObserverOverlayComponent extends Container { + #registry: SessionObserverRegistry; + #onDone: () => void; + #mode: Mode = "picker"; + #selectList: SelectList; + #viewerContainer: Container; + #selectedSessionId?: string; + #observeKeys: KeyId[]; + + constructor(registry: SessionObserverRegistry, onDone: () => void, observeKeys: KeyId[]) { + super(); + this.#registry = registry; + this.#onDone = onDone; + this.#observeKeys = observeKeys; + this.#selectList = new SelectList([], 0, getSelectListTheme()); + this.#viewerContainer = new Container(); + + this.#setupPicker(); + } + + #setupPicker(): void { + this.#mode = "picker"; + this.children = []; + + this.addChild(new DynamicBorder()); + this.addChild(new Text(theme.bold(theme.fg("accent", "Session Observer")), 1, 0)); + this.addChild(new Spacer(1)); + + const items = this.#buildPickerItems(); + this.#selectList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme()); + + this.#selectList.onSelect = item => { + if (item.value === "main") { + this.#onDone(); + return; + } + this.#selectedSessionId = item.value; + this.#setupViewer(); + }; + + this.#selectList.onCancel = () => { + this.#onDone(); + }; + + this.addChild(this.#selectList); + this.addChild(new DynamicBorder()); + } + + #setupViewer(): void { + this.#mode = "viewer"; + this.children = []; + this.#viewerContainer = new Container(); + this.#refreshViewer(); + + this.addChild(new DynamicBorder()); + this.addChild(this.#viewerContainer); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", "Esc: back to picker | Ctrl+S: back to picker"), 1, 0)); + this.addChild(new DynamicBorder()); + } + + /** Rebuild content from live registry data */ + refreshFromRegistry(): void { + if (this.#mode === "picker") { + this.#refreshPickerItems(); + } else if (this.#mode === "viewer" && this.#selectedSessionId) { + this.#refreshViewer(); + } + } + + #refreshPickerItems(): void { + // Preserve selection across refresh by matching on value + const previousValue = this.#selectList.getSelectedItem()?.value; + + const items = this.#buildPickerItems(); + const newList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme()); + newList.onSelect = this.#selectList.onSelect; + newList.onCancel = this.#selectList.onCancel; + + if (previousValue) { + const newIndex = items.findIndex(i => i.value === previousValue); + if (newIndex >= 0) newList.setSelectedIndex(newIndex); + } + + const idx = this.children.indexOf(this.#selectList); + if (idx >= 0) { + this.children[idx] = newList; + } + this.#selectList = newList; + } + + #refreshViewer(): void { + this.#viewerContainer.clear(); + + const sessions = this.#registry.getSessions(); + const session = sessions.find(s => s.id === this.#selectedSessionId); + if (!session) { + this.#viewerContainer.addChild(new Text(theme.fg("dim", "Session no longer available."), 1, 0)); + return; + } + + this.#renderSessionHeader(session); + this.#renderSessionTranscript(session); + } + + #renderSessionHeader(session: ObservableSession): void { + const c = this.#viewerContainer; + const progress = session.progress; + + // Header: label + status + [agent] + const statusColor = session.status === "active" ? "success" : session.status === "failed" ? "error" : "dim"; + const statusText = theme.fg(statusColor, session.status); + const agentTag = session.agent ? theme.fg("dim", ` [${session.agent}]`) : ""; + c.addChild(new Text(`${theme.bold(theme.fg("accent", session.label))} ${statusText}${agentTag}`, 1, 0)); + + if (session.description) { + c.addChild(new Text(theme.fg("muted", session.description), 1, 0)); + } + + // Stats from progress + if (progress) { + const stats: string[] = []; + if (progress.toolCount > 0) stats.push(`${formatNumber(progress.toolCount)} tools`); + if (progress.tokens > 0) stats.push(`${formatNumber(progress.tokens)} tokens`); + if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs)); + if (stats.length > 0) { + c.addChild(new Text(theme.fg("dim", stats.join(theme.sep.dot)), 1, 0)); + } + } + + if (session.sessionFile) { + c.addChild(new Text(theme.fg("dim", `Session: ${shortenPath(session.sessionFile)}`), 1, 0)); + } + + c.addChild(new DynamicBorder()); + } + + #renderSessionTranscript(session: ObservableSession): void { + const c = this.#viewerContainer; + + if (!session.sessionFile) { + c.addChild(new Text(theme.fg("dim", "No session file available yet."), 1, 0)); + return; + } + + let entries: FileEntry[]; + try { + const _file = Bun.file(session.sessionFile); + // Use synchronous read since we're in a render path + const text = readFileSync(session.sessionFile); + if (!text) { + c.addChild(new Text(theme.fg("dim", "Session file is empty."), 1, 0)); + return; + } + entries = parseSessionEntries(text); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.debug("Session observer: failed to read session file", { path: session.sessionFile, error: msg }); + c.addChild(new Text(theme.fg("dim", "Unable to read session file."), 1, 0)); + return; + } + + // Filter to message entries and render them + const messageEntries = entries.filter((e): e is SessionMessageEntry => e.type === "message"); + + if (messageEntries.length === 0) { + c.addChild(new Text(theme.fg("dim", "No messages yet."), 1, 0)); + return; + } + + // Build a tool call ID -> tool result map for matching + const toolResults = new Map(); + for (const entry of messageEntries) { + if (entry.message.role === "toolResult") { + toolResults.set(entry.message.toolCallId, entry.message); + } + } + + for (const entry of messageEntries) { + const msg = entry.message; + + if (msg.role === "assistant") { + this.#renderAssistantMessage(c, msg, toolResults); + } else if (msg.role === "user" || msg.role === "developer") { + // Show user/developer messages briefly + const text = + typeof msg.content === "string" + ? msg.content + : msg.content + .filter((b): b is { type: "text"; text: string } => b.type === "text") + .map(b => b.text) + .join("\n"); + if (text.trim()) { + const label = msg.role === "developer" ? "System" : "User"; + c.addChild(new Spacer(1)); + c.addChild( + new Text( + `${theme.fg("dim", `[${label}]`)} ${theme.fg("muted", truncateToWidth(text.trim(), 80))}`, + 1, + 0, + ), + ); + } + } + // toolResult entries are rendered inline with their tool calls above + } + } + + #renderAssistantMessage(c: Container, msg: AssistantMessage, toolResults: Map): void { + for (const content of msg.content) { + if (content.type === "thinking" && content.thinking.trim()) { + const thinking = content.thinking.trim(); + c.addChild(new Spacer(1)); + if (thinking.length > MAX_THINKING_CHARS) { + // Show truncated thinking as markdown for proper formatting + const truncated = `${thinking.slice(0, MAX_THINKING_CHARS)}...`; + c.addChild( + new Markdown(truncated, 1, 0, getMarkdownTheme(), { + color: (t: string) => theme.fg("thinkingText", t), + italic: true, + }), + ); + } else { + c.addChild( + new Markdown(thinking, 1, 0, getMarkdownTheme(), { + color: (t: string) => theme.fg("thinkingText", t), + italic: true, + }), + ); + } + } else if (content.type === "text" && content.text.trim()) { + c.addChild(new Spacer(1)); + c.addChild(new Markdown(content.text.trim(), 1, 0, getMarkdownTheme())); + } else if (content.type === "toolCall") { + this.#renderToolCall(c, content, toolResults); + } + } + } + + #renderToolCall( + c: Container, + call: { id: string; name: string; arguments: Record; intent?: string }, + toolResults: Map, + ): void { + c.addChild(new Spacer(1)); + + // Tool call header with intent + const intentStr = call.intent ? theme.fg("dim", ` ${truncateToWidth(call.intent, 50)}`) : ""; + c.addChild(new Text(`${theme.fg("accent", "▸")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`, 1, 0)); + + // Key arguments (skip very long ones, show summary) + const argEntries = Object.entries(call.arguments); + if (argEntries.length > 0) { + const argSummary = this.#formatToolArgs(call.name, call.arguments); + if (argSummary) { + c.addChild(new Text(` ${theme.fg("dim", argSummary)}`, 1, 0)); + } + } + + // Inline tool result + const result = toolResults.get(call.id); + if (result) { + this.#renderToolResult(c, result); + } + } + + #formatToolArgs(toolName: string, args: Record): string { + // Show the most relevant arg for common tools + switch (toolName) { + case "read": + return args.path ? `path: ${args.path}` : ""; + case "write": + return args.path ? `path: ${args.path}` : ""; + case "edit": + return args.path ? `path: ${args.path}` : ""; + case "grep": + return [args.pattern ? `pattern: ${args.pattern}` : "", args.path ? `path: ${args.path}` : ""] + .filter(Boolean) + .join(", "); + case "find": + return args.pattern ? `pattern: ${args.pattern}` : ""; + case "bash": { + const cmd = args.command; + if (typeof cmd === "string") { + return truncateToWidth(replaceTabs(cmd), 70); + } + return ""; + } + case "lsp": + return [args.action, args.file, args.symbol].filter(Boolean).join(" "); + case "ast_grep": + case "ast_edit": + return args.path ? `path: ${args.path}` : ""; + case "task": { + const tasks = args.tasks; + if (Array.isArray(tasks)) { + return `${tasks.length} task(s)`; + } + return ""; + } + default: { + // Generic: show first few args truncated + const parts: string[] = []; + let total = 0; + for (const [key, value] of Object.entries(args)) { + if (key.startsWith("_")) continue; + const v = typeof value === "string" ? value : JSON.stringify(value); + const entry = `${key}: ${truncateToWidth(replaceTabs(v ?? ""), 40)}`; + if (total + entry.length > MAX_TOOL_ARGS_CHARS) break; + parts.push(entry); + total += entry.length; + } + return parts.join(", "); + } + } + } + + #renderToolResult(c: Container, result: ToolResultMessage): void { + const textParts = result.content + .filter((p): p is { type: "text"; text: string } => p.type === "text") + .map(p => p.text); + const text = textParts.join("\n").trim(); + + if (result.isError) { + const preview = truncateToWidth(replaceTabs(text || "Error"), 70); + c.addChild(new Text(` ${theme.fg("error", `✗ ${preview}`)}`, 1, 0)); + } else if (text) { + // Show brief result preview + const lines = text.split("\n"); + if (lines.length === 1 && text.length < MAX_TOOL_RESULT_CHARS) { + c.addChild(new Text(` ${theme.fg("dim", `✓ ${truncateToWidth(replaceTabs(text), 70)}`)}`, 1, 0)); + } else { + c.addChild(new Text(` ${theme.fg("dim", `✓ ${lines.length} lines`)}`, 1, 0)); + } + } else { + c.addChild(new Text(` ${theme.fg("dim", "✓ done")}`, 1, 0)); + } + } + + #buildPickerItems(): SelectItem[] { + const sessions = this.#registry.getSessions(); + return sessions.map(s => { + const statusIcon = + s.status === "active" ? "●" : s.status === "completed" ? "✓" : s.status === "failed" ? "✗" : "○"; + const statusColor = s.status === "active" ? "success" : s.status === "failed" ? "error" : "dim"; + const prefix = theme.fg(statusColor, statusIcon); + const agentSuffix = s.agent ? theme.fg("dim", ` [${s.agent}]`) : ""; + const label = s.kind === "main" ? `${prefix} ${s.label} (return)` : `${prefix} ${s.label}${agentSuffix}`; + + // Show current activity in the picker description for subagents + let description = s.description; + if (s.progress?.currentTool) { + const intent = s.progress.lastIntent; + description = intent ? `${s.progress.currentTool}: ${truncateToWidth(intent, 40)}` : s.progress.currentTool; + } + + return { value: s.id, label, description }; + }); + } + + handleInput(keyData: string): void { + for (const key of this.#observeKeys) { + if (matchesKey(keyData, key)) { + if (this.#mode === "viewer") { + this.#setupPicker(); + return; + } + this.#onDone(); + return; + } + } + + if (this.#mode === "picker") { + this.#selectList.handleInput(keyData); + } else if (this.#mode === "viewer") { + if (matchesKey(keyData, "escape")) { + this.#setupPicker(); + return; + } + } + } +} + +// Sync read for render path — avoid async in component rendering +import * as fs from "node:fs"; + +function readFileSync(filePath: string): string { + try { + return fs.readFileSync(filePath, "utf-8"); + } catch { + return ""; + } +} diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index ed66f0a51..1ddae2c4f 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -158,6 +158,9 @@ export class InputController { for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) { this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine()); } + for (const key of this.ctx.keybindings.getKeys("app.session.observe")) { + this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionObserver()); + } this.ctx.editor.onChange = (text: string) => { const wasBashMode = this.ctx.isBashMode; diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index bdfb777ba..aac5e9256 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -39,11 +39,13 @@ import { HistorySearchComponent } from "../components/history-search"; import { ModelSelectorComponent } from "../components/model-selector"; import { OAuthSelectorComponent } from "../components/oauth-selector"; import { PluginSelectorComponent } from "../components/plugin-selector"; +import { SessionObserverOverlayComponent } from "../components/session-observer-overlay"; import { SessionSelectorComponent } from "../components/session-selector"; import { SettingsSelectorComponent } from "../components/settings-selector"; import { ToolExecutionComponent } from "../components/tool-execution"; import { TreeSelectorComponent } from "../components/tree-selector"; import { UserMessageSelectorComponent } from "../components/user-message-selector"; +import type { SessionObserverRegistry } from "../session-observer-registry"; const CALLBACK_SERVER_PROVIDERS = new Set([ "anthropic", @@ -962,4 +964,28 @@ export class SelectorController { return { component: selector, focus: selector }; }); } + + showSessionObserver(registry: SessionObserverRegistry): void { + const observeKeys = this.ctx.keybindings.getKeys("app.session.observe"); + + this.showSelector(done => { + let cleanup: (() => void) | undefined; + + const selector = new SessionObserverOverlayComponent( + registry, + () => { + cleanup?.(); + done(); + }, + observeKeys, + ); + + cleanup = registry.onChange(() => { + selector.refreshFromRegistry(); + this.ctx.ui.requestRender(); + }); + + return { component: selector, focus: selector }; + }); + } } diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index ea9e5dbb7..6fece6a20 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -31,6 +31,7 @@ import { getRecentSessions } from "../session/session-manager"; import { STTController, type SttState } from "../stt"; import type { ExitPlanModeDetails } from "../tools"; import { getEditorCommand, openInEditor } from "../utils/external-editor"; +import type { EventBus } from "../utils/event-bus"; import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator"; import type { AssistantMessageComponent } from "./components/assistant-message"; import type { BashExecutionComponent } from "./components/bash-execution"; @@ -52,6 +53,7 @@ import { MCPCommandController } from "./controllers/mcp-command-controller"; import { SelectorController } from "./controllers/selector-controller"; import { SSHCommandController } from "./controllers/ssh-command-controller"; import { OAuthManualInputManager } from "./oauth-manual-input"; +import { SessionObserverRegistry } from "./session-observer-registry"; import { setMermaidRenderCallback } from "./theme/mermaid-cache"; import type { Theme } from "./theme/theme"; import { @@ -176,6 +178,8 @@ export class InteractiveMode implements InteractiveModeContext { #voicePreviousShowHardwareCursor: boolean | null = null; #voicePreviousUseTerminalCursor: boolean | null = null; #resizeHandler?: () => void; + #observerRegistry: SessionObserverRegistry; + #eventBus?: EventBus; constructor( session: AgentSession, @@ -186,6 +190,7 @@ export class InteractiveMode implements InteractiveModeContext { | Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }> | undefined = undefined, mcpManager?: import("../mcp").MCPManager, + eventBus?: EventBus, ) { this.session = session; this.sessionManager = session.sessionManager; @@ -197,6 +202,7 @@ export class InteractiveMode implements InteractiveModeContext { this.#toolUiContextSetter = setToolUIContext; this.lspServers = lspServers; this.mcpManager = mcpManager; + this.#eventBus = eventBus; this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor")); this.ui.setClearOnShrink(settings.get("clearOnShrink")); @@ -271,6 +277,7 @@ export class InteractiveMode implements InteractiveModeContext { this.#commandController = new CommandController(this); this.#selectorController = new SelectorController(this); this.#inputController = new InputController(this); + this.#observerRegistry = new SessionObserverRegistry(); } async init(): Promise { @@ -355,6 +362,16 @@ export class InteractiveMode implements InteractiveModeContext { this.#inputController.setupKeyHandlers(); this.#inputController.setupEditorSubmitHandler(); + // Wire observer registry to EventBus + if (this.#eventBus) { + this.#observerRegistry.subscribeToEventBus(this.#eventBus); + } + this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined); + this.#observerRegistry.onChange(() => { + this.statusLine.setSubagentCount(this.#observerRegistry.getActiveSubagentCount()); + this.ui.requestRender(); + }); + // Load initial todos await this.#loadTodoList(); @@ -947,6 +964,7 @@ export class InteractiveMode implements InteractiveModeContext { } this.#extensionUiController.clearExtensionTerminalInputListeners(); this.#extensionUiController.clearHookWidgets(); + this.#observerRegistry.dispose(); this.statusLine.dispose(); if (this.#resizeHandler) { process.stdout.removeListener("resize", this.#resizeHandler); @@ -1286,6 +1304,15 @@ export class InteractiveMode implements InteractiveModeContext { this.#selectorController.showDebugSelector(); } + showSessionObserver(): void { + const sessions = this.#observerRegistry.getSessions(); + if (sessions.length <= 1) { + this.showStatus("No active subagent sessions"); + return; + } + this.#selectorController.showSessionObserver(this.#observerRegistry); + } + handleBashCommand(command: string, excludeFromContext?: boolean): Promise { return this.#commandController.handleBashCommand(command, excludeFromContext); } diff --git a/packages/coding-agent/src/modes/session-observer-registry.ts b/packages/coding-agent/src/modes/session-observer-registry.ts new file mode 100644 index 000000000..8bb8c9ba3 --- /dev/null +++ b/packages/coding-agent/src/modes/session-observer-registry.ts @@ -0,0 +1,140 @@ +import type { AgentProgress, SubagentLifecyclePayload, SubagentProgressPayload } from "../task"; +import { TASK_SUBAGENT_LIFECYCLE_CHANNEL, TASK_SUBAGENT_PROGRESS_CHANNEL } from "../task"; +import type { EventBus } from "../utils/event-bus"; + +export interface ObservableSession { + id: string; + kind: "main" | "subagent"; + label: string; + agent?: string; + description?: string; + status: "active" | "completed" | "failed" | "aborted"; + sessionFile?: string; + lastUpdate: number; + /** Latest progress snapshot from the subagent executor */ + progress?: AgentProgress; +} + +const STATUS_MAP: Record = { + started: "active", + completed: "completed", + failed: "failed", + aborted: "aborted", +}; + +export class SessionObserverRegistry { + #sessions = new Map(); + #listeners = new Set<() => void>(); + #eventBusUnsubscribers: Array<() => void> = []; + + /** Add a change listener. Returns unsubscribe function. */ + onChange(cb: () => void): () => void { + this.#listeners.add(cb); + return () => this.#listeners.delete(cb); + } + + #notifyListeners(): void { + for (const cb of this.#listeners) cb(); + } + + setMainSession(sessionFile?: string): void { + const existing = this.#sessions.get("main"); + this.#sessions.set("main", { + id: "main", + kind: "main", + label: "Main Session", + status: "active", + sessionFile: sessionFile ?? existing?.sessionFile, + lastUpdate: Date.now(), + }); + this.#notifyListeners(); + } + + getSessions(): ObservableSession[] { + const sessions = [...this.#sessions.values()]; + sessions.sort((a, b) => { + if (a.kind === "main") return -1; + if (b.kind === "main") return 1; + return a.lastUpdate - b.lastUpdate; + }); + return sessions; + } + + getActiveSubagentCount(): number { + let count = 0; + for (const s of this.#sessions.values()) { + if (s.kind === "subagent" && s.status === "active") count++; + } + return count; + } + + dispose(): void { + for (const unsub of this.#eventBusUnsubscribers) unsub(); + this.#eventBusUnsubscribers = []; + this.#sessions.clear(); + this.#listeners.clear(); + } + + subscribeToEventBus(eventBus: EventBus): void { + // Dispose previous EventBus subscriptions if called again + for (const unsub of this.#eventBusUnsubscribers) unsub(); + this.#eventBusUnsubscribers = []; + + this.#eventBusUnsubscribers.push( + eventBus.on(TASK_SUBAGENT_LIFECYCLE_CHANNEL, data => { + const payload = data as SubagentLifecyclePayload; + const status = STATUS_MAP[payload.status]; + if (!status) return; + + const existing = this.#sessions.get(payload.id); + if (existing) { + existing.status = status; + existing.lastUpdate = Date.now(); + if (payload.description) existing.description = payload.description; + if (payload.sessionFile) existing.sessionFile = payload.sessionFile; + } else { + this.#sessions.set(payload.id, { + id: payload.id, + kind: "subagent", + label: payload.description ?? `Subagent #${payload.index}`, + agent: payload.agent, + description: payload.description, + status, + sessionFile: payload.sessionFile, + lastUpdate: Date.now(), + }); + } + this.#notifyListeners(); + }), + ); + + this.#eventBusUnsubscribers.push( + eventBus.on(TASK_SUBAGENT_PROGRESS_CHANNEL, data => { + const payload = data as SubagentProgressPayload; + const progress = payload.progress; + const id = progress.id; + const existing = this.#sessions.get(id); + + if (existing) { + existing.lastUpdate = Date.now(); + existing.progress = progress; + if (progress.description) existing.description = progress.description; + if (payload.sessionFile) existing.sessionFile = payload.sessionFile; + } else { + this.#sessions.set(id, { + id, + kind: "subagent", + label: progress.description ?? `Subagent #${payload.index}`, + agent: payload.agent, + description: progress.description, + status: "active", + sessionFile: payload.sessionFile, + lastUpdate: Date.now(), + progress, + }); + } + this.#notifyListeners(); + }), + ); + } +} diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 2db98baae..f31f45ad6 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -208,6 +208,7 @@ export interface InteractiveModeContext { showOAuthSelector(mode: "login" | "logout", providerId?: string): Promise; showHookConfirm(title: string, message: string): Promise; showDebugSelector(): void; + showSessionObserver(): void; // Input handling handleCtrlC(): void; diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 4c35a1a5d..69371bfc0 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -229,6 +229,8 @@ export interface CreateAgentSessionResult { modelFallbackMessage?: string; /** LSP servers that were warmed up at startup */ lspServers?: Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }>; + /** Shared event bus for tool/extension communication */ + eventBus: EventBus; } // Re-exports @@ -1639,5 +1641,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} mcpManager, modelFallbackMessage, lspServers, + eventBus, }; } diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 24a299893..fa357eeca 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -38,6 +38,7 @@ import { type ReviewFinding, type SingleResult, TASK_SUBAGENT_EVENT_CHANNEL, + TASK_SUBAGENT_LIFECYCLE_CHANNEL, TASK_SUBAGENT_PROGRESS_CHANNEL, } from "./types"; @@ -630,6 +631,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise !parentOwnedToolNames.has(name)); @@ -1175,6 +1190,20 @@ export async function runSubprocess(options: ExecutorOptions): Promise { contextFile: contextFilePath, enableLsp: false, signal, - eventBus: undefined, + eventBus: this.session.eventBus, onProgress: progress => { progressMap.set(index, { ...structuredClone(progress), @@ -820,7 +833,7 @@ export class TaskTool implements AgentTool { contextFile: contextFilePath, enableLsp: false, signal, - eventBus: undefined, + eventBus: this.session.eventBus, onProgress: progress => { progressMap.set(index, { ...structuredClone(progress), diff --git a/packages/coding-agent/src/task/types.ts b/packages/coding-agent/src/task/types.ts index 2ed4458f9..df3aa2333 100644 --- a/packages/coding-agent/src/task/types.ts +++ b/packages/coding-agent/src/task/types.ts @@ -31,6 +31,31 @@ export const TASK_SUBAGENT_EVENT_CHANNEL = "task:subagent:event"; /** EventBus channel for aggregated subagent progress */ export const TASK_SUBAGENT_PROGRESS_CHANNEL = "task:subagent:progress"; +/** EventBus channel for subagent lifecycle (start/end) */ +export const TASK_SUBAGENT_LIFECYCLE_CHANNEL = "task:subagent:lifecycle"; + +/** Payload emitted on TASK_SUBAGENT_PROGRESS_CHANNEL */ +export interface SubagentProgressPayload { + index: number; + agent: string; + agentSource: AgentSource; + task: string; + assignment?: string; + progress: AgentProgress; + sessionFile?: string; +} + +/** Payload emitted on TASK_SUBAGENT_LIFECYCLE_CHANNEL */ +export interface SubagentLifecyclePayload { + id: string; + agent: string; + agentSource: AgentSource; + description?: string; + status: "started" | "completed" | "failed" | "aborted"; + sessionFile?: string; + index: number; +} + /** Single task item for parallel execution */ export const taskItemSchema = Type.Object({ id: Type.String({ diff --git a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts index 41565290b..90a049801 100644 --- a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts +++ b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts @@ -8,6 +8,7 @@ import type { AgentSession, AgentSessionEvent, PromptOptions } from "../../src/s import type { AuthStorage } from "../../src/session/auth-storage"; import { runSubprocess, SUBAGENT_WARNING_MISSING_SUBMIT_RESULT } from "../../src/task/executor"; import type { AgentDefinition } from "../../src/task/types"; +import { EventBus } from "../../src/utils/event-bus"; function createAssistantStopMessage(text: string): AssistantMessage { return { @@ -81,6 +82,7 @@ function createSessionResult(session: AgentSession): CreateAgentSessionResult { session, extensionsResult: {} as unknown as LoadExtensionsResult, setToolUIContext: () => {}, + eventBus: new EventBus(), }; } From 06ed77081da35bdc44ddaab9d4392ae9e9b19d46 Mon Sep 17 00:00:00 2001 From: Patrik Sundberg Date: Sat, 21 Mar 2026 22:58:41 +0000 Subject: [PATCH 06/14] docs(coding-agent): add session observer to changelog --- packages/coding-agent/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8daef5ebd..ddaea7634 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -401,6 +401,9 @@ - Fixed resumed and session-switched GitHub Copilot/OpenAI Responses conversations replaying stale assistant native history from older saved sessions by sanitizing persisted assistant replay metadata on rehydration and resetting provider session state across live session boundaries ([#505](https://github.com/can1357/oh-my-pi/issues/505)) +### Added + +- Session observer overlay (`Ctrl+S`): view running subagent sessions with a picker and read-only transcript showing thinking, text, tool calls, and results ## [13.14.0] - 2026-03-20 ### Added From 94d3636bc55a3d888f3e287d876666f8d1b8908c Mon Sep 17 00:00:00 2001 From: Patrik Sundberg Date: Sat, 21 Mar 2026 23:21:24 +0000 Subject: [PATCH 07/14] fix(coding-agent): address session observer PR review feedback - Move lifecycle end event after finalizeSubprocessOutput() so submit_result({status:'aborted'}) is correctly reflected in the observer instead of showing completed/failed (P1) - Reset observer registry on session resume and /new command so stale subagent sessions from prior conversations are cleared (P2) - Cache parsed JSONL transcript incrementally: track byte offset, only read and parse new bytes on each refresh instead of reparsing the entire file, avoiding render loop blocking on large sessions (P2) --- .../components/session-observer-overlay.ts | 75 +++++++++++++------ .../modes/controllers/command-controller.ts | 1 + .../src/modes/interactive-mode.ts | 6 ++ .../src/modes/session-observer-registry.ts | 6 ++ packages/coding-agent/src/modes/types.ts | 1 + packages/coding-agent/src/task/executor.ts | 27 ++++--- 6 files changed, 80 insertions(+), 36 deletions(-) diff --git a/packages/coding-agent/src/modes/components/session-observer-overlay.ts b/packages/coding-agent/src/modes/components/session-observer-overlay.ts index 9a91a18a0..c1e022df7 100644 --- a/packages/coding-agent/src/modes/components/session-observer-overlay.ts +++ b/packages/coding-agent/src/modes/components/session-observer-overlay.ts @@ -17,7 +17,7 @@ import type { AssistantMessage, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { Container, Markdown, matchesKey, type SelectItem, SelectList, Spacer, Text } from "@oh-my-pi/pi-tui"; import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils"; import type { KeyId } from "../../config/keybindings"; -import type { FileEntry, SessionMessageEntry } from "../../session/session-manager"; +import type { SessionMessageEntry } from "../../session/session-manager"; import { parseSessionEntries } from "../../session/session-manager"; import { replaceTabs, shortenPath, truncateToWidth } from "../../tools/render-utils"; import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry"; @@ -41,6 +41,8 @@ export class SessionObserverOverlayComponent extends Container { #viewerContainer: Container; #selectedSessionId?: string; #observeKeys: KeyId[]; + /** Cached parsed transcript per session file to avoid reparsing on every refresh */ + #transcriptCache?: { path: string; bytesRead: number; entries: SessionMessageEntry[] }; constructor(registry: SessionObserverRegistry, onDone: () => void, observeKeys: KeyId[]) { super(); @@ -170,6 +172,37 @@ export class SessionObserverOverlayComponent extends Container { c.addChild(new DynamicBorder()); } + /** Incrementally read and parse the session JSONL, caching already-parsed entries. */ + #loadTranscript(sessionFile: string): SessionMessageEntry[] | null { + // Invalidate cache if session file changed (e.g. switched to different subagent) + if (this.#transcriptCache && this.#transcriptCache.path !== sessionFile) { + this.#transcriptCache = undefined; + } + + const fromByte = this.#transcriptCache?.bytesRead ?? 0; + const result = readFileIncremental(sessionFile, fromByte); + if (!result) { + logger.debug("Session observer: failed to read session file", { path: sessionFile }); + return this.#transcriptCache?.entries ?? null; + } + + if (!this.#transcriptCache) { + this.#transcriptCache = { path: sessionFile, bytesRead: 0, entries: [] }; + } + + // Parse only new bytes + if (result.text.length > 0) { + const newEntries = parseSessionEntries(result.text); + for (const entry of newEntries) { + if (entry.type === "message") { + this.#transcriptCache.entries.push(entry as SessionMessageEntry); + } + } + } + this.#transcriptCache.bytesRead = result.newSize; + return this.#transcriptCache.entries; + } + #renderSessionTranscript(session: ObservableSession): void { const c = this.#viewerContainer; @@ -178,26 +211,11 @@ export class SessionObserverOverlayComponent extends Container { return; } - let entries: FileEntry[]; - try { - const _file = Bun.file(session.sessionFile); - // Use synchronous read since we're in a render path - const text = readFileSync(session.sessionFile); - if (!text) { - c.addChild(new Text(theme.fg("dim", "Session file is empty."), 1, 0)); - return; - } - entries = parseSessionEntries(text); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logger.debug("Session observer: failed to read session file", { path: session.sessionFile, error: msg }); + const messageEntries = this.#loadTranscript(session.sessionFile); + if (!messageEntries) { c.addChild(new Text(theme.fg("dim", "Unable to read session file."), 1, 0)); return; } - - // Filter to message entries and render them - const messageEntries = entries.filter((e): e is SessionMessageEntry => e.type === "message"); - if (messageEntries.length === 0) { c.addChild(new Text(theme.fg("dim", "No messages yet."), 1, 0)); return; @@ -416,13 +434,26 @@ export class SessionObserverOverlayComponent extends Container { } } -// Sync read for render path — avoid async in component rendering +// Sync helpers for render path — avoid async in component rendering import * as fs from "node:fs"; -function readFileSync(filePath: string): string { +/** + * Read new bytes from a file starting at the given byte offset. + * Returns the new text and updated file size, or null on error. + */ +function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null { try { - return fs.readFileSync(filePath, "utf-8"); + const stat = fs.statSync(filePath); + if (stat.size <= fromByte) return { text: "", newSize: stat.size }; + const buf = Buffer.alloc(stat.size - fromByte); + const fd = fs.openSync(filePath, "r"); + try { + fs.readSync(fd, buf, 0, buf.length, fromByte); + } finally { + fs.closeSync(fd); + } + return { text: buf.toString("utf-8"), newSize: stat.size }; } catch { - return ""; + return null; } } diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index f00be96ba..b60a5ca37 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -586,6 +586,7 @@ export class CommandController { } } await this.ctx.session.newSession(); + this.ctx.resetObserverRegistry(); setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd()); this.ctx.statusLine.invalidate(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 6fece6a20..d6c66d29b 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -1313,6 +1313,11 @@ export class InteractiveMode implements InteractiveModeContext { this.#selectorController.showSessionObserver(this.#observerRegistry); } + resetObserverRegistry(): void { + this.#observerRegistry.resetSessions(); + this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined); + } + handleBashCommand(command: string, excludeFromContext?: boolean): Promise { return this.#commandController.handleBashCommand(command, excludeFromContext); } @@ -1386,6 +1391,7 @@ export class InteractiveMode implements InteractiveModeContext { handleResumeSession(sessionPath: string): Promise { this.#btwController.dispose(); + this.resetObserverRegistry(); return this.#selectorController.handleResumeSession(sessionPath); } diff --git a/packages/coding-agent/src/modes/session-observer-registry.ts b/packages/coding-agent/src/modes/session-observer-registry.ts index 8bb8c9ba3..c1c6bea6f 100644 --- a/packages/coding-agent/src/modes/session-observer-registry.ts +++ b/packages/coding-agent/src/modes/session-observer-registry.ts @@ -68,6 +68,12 @@ export class SessionObserverRegistry { return count; } + /** Clear all tracked sessions (e.g. on session switch). Keeps EventBus subscriptions and listeners. */ + resetSessions(): void { + this.#sessions.clear(); + this.#notifyListeners(); + } + dispose(): void { for (const unsub of this.#eventBusUnsubscribers) unsub(); this.#eventBusUnsubscribers = []; diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index f31f45ad6..d98052af2 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -209,6 +209,7 @@ export interface InteractiveModeContext { showHookConfirm(title: string, message: string): Promise; showDebugSelector(): void; showSessionObserver(): void; + resetObserverRegistry(): void; // Input handling handleCtrlC(): void; diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index fa357eeca..2fb6f562d 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -1190,20 +1190,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise Date: Sun, 22 Mar 2026 07:28:59 +0000 Subject: [PATCH 08/14] fix(coding-agent): preserve partial JSONL records in session observer Only advance bytesRead to the last complete newline-terminated line, not to EOF. A partial trailing record (read during mid-write) stays unconsumed and gets re-read on the next refresh, preventing permanent entry loss from the transcript. --- .../components/session-observer-overlay.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/modes/components/session-observer-overlay.ts b/packages/coding-agent/src/modes/components/session-observer-overlay.ts index c1e022df7..196815b1e 100644 --- a/packages/coding-agent/src/modes/components/session-observer-overlay.ts +++ b/packages/coding-agent/src/modes/components/session-observer-overlay.ts @@ -190,16 +190,23 @@ export class SessionObserverOverlayComponent extends Container { this.#transcriptCache = { path: sessionFile, bytesRead: 0, entries: [] }; } - // Parse only new bytes + // Parse only new bytes, but only up to the last complete line. + // A partial trailing record (mid-write) must not be consumed — + // we leave those bytes for the next refresh. if (result.text.length > 0) { - const newEntries = parseSessionEntries(result.text); - for (const entry of newEntries) { - if (entry.type === "message") { - this.#transcriptCache.entries.push(entry as SessionMessageEntry); + const lastNewline = result.text.lastIndexOf("\n"); + if (lastNewline >= 0) { + const completeChunk = result.text.slice(0, lastNewline + 1); + const newEntries = parseSessionEntries(completeChunk); + for (const entry of newEntries) { + if (entry.type === "message") { + this.#transcriptCache.entries.push(entry as SessionMessageEntry); + } } + this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8"); } + // If no newline found, the entire chunk is partial — leave bytesRead unchanged } - this.#transcriptCache.bytesRead = result.newSize; return this.#transcriptCache.entries; } From 9ac969f0a0318202daaf2616fdf05b64d4e5c1da Mon Sep 17 00:00:00 2001 From: Patrik Sundberg Date: Sun, 22 Mar 2026 07:40:30 +0000 Subject: [PATCH 09/14] fix(coding-agent): invalidate transcript cache when session file shrinks Compaction and tool-output pruning can rewrite the session JSONL in place, shrinking the file. When stat.size < bytesRead, the cache is now invalidated and the file is re-read from scratch, preventing the observer from getting stuck on a stale transcript. --- .../src/modes/components/session-observer-overlay.ts | 6 ++++++ packages/coding-agent/src/modes/interactive-mode.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/components/session-observer-overlay.ts b/packages/coding-agent/src/modes/components/session-observer-overlay.ts index 196815b1e..800c13654 100644 --- a/packages/coding-agent/src/modes/components/session-observer-overlay.ts +++ b/packages/coding-agent/src/modes/components/session-observer-overlay.ts @@ -186,6 +186,12 @@ export class SessionObserverOverlayComponent extends Container { return this.#transcriptCache?.entries ?? null; } + // File shrank (compaction or pruning rewrote it) — invalidate and re-read from scratch + if (result.newSize < fromByte) { + this.#transcriptCache = undefined; + return this.#loadTranscript(sessionFile); + } + if (!this.#transcriptCache) { this.#transcriptCache = { path: sessionFile, bytesRead: 0, entries: [] }; } diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index d6c66d29b..f9b652133 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -30,8 +30,8 @@ import type { SessionContext, SessionManager } from "../session/session-manager" import { getRecentSessions } from "../session/session-manager"; import { STTController, type SttState } from "../stt"; import type { ExitPlanModeDetails } from "../tools"; -import { getEditorCommand, openInEditor } from "../utils/external-editor"; import type { EventBus } from "../utils/event-bus"; +import { getEditorCommand, openInEditor } from "../utils/external-editor"; import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator"; import type { AssistantMessageComponent } from "./components/assistant-message"; import type { BashExecutionComponent } from "./components/bash-execution"; From 85d0d366c550a439dbe501ae71c468194b424de8 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 4 Apr 2026 16:08:48 +0200 Subject: [PATCH 10/14] fix(coding-agent): resolved PR checkout worktree paths to canonical form - Fixed PR checkout tool to resolve worktree paths to canonical form using fs.realpath(). - Refactored test mocks to use vi.spyOn for git module functions instead of inline implementations. - Updated test setup to pass Settings.isolated() with edit.manageImports enabled to EditTool. - Added afterEach hooks across test suites to restore mocks after each test execution. --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/src/tools/gh.ts | 5 +- .../test/autoresearch-state.test.ts | 54 ++++++++++++++++++- .../test/autoresearch-tools.test.ts | 19 ++++--- .../custom-commands/ci-green.test.ts | 19 ++++--- .../test/patch/imports/integration.test.ts | 2 +- 6 files changed, 81 insertions(+), 20 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ddaea7634..d8c3b28a9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Added - Added `edit.manageImports` setting to enable/disable post-edit import and include management @@ -46,6 +47,7 @@ ### Fixed +- Fixed PR checkout tool to resolve symlinks in worktree paths, ensuring consistent path references in results and metadata - Fixed `read` output for file-backed internal URLs like `local://...` to include hashline prefixes in hashline edit mode, preserving usable line refs for follow-up edits - Fixed the plan review selector to support the external editor shortcut for opening and updating the current plan from the approval screen diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index 2eacb701d..16bd45ca2 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -2177,12 +2177,13 @@ export class GhPrCheckoutTool implements AgentTool { + vi.restoreAllMocks(); +}); function makeTempDir(): string { const dir = path.join(os.tmpdir(), `pi-autoresearch-test-${Snowflake.next()}`); fs.mkdirSync(dir, { recursive: true }); @@ -326,6 +330,54 @@ function createAutoresearchCommandHarness( let command: RegisteredCommand | undefined; const inputQueue = typeof inputResult === "string" || inputResult === undefined ? [inputResult] : [...inputResult]; + const runGitMock = async (args: string[]) => { + execCalls.push({ args: [...args], command: "git" }); + if (execImpl) { + return execImpl("git", args); + } + return { code: 0, stderr: "", stdout: "" }; + }; + + vi.spyOn(git.repo, "root").mockImplementation(async () => { + const result = await runGitMock(["rev-parse", "--show-toplevel"]); + if (result.code !== 0) return null; + const repoRoot = result.stdout.trim(); + return repoRoot.length > 0 ? repoRoot : null; + }); + vi.spyOn(git.show, "prefix").mockImplementation(async () => { + const result = await runGitMock(["rev-parse", "--show-prefix"]); + return result.code === 0 ? result.stdout.trim() : ""; + }); + vi.spyOn(git.branch, "current").mockImplementation(async () => { + const result = await runGitMock(["branch", "--show-current"]); + if (result.code !== 0) return null; + const branch = result.stdout.trim(); + return branch.length > 0 ? branch : null; + }); + const mockStatus = Object.assign( + async (_cwd: string, options?: Parameters[1]) => { + const args = ["status", "--porcelain=v1", "--untracked-files=all", "-z"]; + if (options?.pathspecs?.length) { + args.push("--", ...options.pathspecs); + } + const result = await runGitMock(args); + if (result.code !== 0) throw new Error(result.stderr || result.stdout || `git status exited with code ${result.code}`); + return result.stdout; + }, + { parse: git.status.parse, summary: git.status.summary }, + ); + vi.spyOn(git, "status").mockImplementation(mockStatus); + vi.spyOn(git.ref, "exists").mockImplementation(async (_workDir, refName) => { + const result = await runGitMock(["show-ref", "--verify", "--quiet", refName]); + return result.code === 0; + }); + vi.spyOn(git.branch, "checkoutNew").mockImplementation(async (_workDir, branchName) => { + const result = await runGitMock(["checkout", "-b", branchName]); + if (result.code !== 0) { + throw new Error(result.stderr || result.stdout || `git checkout exited with code ${result.code}`); + } + }); + const api = { appendEntry(_customType: string, _data?: unknown): void {}, exec: async (commandName: string, args: string[]) => { diff --git a/packages/coding-agent/test/autoresearch-tools.test.ts b/packages/coding-agent/test/autoresearch-tools.test.ts index 19ae7f6f1..88832a3bd 100644 --- a/packages/coding-agent/test/autoresearch-tools.test.ts +++ b/packages/coding-agent/test/autoresearch-tools.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -16,7 +16,11 @@ import { createLogExperimentTool } from "../src/autoresearch/tools/log-experimen import { createRunExperimentTool } from "../src/autoresearch/tools/run-experiment"; import type { RunDetails } from "../src/autoresearch/types"; import type { ExtensionAPI, ExtensionContext } from "../src/extensibility/extensions"; +import * as git from "../src/utils/git"; +afterEach(() => { + vi.restoreAllMocks(); +}); function makeTempDir(): string { const dir = path.join(os.tmpdir(), `pi-autoresearch-tools-${Snowflake.next()}`); fs.mkdirSync(dir, { recursive: true }); @@ -1469,15 +1473,10 @@ describe("autoresearch tools", () => { runNumber: 1, }; - const api = { - exec: async (command: string, args: string[]) => { - if (command !== "git") return { code: 1, stderr: "unexpected", stdout: "" }; - if (args[0] === "status") { - return { code: 0, stderr: "", stdout: "R src/generated/index.ts\0src/index.ts\0" }; - } - return { code: 1, stderr: `unexpected git args: ${args.join(" ")}`, stdout: "" }; - }, - } as unknown as ExtensionAPI; + vi.spyOn(git, "status").mockResolvedValue("R src/generated/index.ts\0src/index.ts\0"); + vi.spyOn(git.show, "prefix").mockResolvedValue(""); + + const api = {} as ExtensionAPI; const tool = createLogExperimentTool({ dashboard: createDashboardStub(), diff --git a/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts b/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts index cc7cf916e..bc5d835af 100644 --- a/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts +++ b/packages/coding-agent/test/extensibility/custom-commands/ci-green.test.ts @@ -1,15 +1,20 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, vi } from "bun:test"; import * as typebox from "@sinclair/typebox"; import { GreenCommand } from "../../../src/extensibility/custom-commands/bundled/ci-green"; import type { CustomCommandAPI } from "../../../src/extensibility/custom-commands/types"; import type { HookCommandContext } from "../../../src/extensibility/hooks/types"; import * as piCodingAgent from "../../../src/index"; +import * as git from "../../../src/utils/git"; -function createApi(stdout: string): CustomCommandAPI { +afterEach(() => { + vi.restoreAllMocks(); +}); + +function createApi(): CustomCommandAPI { return { cwd: "/tmp/test", exec: async () => ({ - stdout, + stdout: "", stderr: "", code: 0, killed: false, @@ -21,13 +26,14 @@ function createApi(stdout: string): CustomCommandAPI { describe("GreenCommand", () => { it("exposes the /green command name", () => { - const command = new GreenCommand(createApi("")); + const command = new GreenCommand(createApi()); expect(command.name).toBe("green"); }); it("includes tag instructions when HEAD has a tag", async () => { - const command = new GreenCommand(createApi("v0.1.0-alpha2\n")); + vi.spyOn(git.ref, "tags").mockResolvedValue(["v0.1.0-alpha2"]); + const command = new GreenCommand(createApi()); const result = await command.execute([], {} as HookCommandContext); @@ -41,7 +47,8 @@ describe("GreenCommand", () => { }); it("omits tag instructions when HEAD is not tagged", async () => { - const command = new GreenCommand(createApi("")); + vi.spyOn(git.ref, "tags").mockResolvedValue([]); + const command = new GreenCommand(createApi()); const result = await command.execute([], {} as HookCommandContext); diff --git a/packages/coding-agent/test/patch/imports/integration.test.ts b/packages/coding-agent/test/patch/imports/integration.test.ts index 479ce0d8c..26fc2d116 100644 --- a/packages/coding-agent/test/patch/imports/integration.test.ts +++ b/packages/coding-agent/test/patch/imports/integration.test.ts @@ -65,7 +65,7 @@ describe("EditTool import management integration", () => { const filePath = path.join(tempDir, "main.go"); fs.writeFileSync(filePath, ["package main", "", "func main() {", '\tprintln("old")', "}", ""].join("\n")); - const tool = new EditTool(createTestToolSession(tempDir)); + const tool = new EditTool(createTestToolSession(tempDir, Settings.isolated({ "edit.manageImports": true }))); const result = await tool.execute("replace-go-imports", { path: filePath, old_text: 'println("old")', From db995107af0124b1737600b0f7a8a1ad82d2c8fb Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 4 Apr 2026 16:10:34 +0200 Subject: [PATCH 11/14] chore: reformat --- packages/coding-agent/test/autoresearch-state.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/test/autoresearch-state.test.ts b/packages/coding-agent/test/autoresearch-state.test.ts index f0f5599e5..bde9542a8 100644 --- a/packages/coding-agent/test/autoresearch-state.test.ts +++ b/packages/coding-agent/test/autoresearch-state.test.ts @@ -361,7 +361,8 @@ function createAutoresearchCommandHarness( args.push("--", ...options.pathspecs); } const result = await runGitMock(args); - if (result.code !== 0) throw new Error(result.stderr || result.stdout || `git status exited with code ${result.code}`); + if (result.code !== 0) + throw new Error(result.stderr || result.stdout || `git status exited with code ${result.code}`); return result.stdout; }, { parse: git.status.parse, summary: git.status.summary }, From 511fddd8de65597d497af0ea6a5d1b1afa3865a4 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 4 Apr 2026 21:35:39 +0200 Subject: [PATCH 12/14] revert: language-specific import management experiment This reverts commit f044f3ba756e0a7e15f45b3f2403c71961703d17. --- packages/coding-agent/CHANGELOG.md | 13 - .../src/config/settings-schema.ts | 10 - .../coding-agent/src/patch/imports/cpp.ts | 470 ------- packages/coding-agent/src/patch/imports/go.ts | 435 ------- .../coding-agent/src/patch/imports/index.ts | 60 - .../coding-agent/src/patch/imports/python.ts | 653 ---------- .../coding-agent/src/patch/imports/rust.ts | 594 --------- .../coding-agent/src/patch/imports/types.ts | 47 - .../src/patch/imports/typescript.ts | 1150 ----------------- packages/coding-agent/src/patch/index.ts | 193 +-- packages/coding-agent/src/patch/types.ts | 20 - .../src/prompts/tools/hashline.md | 41 +- .../coding-agent/src/prompts/tools/patch.md | 79 +- .../coding-agent/src/prompts/tools/replace.md | 64 +- .../test/patch/imports/cpp.test.ts | 67 - .../test/patch/imports/go.test.ts | 71 - .../test/patch/imports/integration.test.ts | 111 -- .../test/patch/imports/python.test.ts | 51 - .../test/patch/imports/rust.test.ts | 46 - .../test/patch/imports/typescript.test.ts | 48 - 20 files changed, 47 insertions(+), 4176 deletions(-) delete mode 100644 packages/coding-agent/src/patch/imports/cpp.ts delete mode 100644 packages/coding-agent/src/patch/imports/go.ts delete mode 100644 packages/coding-agent/src/patch/imports/index.ts delete mode 100644 packages/coding-agent/src/patch/imports/python.ts delete mode 100644 packages/coding-agent/src/patch/imports/rust.ts delete mode 100644 packages/coding-agent/src/patch/imports/types.ts delete mode 100644 packages/coding-agent/src/patch/imports/typescript.ts delete mode 100644 packages/coding-agent/test/patch/imports/cpp.test.ts delete mode 100644 packages/coding-agent/test/patch/imports/go.test.ts delete mode 100644 packages/coding-agent/test/patch/imports/integration.test.ts delete mode 100644 packages/coding-agent/test/patch/imports/python.test.ts delete mode 100644 packages/coding-agent/test/patch/imports/rust.test.ts delete mode 100644 packages/coding-agent/test/patch/imports/typescript.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d8c3b28a9..69031f997 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,16 +4,6 @@ ### Added -- Added `edit.manageImports` setting to enable/disable post-edit import and include management -- Added import/include management for TypeScript, JavaScript, Python, Rust, Go, and C/C++ in edit operations -- Added `imports` parameter to patch, hashline, and replace edit operations to declare imports/includes needed after code changes -- Added `ImportSpec` type to specify structured import requests with support for named imports, default imports, namespace imports, aliases, and system includes -- Added comprehensive import handler modules for each supported language with deduplication and grouping logic -- Added `edit.manageImports` setting to enable/disable post-edit import and include management -- Added import/include management for TypeScript, JavaScript, Python, Rust, Go, and C/C++ in edit operations -- Added `imports` parameter to patch, hashline, and replace edit operations to declare imports/includes needed after code changes -- Added `ImportSpec` type to specify structured import requests with support for named imports, default imports, namespace imports, aliases, and system includes -- Added comprehensive import handler modules for each supported language with deduplication and grouping logic - Added `onExternalEditor` callback to extension UI dialog options for handling external editor shortcut in select dialogs - Added external editor shortcut support in plan review selector, allowing users to open and edit the plan in their configured editor - Added `matchesAppExternalEditor` keybinding matcher to detect external editor shortcut (Ctrl+G or configured binding) @@ -24,9 +14,6 @@ ### Changed -- Updated edit tool prompts to document import management feature when enabled -- Modified edit tool schemas to conditionally include `imports` parameter based on `edit.manageImports` setting -- Enhanced patch, hashline, and replace tool documentation with import management examples and guidance - Updated hook editor and hook selector components to use `matchesAppExternalEditor` matcher for consistent external editor keybinding detection - Modified plan review flow to read the latest plan content from disk before approval, allowing changes made in external editor to be reflected - Enhanced plan review help text to dynamically display the configured external editor keybinding diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 32a8a1406..019063bba 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -958,16 +958,6 @@ export const SETTINGS_SCHEMA = { }, }, - "edit.manageImports": { - type: "boolean", - default: false, - ui: { - tab: "editing", - label: "Manage Imports After Edit", - description: "Expose post-edit import/include management for the edit tool", - }, - }, - readLineNumbers: { type: "boolean", default: false, diff --git a/packages/coding-agent/src/patch/imports/cpp.ts b/packages/coding-agent/src/patch/imports/cpp.ts deleted file mode 100644 index ff730d216..000000000 --- a/packages/coding-agent/src/patch/imports/cpp.ts +++ /dev/null @@ -1,470 +0,0 @@ -import type { ImportSpec } from "../types"; -import { - defaultImportStyle, - type ImportHandler, - type ImportRegion, - type ImportStyle, - type ParsedImport, -} from "./types"; - -type IncludeGroup = "system" | "local"; -type LineKind = "blank" | "comment" | "include" | "other"; - -interface ParsedIncludeEntry extends ParsedImport { - lineIndex: number; - group: IncludeGroup; -} - -interface LineClassification { - kind: LineKind; - inBlockComment: boolean; - parsedImport?: ParsedImport; -} - -interface IncludeRegionAnalysis extends ImportRegion { - entries: ParsedIncludeEntry[]; - groupOrder: IncludeGroup[]; -} - -const INCLUDE_PATTERN = /^#\s*include\s*([<"])([^>"]+)[>"](?:\s*(?:\/\/.*|\/\*.*\*\/\s*)?)?$/; -const PRAGMA_ONCE_PATTERN = /^#\s*pragma\s+once\b/; -const IFNDEF_PATTERN = /^#\s*ifndef\s+([A-Za-z_]\w*)\b/; - -function isBlankLine(line: string): boolean { - return line.trim().length === 0; -} - -function lastItem(items: T[]): T | undefined { - return items[items.length - 1]; -} - -function parseIncludeLine(text: string): ParsedImport | null { - const match = INCLUDE_PATTERN.exec(text.trim()); - if (!match) { - return null; - } - - const delimiter = match[1]; - const source = match[2]?.trim(); - if (!source) { - return null; - } - - return { - raw: text, - source, - names: [], - system: delimiter === "<", - }; -} - -function classifyLine(line: string, inBlockComment: boolean): LineClassification { - const trimmed = line.trim(); - if (trimmed.length === 0) { - return { kind: "blank", inBlockComment }; - } - - if (inBlockComment) { - const blockEnd = trimmed.indexOf("*/"); - if (blockEnd === -1) { - return { kind: "comment", inBlockComment: true }; - } - - const remainder = trimmed.slice(blockEnd + 2).trim(); - if (remainder.length === 0) { - return { kind: "comment", inBlockComment: false }; - } - - return classifyLine(remainder, false); - } - - const parsedImport = parseIncludeLine(trimmed); - if (parsedImport) { - return { kind: "include", inBlockComment: false, parsedImport }; - } - - if (trimmed.startsWith("//")) { - return { kind: "comment", inBlockComment: false }; - } - - if (trimmed.startsWith("/*")) { - const blockEnd = trimmed.indexOf("*/", 2); - if (blockEnd === -1) { - return { kind: "comment", inBlockComment: true }; - } - - const remainder = trimmed.slice(blockEnd + 2).trim(); - if (remainder.length === 0) { - return { kind: "comment", inBlockComment: false }; - } - - return classifyLine(remainder, false); - } - - return { kind: "other", inBlockComment: false }; -} - -function findNextSubstantiveLine(lines: string[], startIndex: number): number | null { - let inBlockComment = false; - for (let index = startIndex; index < lines.length; index += 1) { - const classification = classifyLine(lines[index] ?? "", inBlockComment); - inBlockComment = classification.inBlockComment; - if (classification.kind === "blank" || classification.kind === "comment") { - continue; - } - return index; - } - return null; -} - -function findInsertionPoint(lines: string[]): number { - let index = 0; - if ((lines[0] ?? "").startsWith("#!")) { - index = 1; - } - - let inBlockComment = false; - while (index < lines.length) { - const classification = classifyLine(lines[index] ?? "", inBlockComment); - inBlockComment = classification.inBlockComment; - if (classification.kind === "blank" || classification.kind === "comment") { - index += 1; - continue; - } - break; - } - - const firstSubstantive = lines[index]?.trim() ?? ""; - if (PRAGMA_ONCE_PATTERN.test(firstSubstantive)) { - return index + 1; - } - - const guardMatch = IFNDEF_PATTERN.exec(firstSubstantive); - if (!guardMatch) { - return index; - } - - const macroName = guardMatch[1]; - const defineIndex = findNextSubstantiveLine(lines, index + 1); - if (defineIndex === null) { - return index; - } - - const definePattern = new RegExp(`^#\\s*define\\s+${macroName}\\b`); - return definePattern.test(lines[defineIndex]?.trim() ?? "") ? defineIndex + 1 : index; -} - -function detectGroupOrder(entries: ParsedIncludeEntry[]): IncludeGroup[] { - const firstSystem = entries.find(entry => entry.group === "system"); - const firstLocal = entries.find(entry => entry.group === "local"); - if (firstSystem && firstLocal) { - return firstSystem.lineIndex < firstLocal.lineIndex ? ["system", "local"] : ["local", "system"]; - } - return ["system", "local"]; -} - -function hasBlankBetweenGroups(lines: string[], entries: ParsedIncludeEntry[]): boolean { - const firstSystem = entries.find(entry => entry.group === "system"); - const firstLocal = entries.find(entry => entry.group === "local"); - if (!firstSystem || !firstLocal) { - return defaultImportStyle.groupSeparator; - } - - const start = Math.min(firstSystem.lineIndex, firstLocal.lineIndex); - const end = Math.max(firstSystem.lineIndex, firstLocal.lineIndex); - for (let index = start + 1; index < end; index += 1) { - if (isBlankLine(lines[index] ?? "")) { - return true; - } - } - return false; -} - -function isGroupSorted(entries: ParsedIncludeEntry[]): boolean { - for (let index = 1; index < entries.length; index += 1) { - if ((entries[index - 1]?.source ?? "").localeCompare(entries[index]?.source ?? "") > 0) { - return false; - } - } - return true; -} - -function analyzeIncludeRegion(content: string): IncludeRegionAnalysis | null { - const lines = content.split("\n"); - const insertionPoint = findInsertionPoint(lines); - - let index = insertionPoint; - let inBlockComment = false; - while (index < lines.length) { - const classification = classifyLine(lines[index] ?? "", inBlockComment); - inBlockComment = classification.inBlockComment; - if (classification.kind === "blank" || classification.kind === "comment") { - index += 1; - continue; - } - if (classification.kind !== "include") { - return null; - } - break; - } - - if (index >= lines.length) { - return null; - } - - const entries: ParsedIncludeEntry[] = []; - let endIndex = index; - inBlockComment = false; - - for (let lineIndex = index; lineIndex < lines.length; lineIndex += 1) { - const classification = classifyLine(lines[lineIndex] ?? "", inBlockComment); - inBlockComment = classification.inBlockComment; - if (classification.kind === "other") { - break; - } - - endIndex = lineIndex; - if (classification.kind !== "include" || !classification.parsedImport) { - continue; - } - - entries.push({ - ...classification.parsedImport, - group: classification.parsedImport.system ? "system" : "local", - lineIndex, - }); - } - - if (entries.length === 0) { - return null; - } - - const groupOrder = detectGroupOrder(entries); - const imports = entries.map(({ lineIndex, group, ...parsedImport }) => parsedImport); - const systemEntries = entries.filter(entry => entry.group === "system"); - const localEntries = entries.filter(entry => entry.group === "local"); - - return { - startLine: index + 1, - endLine: endIndex + 1, - imports, - entries, - groupOrder, - style: { - ...defaultImportStyle, - groupSeparator: hasBlankBetweenGroups(lines, entries), - groupOrder, - sorted: isGroupSorted(systemEntries) && isGroupSorted(localEntries), - }, - }; -} - -function parseExisting(content: string): ImportRegion | null { - const region = analyzeIncludeRegion(content); - if (!region) { - return null; - } - - return { - startLine: region.startLine, - endLine: region.endLine, - imports: region.imports, - style: region.style, - }; -} - -function specToLine(spec: ImportSpec, _style: ImportStyle): string { - return spec.system ? `#include <${spec.from}>` : `#include "${spec.from}"`; -} - -function makeSpecKey(spec: Pick): string { - return `${spec.system ? "system" : "local"}:${spec.from}`; -} - -function dedupeSpecs(specs: ImportSpec[], existing: Iterable>): ImportSpec[] { - const seen = new Set(); - for (const spec of existing) { - seen.add(makeSpecKey(spec)); - } - - const uniqueSpecs: ImportSpec[] = []; - for (const spec of specs) { - const key = makeSpecKey(spec); - if (seen.has(key)) { - continue; - } - seen.add(key); - uniqueSpecs.push(spec); - } - return uniqueSpecs; -} - -function addInsertion(insertions: Map, index: number, addedLines: string[]): void { - if (addedLines.length === 0) { - return; - } - - const existing = insertions.get(index); - if (existing) { - existing.push(...addedLines); - return; - } - - insertions.set(index, [...addedLines]); -} - -function insertChunk( - insertions: Map, - lines: string[], - index: number, - chunk: string[], - options: { blankBefore?: boolean; blankAfter?: boolean } = {}, -): void { - if (chunk.length === 0) { - return; - } - - const nextChunk = [...chunk]; - if (options.blankBefore && !isBlankLine(lines[index - 1] ?? "")) { - nextChunk.unshift(""); - } - if (options.blankAfter && !isBlankLine(lines[index] ?? "")) { - nextChunk.push(""); - } - - addInsertion(insertions, index, nextChunk); -} - -function sortSpecs(specs: ImportSpec[]): ImportSpec[] { - return [...specs].sort((left, right) => left.from.localeCompare(right.from)); -} - -function buildSectionLines(specs: ImportSpec[], style: ImportStyle): { sectionLines: string[]; addedLines: string[] } { - const systemLines = sortSpecs(specs.filter(spec => spec.system)).map(spec => specToLine(spec, style)); - const localLines = sortSpecs(specs.filter(spec => !spec.system)).map(spec => specToLine(spec, style)); - const sectionLines = [ - ...systemLines, - ...(systemLines.length > 0 && localLines.length > 0 && style.groupSeparator ? [""] : []), - ...localLines, - ]; - return { - sectionLines, - addedLines: [...systemLines, ...localLines], - }; -} - -function applyInsertions(lines: string[], insertions: Map): string[] { - const indexes = [...insertions.keys()].sort((left, right) => right - left); - for (const index of indexes) { - const chunk = insertions.get(index); - if (!chunk || chunk.length === 0) { - continue; - } - lines.splice(index, 0, ...chunk); - } - return lines; -} - -function applyExistingRegion( - content: string, - specs: ImportSpec[], - region: IncludeRegionAnalysis, -): { content: string; added: string[] } { - const lines = content.split("\n"); - const insertions = new Map(); - const groupedSpecs: Record = { - system: sortSpecs(specs.filter(spec => spec.system)), - local: sortSpecs(specs.filter(spec => !spec.system)), - }; - const groupedEntries: Record = { - system: region.entries.filter(entry => entry.group === "system"), - local: region.entries.filter(entry => entry.group === "local"), - }; - const addedLines: string[] = []; - - for (const group of region.groupOrder) { - const specsForGroup = groupedSpecs[group]; - if (specsForGroup.length === 0) { - continue; - } - - const entriesForGroup = groupedEntries[group]; - const otherGroup: IncludeGroup = group === "system" ? "local" : "system"; - const otherEntries = groupedEntries[otherGroup]; - const linesForGroup = specsForGroup.map(spec => specToLine(spec, region.style)); - addedLines.push(...linesForGroup); - - if (entriesForGroup.length === 0) { - const placeBeforeOther = region.groupOrder[0] === group; - if (placeBeforeOther) { - insertChunk(insertions, lines, otherEntries[0]?.lineIndex ?? region.startLine - 1, linesForGroup, { - blankAfter: otherEntries.length > 0 && region.style.groupSeparator, - }); - } else { - insertChunk( - insertions, - lines, - (lastItem(otherEntries)?.lineIndex ?? region.endLine - 1) + 1, - linesForGroup, - { - blankBefore: otherEntries.length > 0 && region.style.groupSeparator, - }, - ); - } - continue; - } - - if (!isGroupSorted(entriesForGroup)) { - addInsertion(insertions, (lastItem(entriesForGroup)?.lineIndex ?? region.endLine - 1) + 1, linesForGroup); - continue; - } - - const pendingByIndex = new Map(); - for (const spec of specsForGroup) { - const targetIndex = - entriesForGroup.find(entry => spec.from.localeCompare(entry.source) < 0)?.lineIndex ?? - (lastItem(entriesForGroup)?.lineIndex ?? region.endLine - 1) + 1; - addInsertion(pendingByIndex, targetIndex, [specToLine(spec, region.style)]); - } - - for (const [targetIndex, chunk] of pendingByIndex.entries()) { - addInsertion(insertions, targetIndex, chunk); - } - } - - const updatedLines = applyInsertions(lines, insertions); - return { content: updatedLines.join("\n"), added: addedLines }; -} - -export const cppImportHandler: ImportHandler = { - parseExisting, - specToLine, - apply(content, specs) { - const region = analyzeIncludeRegion(content); - const uniqueSpecs = dedupeSpecs( - specs, - region?.entries.map(entry => ({ from: entry.source, system: entry.system })) ?? [], - ); - if (uniqueSpecs.length === 0) { - return { content, added: [], warnings: [] }; - } - - if (!region) { - const lines = content.split("\n"); - const insertionPoint = findInsertionPoint(lines); - const { sectionLines, addedLines } = buildSectionLines(uniqueSpecs, defaultImportStyle); - const insertions = new Map(); - insertChunk(insertions, lines, insertionPoint, sectionLines, { - blankBefore: insertionPoint > 0, - blankAfter: insertionPoint < lines.length && !isBlankLine(lines[insertionPoint] ?? ""), - }); - const updatedLines = applyInsertions(lines, insertions); - return { content: updatedLines.join("\n"), added: addedLines, warnings: [] }; - } - - const result = applyExistingRegion(content, uniqueSpecs, region); - return { content: result.content, added: result.added, warnings: [] }; - }, -}; - -export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/imports/go.ts b/packages/coding-agent/src/patch/imports/go.ts deleted file mode 100644 index 4d5b3367f..000000000 --- a/packages/coding-agent/src/patch/imports/go.ts +++ /dev/null @@ -1,435 +0,0 @@ -import type { ImportSpec } from "../types"; -import { - defaultImportStyle, - type ImportHandler, - type ImportRegion, - type ImportStyle, - type ParsedImport, -} from "./types"; - -type GoImportGroup = "stdlib" | "thirdParty"; - -interface GoImportEntry extends ParsedImport { - group: GoImportGroup; -} - -interface ParsedGoImportRegion extends ImportRegion { - entries: GoImportEntry[]; - hasBlock: boolean; - packageLine: number; - newline: string; -} - -const GROUP_ORDER: GoImportGroup[] = ["stdlib", "thirdParty"]; -const SINGLE_IMPORT_RE = /^\s*import\s+(?:(?[._]|[A-Za-z_]\w*)\s+)?"(?[^"]+)"(?:\s*\/\/.*)?\s*$/; -const BLOCK_IMPORT_START_RE = /^\s*import\s*\(\s*(?:\/\/.*)?\s*$/; -const BLOCK_IMPORT_END_RE = /^\s*\)\s*(?:\/\/.*)?\s*$/; -const BLOCK_IMPORT_LINE_RE = /^\s*(?:(?[._]|[A-Za-z_]\w*)\s+)?"(?[^"]+)"(?:\s*\/\/.*)?\s*$/; -const PACKAGE_RE = /^\s*package\s+[A-Za-z_]\w*\s*(?:\/\/.*)?\s*$/; - -function detectNewline(content: string): string { - return content.includes("\r\n") ? "\r\n" : "\n"; -} - -function hasTrailingNewline(content: string): boolean { - return content.endsWith("\n"); -} - -function splitLines(content: string): string[] { - return content.split(/\r?\n/); -} - -function joinLines(lines: string[], newline: string, trailingNewline: boolean): string { - let result = lines.join(newline); - if (trailingNewline && (lines.length === 0 || lines[lines.length - 1] !== "")) { - result += newline; - } - return result; -} - -function isBlank(line: string): boolean { - return line.trim().length === 0; -} - -function isCommentLine(line: string): boolean { - const trimmed = line.trim(); - return trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("*/"); -} - -function isIgnorableTopLevelLine(line: string): boolean { - return isBlank(line) || isCommentLine(line); -} - -function classifyGroup(source: string): GoImportGroup { - const firstSegment = source.split("/")[0] ?? source; - return firstSegment.includes(".") ? "thirdParty" : "stdlib"; -} - -function parseImportSpec(line: string, expression: RegExp): GoImportEntry | null { - const match = expression.exec(line); - if (!match?.groups?.source) { - return null; - } - - const alias = match.groups.alias; - const source = match.groups.source; - return { - raw: line, - source, - names: [], - alias, - group: classifyGroup(source), - }; -} - -function findPackageLine(lines: string[]): number { - for (let index = 0; index < lines.length; index += 1) { - if (PACKAGE_RE.test(lines[index] ?? "")) { - return index + 1; - } - } - return -1; -} - -function detectGroupOrder(entries: GoImportEntry[]): string[] { - const seen = new Set(); - const order: string[] = []; - - for (const entry of entries) { - if (seen.has(entry.group)) { - continue; - } - seen.add(entry.group); - order.push(entry.group); - } - - return order.length > 0 ? order : [...GROUP_ORDER]; -} - -function parseExistingRegion(content: string): ParsedGoImportRegion | null { - const lines = splitLines(content); - const packageLine = findPackageLine(lines); - if (packageLine < 0) { - return null; - } - - let cursor = packageLine; - while (cursor < lines.length && isIgnorableTopLevelLine(lines[cursor] ?? "")) { - cursor += 1; - } - - if (cursor >= lines.length || !(lines[cursor]?.trimStart().startsWith("import") ?? false)) { - return null; - } - - const newline = detectNewline(content); - const entries: GoImportEntry[] = []; - const startLine = cursor + 1; - let endLine = cursor + 1; - let hasBlock = false; - let sawBlankGroupSeparator = false; - let previousEntryLine = -1; - - while (cursor < lines.length) { - const line = lines[cursor] ?? ""; - if (isIgnorableTopLevelLine(line)) { - cursor += 1; - continue; - } - - if (BLOCK_IMPORT_START_RE.test(line)) { - hasBlock = true; - cursor += 1; - while (cursor < lines.length) { - const innerLine = lines[cursor] ?? ""; - if (BLOCK_IMPORT_END_RE.test(innerLine)) { - endLine = cursor + 1; - cursor += 1; - break; - } - - if (isBlank(innerLine)) { - if (previousEntryLine > 0) { - sawBlankGroupSeparator = true; - } - cursor += 1; - continue; - } - - if (isCommentLine(innerLine)) { - cursor += 1; - continue; - } - - const parsed = parseImportSpec(innerLine, BLOCK_IMPORT_LINE_RE); - if (parsed) { - entries.push(parsed); - previousEntryLine = cursor + 1; - } - cursor += 1; - } - continue; - } - - const parsed = parseImportSpec(line, SINGLE_IMPORT_RE); - if (!parsed) { - break; - } - - entries.push(parsed); - endLine = cursor + 1; - cursor += 1; - - let lookahead = cursor; - while (lookahead < lines.length && isIgnorableTopLevelLine(lines[lookahead] ?? "")) { - if (isBlank(lines[lookahead] ?? "")) { - sawBlankGroupSeparator = true; - } - lookahead += 1; - } - if (lookahead >= lines.length || !(lines[lookahead]?.trimStart().startsWith("import") ?? false)) { - break; - } - cursor = lookahead; - } - - const style: ImportStyle = { - ...defaultImportStyle, - groupSeparator: sawBlankGroupSeparator, - groupOrder: detectGroupOrder(entries), - }; - - return { - startLine, - endLine, - imports: entries, - style, - entries, - hasBlock, - packageLine, - newline, - }; -} - -function parseExisting(content: string): ImportRegion | null { - return parseExistingRegion(content); -} - -function specToLine(spec: ImportSpec, _style: ImportStyle): string { - const aliasPrefix = spec.alias ? `${spec.alias} ` : ""; - return `${aliasPrefix}"${spec.from}"`; -} - -function normalizeRequestedImports(specs: ImportSpec[]): { imports: GoImportEntry[]; warnings: string[] } { - const warnings: string[] = []; - const bySource = new Map(); - - for (const spec of specs) { - if (spec.imports?.length) { - warnings.push(`Go imports do not support named imports from ${spec.from}; ignoring named bindings.`); - } - if (spec.default) { - warnings.push(`Go imports do not support default imports from ${spec.from}; ignoring default binding.`); - } - if (spec.namespace) { - warnings.push(`Go imports do not support namespace imports from ${spec.from}; ignoring namespace binding.`); - } - if (spec.system) { - warnings.push(`Go imports do not use system import mode for ${spec.from}; ignoring system flag.`); - } - - const normalized: GoImportEntry = { - raw: specToLine(spec, defaultImportStyle), - source: spec.from, - names: [], - alias: spec.alias, - group: classifyGroup(spec.from), - }; - - const existing = bySource.get(normalized.source); - if (!existing) { - bySource.set(normalized.source, normalized); - continue; - } - - if (existing.alias === normalized.alias) { - continue; - } - - warnings.push( - `Conflicting Go import aliases requested for ${normalized.source}; keeping ${formatAlias(existing.alias)}.`, - ); - } - - return { imports: [...bySource.values()], warnings }; -} - -function formatAlias(alias: string | undefined): string { - return alias ? `alias ${alias}` : "the existing unaliased import"; -} - -function trimLeadingBlankLines(lines: string[]): string[] { - let start = 0; - while (start < lines.length && isBlank(lines[start] ?? "")) { - start += 1; - } - return lines.slice(start); -} - -function pushGroupLines(lines: string[], imports: GoImportEntry[], style: ImportStyle): void { - const orderedGroups = style.groupOrder.filter( - (group): group is GoImportGroup => group === "stdlib" || group === "thirdParty", - ); - const groupOrder = orderedGroups.length > 0 ? orderedGroups : GROUP_ORDER; - let emittedGroups = 0; - - for (const group of groupOrder) { - const groupImports = imports.filter(entry => entry.group === group); - if (groupImports.length === 0) { - continue; - } - if (style.groupSeparator && emittedGroups > 0) { - lines.push(""); - } - for (const entry of groupImports) { - lines.push(`\t${specToLine({ from: entry.source, alias: entry.alias }, style)}`); - } - emittedGroups += 1; - } -} - -function buildImportSection(imports: GoImportEntry[], preferBlock: boolean, style: ImportStyle): string[] { - if (imports.length === 1 && !preferBlock) { - const [entry] = imports; - return [`import ${specToLine({ from: entry.source, alias: entry.alias }, style)}`]; - } - - const lines = ["import ("]; - pushGroupLines(lines, imports, style); - lines.push(")"); - return lines; -} - -function mergeImports( - existing: GoImportEntry[], - requested: GoImportEntry[], -): { merged: GoImportEntry[]; added: GoImportEntry[]; warnings: string[] } { - const warnings: string[] = []; - const added: GoImportEntry[] = []; - const mergedBySource = new Map(); - - for (const entry of existing) { - if (mergedBySource.has(entry.source)) { - const prior = mergedBySource.get(entry.source); - if (prior?.alias !== entry.alias) { - warnings.push( - `Conflicting existing Go import aliases for ${entry.source}; keeping ${formatAlias(prior?.alias)}.`, - ); - } - continue; - } - mergedBySource.set(entry.source, entry); - } - - for (const entry of requested) { - const prior = mergedBySource.get(entry.source); - if (!prior) { - mergedBySource.set(entry.source, entry); - added.push(entry); - continue; - } - if (prior.alias === entry.alias) { - continue; - } - warnings.push( - `Go import ${entry.source} already exists with ${formatAlias(prior.alias)}; skipping conflicting alias.`, - ); - } - - const groupBuckets = new Map(); - for (const group of GROUP_ORDER) { - groupBuckets.set(group, []); - } - for (const entry of mergedBySource.values()) { - groupBuckets.get(entry.group)?.push(entry); - } - for (const bucket of groupBuckets.values()) { - bucket.sort((left, right) => { - if (left.source === right.source) { - return (left.alias ?? "").localeCompare(right.alias ?? ""); - } - return left.source.localeCompare(right.source); - }); - } - - return { - merged: [...(groupBuckets.get("stdlib") ?? []), ...(groupBuckets.get("thirdParty") ?? [])], - added, - warnings, - }; -} - -function apply(content: string, specs: ImportSpec[]): { content: string; added: string[]; warnings: string[] } { - const requested = normalizeRequestedImports(specs); - const parsedRegion = parseExistingRegion(content); - const newline = parsedRegion?.newline ?? detectNewline(content); - const trailingNewline = hasTrailingNewline(content); - const warnings = [...requested.warnings]; - const lines = splitLines(content); - const packageLine = parsedRegion?.packageLine ?? findPackageLine(lines); - if (packageLine < 0) { - warnings.push("Go import management requires a package declaration."); - return { content, added: [], warnings }; - } - - const mergeResult = mergeImports(parsedRegion?.entries ?? [], requested.imports); - warnings.push(...mergeResult.warnings); - if (mergeResult.added.length === 0) { - return { content, added: [], warnings }; - } - - const style: ImportStyle = parsedRegion?.style ?? { - ...defaultImportStyle, - groupSeparator: true, - groupOrder: [...GROUP_ORDER], - }; - const preferBlock = Boolean(parsedRegion) || mergeResult.merged.length > 1; - const importSection = buildImportSection(mergeResult.merged, preferBlock, style); - - let nextLines: string[]; - if (parsedRegion) { - const before = lines.slice(0, parsedRegion.startLine - 1); - const after = trimLeadingBlankLines(lines.slice(parsedRegion.endLine)); - nextLines = [...before, ...importSection]; - if (after.length > 0 && !isBlank(nextLines[nextLines.length - 1] ?? "")) { - nextLines.push(""); - } - nextLines.push(...after); - } else { - const before = lines.slice(0, packageLine); - const after = trimLeadingBlankLines(lines.slice(packageLine)); - nextLines = [...before]; - if (!isBlank(nextLines[nextLines.length - 1] ?? "")) { - nextLines.push(""); - } - nextLines.push(...importSection); - if (after.length > 0) { - nextLines.push(""); - nextLines.push(...after); - } - } - - return { - content: joinLines(nextLines, newline, trailingNewline), - added: mergeResult.added.map(entry => specToLine({ from: entry.source, alias: entry.alias }, style)), - warnings, - }; -} - -export const goImportHandler: ImportHandler = { - parseExisting, - specToLine, - apply, -}; - -export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/imports/index.ts b/packages/coding-agent/src/patch/imports/index.ts deleted file mode 100644 index 6b9e14986..000000000 --- a/packages/coding-agent/src/patch/imports/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { detectLanguageId } from "../../lsp/utils"; -import type { ImportSpec } from "../types"; -import { cppImportHandler } from "./cpp"; -import { goImportHandler } from "./go"; -import { pythonImportHandler } from "./python"; -import { rustImportHandler } from "./rust"; -import type { ImportApplyResult, ImportHandler } from "./types"; -import { typescriptImportHandler } from "./typescript"; - -const handlers: Partial> = { - typescript: typescriptImportHandler, - typescriptreact: typescriptImportHandler, - javascript: typescriptImportHandler, - javascriptreact: typescriptImportHandler, - rust: rustImportHandler, - python: pythonImportHandler, - go: goImportHandler, - c: cppImportHandler, - cpp: cppImportHandler, -}; - -export interface ApplyImportsResult { - content: string; - warnings: string[]; - added: string[]; -} - -export function applyImports(filePath: string, content: string, specs: ImportSpec[]): ApplyImportsResult { - if (specs.length === 0) { - return { content, warnings: [], added: [] }; - } - - const languageId = detectLanguageId(filePath); - const handler = handlers[languageId]; - if (!handler) { - return { - content, - added: [], - warnings: [`Import management not supported for language: ${languageId}`], - }; - } - - try { - const result: ImportApplyResult = handler.apply(content, specs); - return { - content: result.content, - warnings: result.warnings, - added: result.added, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - content, - added: [], - warnings: [`Failed to manage imports for ${filePath}: ${message}`], - }; - } -} - -export * from "./types"; diff --git a/packages/coding-agent/src/patch/imports/python.ts b/packages/coding-agent/src/patch/imports/python.ts deleted file mode 100644 index c2480669d..000000000 --- a/packages/coding-agent/src/patch/imports/python.ts +++ /dev/null @@ -1,653 +0,0 @@ -import type { ImportSpec } from "../types"; -import { - defaultImportStyle, - type ImportHandler, - type ImportRegion, - type ImportStyle, - type ParsedImport, -} from "./types"; - -type ImportGroup = "stdlib" | "thirdparty" | "local"; - -interface PythonImportStyle extends ImportStyle { - parenthesized: boolean; -} - -interface ImportedName { - name: string; - alias?: string; - order: number; -} - -interface ParsedPythonImport extends ParsedImport { - kind: "module" | "from"; - group: ImportGroup; - order: number; - multiline: boolean; - startLine: number; - endLine: number; - names: string[]; - parsedNames: ImportedName[]; -} - -interface ParsedPythonRegion extends ImportRegion { - imports: ParsedPythonImport[]; - style: PythonImportStyle; -} - -const DEFAULT_GROUP_ORDER: ImportGroup[] = ["stdlib", "thirdparty", "local"]; - -const STDLIB_MODULES = new Set([ - "__future__", - "abc", - "argparse", - "array", - "ast", - "asyncio", - "base64", - "collections", - "contextlib", - "copy", - "csv", - "dataclasses", - "datetime", - "decimal", - "enum", - "functools", - "glob", - "gzip", - "hashlib", - "heapq", - "hmac", - "html", - "http", - "importlib", - "inspect", - "io", - "itertools", - "json", - "logging", - "math", - "mimetypes", - "os", - "pathlib", - "pickle", - "platform", - "queue", - "random", - "re", - "secrets", - "shlex", - "shutil", - "socket", - "sqlite3", - "statistics", - "string", - "subprocess", - "sys", - "tempfile", - "threading", - "time", - "traceback", - "types", - "typing", - "unittest", - "urllib", - "uuid", - "warnings", - "weakref", - "xml", - "zipfile", - "zoneinfo", -]); - -function getPythonStyle(style: ImportStyle): PythonImportStyle { - return { - ...style, - parenthesized: (style as Partial).parenthesized ?? false, - }; -} - -function detectEol(content: string): string { - return content.includes("\r\n") ? "\r\n" : "\n"; -} - -function isBlankLine(line: string): boolean { - return line.trim().length === 0; -} - -function isCommentLine(line: string): boolean { - return line.trimStart().startsWith("#"); -} - -function isEncodingComment(line: string): boolean { - return /^[ \t]*#.*coding[:=][ \t]*[-\w.]+/.test(line); -} - -function isImportStart(line: string): boolean { - const trimmed = line.trimStart(); - return trimmed.startsWith("import ") || trimmed.startsWith("from "); -} - -function stripInlineComment(line: string): string { - const hashIndex = line.indexOf("#"); - return hashIndex === -1 ? line : line.slice(0, hashIndex); -} - -function parseModuleDocstring(lines: string[], startIndex: number): number | null { - const firstLine = lines[startIndex]; - if (firstLine === undefined) return null; - const trimmed = firstLine.trimStart(); - const match = /^[rRuUbBfF]*(["']{3})/.exec(trimmed); - if (!match) return null; - const delimiter = match[1]; - const remainder = trimmed.slice(match[0].length); - if (remainder.includes(delimiter)) return startIndex + 1; - for (let index = startIndex + 1; index < lines.length; index += 1) { - if (lines[index]?.includes(delimiter)) return index + 1; - } - return lines.length; -} - -function findPrefixEnd(lines: string[]): number { - let index = 0; - if (lines[0]?.startsWith("#!")) index += 1; - if (isEncodingComment(lines[index] ?? "")) index += 1; - while (index < lines.length && isBlankLine(lines[index] ?? "")) index += 1; - const docstringEnd = parseModuleDocstring(lines, index); - if (docstringEnd !== null) { - index = docstringEnd; - while (index < lines.length && isBlankLine(lines[index] ?? "")) index += 1; - } - return index; -} - -function classifyGroup(source: string): ImportGroup { - if (source.startsWith(".")) return "local"; - const root = source.split(".")[0] ?? source; - return STDLIB_MODULES.has(root) ? "stdlib" : "thirdparty"; -} - -function formatImportedName(name: ImportedName): string { - return name.alias ? `${name.name} as ${name.alias}` : name.name; -} - -function parseImportedName(token: string, order: number): ImportedName | null { - const cleaned = stripInlineComment(token).replace(/,$/, "").trim(); - if (cleaned.length === 0) return null; - const match = /^(\*|[A-Za-z_][\w]*)(?:\s+as\s+([A-Za-z_][\w]*))?$/.exec(cleaned); - if (!match) return null; - return { name: match[1], alias: match[2], order }; -} - -function parseModuleImport(token: string, order: number): ParsedPythonImport | null { - const cleaned = stripInlineComment(token).replace(/,$/, "").trim(); - if (cleaned.length === 0) return null; - const match = /^([A-Za-z_][\w.]*)(?:\s+as\s+([A-Za-z_][\w]*))?$/.exec(cleaned); - if (!match) return null; - return { - raw: `import ${cleaned}`, - source: match[1], - names: [], - parsedNames: [], - alias: match[2], - kind: "module", - group: classifyGroup(match[1]), - order, - multiline: false, - startLine: 0, - endLine: 0, - }; -} - -function parseImportStatement( - line: string, - lineNumber: number, - startOrder: number, -): { records: ParsedPythonImport[]; nextOrder: number } | null { - const match = /^import\s+(.+)$/.exec(stripInlineComment(line.trim())); - if (!match) return null; - let nextOrder = startOrder; - const records: ParsedPythonImport[] = []; - for (const token of match[1].split(",")) { - const parsed = parseModuleImport(token, nextOrder); - if (!parsed) continue; - parsed.startLine = lineNumber; - parsed.endLine = lineNumber; - parsed.raw = line; - records.push(parsed); - nextOrder += 1; - } - return records.length === 0 ? null : { records, nextOrder }; -} - -function countParentheses(text: string): number { - let balance = 0; - for (const char of text) { - if (char === "(") balance += 1; - if (char === ")") balance -= 1; - } - return balance; -} - -function parseFromStatement( - lines: string[], - startIndex: number, - eol: string, - startOrder: number, -): { record: ParsedPythonImport; nextIndex: number; nextOrder: number } | null { - const collected: string[] = []; - let nextIndex = startIndex; - let balance = 0; - let sawParenthesis = false; - while (nextIndex < lines.length) { - const line = lines[nextIndex] ?? ""; - collected.push(line); - const withoutComment = stripInlineComment(line); - balance += countParentheses(withoutComment); - sawParenthesis ||= withoutComment.includes("("); - nextIndex += 1; - if (sawParenthesis) { - if (balance <= 0) break; - continue; - } - break; - } - const normalized = collected - .map(line => stripInlineComment(line).trim()) - .join(" ") - .replace(/\s+/g, " "); - const match = /^from\s+([.A-Za-z_][\w.]*)\s+import\s+(.+)$/.exec(normalized); - if (!match) return null; - let namesPart = match[2].trim(); - if (namesPart.startsWith("(") && namesPart.endsWith(")")) { - namesPart = namesPart.slice(1, -1).trim(); - } - const parsedNames: ImportedName[] = []; - let nextOrder = startOrder; - for (const token of namesPart.split(",")) { - const parsedName = parseImportedName(token, nextOrder); - if (!parsedName) continue; - parsedNames.push(parsedName); - nextOrder += 1; - } - if (parsedNames.length === 0) return null; - const record: ParsedPythonImport = { - raw: collected.join(eol), - source: match[1], - names: parsedNames.map(formatImportedName), - parsedNames, - kind: "from", - group: classifyGroup(match[1]), - order: startOrder, - multiline: sawParenthesis || collected.length > 1, - startLine: startIndex + 1, - endLine: nextIndex, - }; - return { record, nextIndex, nextOrder }; -} - -function compareImportedNames(left: ImportedName, right: ImportedName): number { - const leftKey = `${left.name}|${left.alias ?? ""}`; - const rightKey = `${right.name}|${right.alias ?? ""}`; - return leftKey.localeCompare(rightKey); -} - -function compareRecords(left: ParsedPythonImport, right: ParsedPythonImport): number { - const leftKey = left.kind === "module" ? `import ${left.source} ${left.alias ?? ""}` : `from ${left.source}`; - const rightKey = right.kind === "module" ? `import ${right.source} ${right.alias ?? ""}` : `from ${right.source}`; - return leftKey.localeCompare(rightKey); -} - -function hasBlankLineBetween(lines: string[], previousEndLine: number, nextStartLine: number): boolean { - for (let index = previousEndLine; index < nextStartLine - 1; index += 1) { - if (isBlankLine(lines[index] ?? "")) return true; - } - return false; -} - -function inferSorted(records: ParsedPythonImport[]): boolean { - const byGroup = new Map(); - for (const record of records) { - const groupRecords = byGroup.get(record.group); - if (groupRecords) groupRecords.push(record); - else byGroup.set(record.group, [record]); - if (record.kind === "from") { - for (let index = 1; index < record.parsedNames.length; index += 1) { - if (compareImportedNames(record.parsedNames[index - 1]!, record.parsedNames[index]!) > 0) return false; - } - } - } - for (const group of DEFAULT_GROUP_ORDER) { - const groupRecords = byGroup.get(group) ?? []; - for (let index = 1; index < groupRecords.length; index += 1) { - if (compareRecords(groupRecords[index - 1]!, groupRecords[index]!) > 0) return false; - } - } - return true; -} - -function inferStyle(records: ParsedPythonImport[], lines: string[]): PythonImportStyle { - const groupOrder: ImportGroup[] = []; - let groupSeparator = false; - for (let index = 0; index < records.length; index += 1) { - const record = records[index]!; - if (!groupOrder.includes(record.group)) groupOrder.push(record.group); - if (index > 0) { - const previous = records[index - 1]!; - if (hasBlankLineBetween(lines, previous.endLine, record.startLine)) groupSeparator = true; - } - } - return { - ...defaultImportStyle, - groupSeparator, - groupOrder: groupOrder.length > 0 ? groupOrder : [...DEFAULT_GROUP_ORDER], - sorted: inferSorted(records), - parenthesized: records.some(record => record.kind === "from" && record.multiline), - }; -} - -function normalizeRecords(records: ParsedPythonImport[]): ParsedPythonImport[] { - const moduleRecords = new Map(); - const fromRecords = new Map(); - for (const record of [...records].sort((left, right) => left.order - right.order)) { - if (record.kind === "module") { - const key = `${record.source}|${record.alias ?? ""}`; - if (!moduleRecords.has(key)) moduleRecords.set(key, { ...record, parsedNames: [], names: [] }); - continue; - } - const existing = fromRecords.get(record.source); - if (!existing) { - fromRecords.set(record.source, { - ...record, - parsedNames: [...record.parsedNames], - names: [...record.names], - }); - continue; - } - for (const name of record.parsedNames) { - const alreadyPresent = existing.parsedNames.some( - existingName => existingName.name === name.name && (existingName.alias ?? "") === (name.alias ?? ""), - ); - if (alreadyPresent) continue; - existing.parsedNames.push({ ...name, order: existing.parsedNames.length }); - } - existing.names = existing.parsedNames.map(formatImportedName); - existing.multiline ||= record.multiline; - if (record.order < existing.order) existing.order = record.order; - } - const combined = [...moduleRecords.values(), ...fromRecords.values()]; - return combined.sort((left, right) => left.order - right.order); -} - -function renderRecord(record: ParsedPythonImport, style: PythonImportStyle): string { - if (record.kind === "module") { - return record.alias ? `import ${record.source} as ${record.alias}` : `import ${record.source}`; - } - const names = [...record.parsedNames]; - if (style.sorted) names.sort(compareImportedNames); - const renderedNames = names.map(formatImportedName); - const useParenthesized = record.multiline || (style.parenthesized && renderedNames.length > 1); - if (!useParenthesized) { - return `from ${record.source} import ${renderedNames.join(", ")}`; - } - return [`from ${record.source} import (`, ...renderedNames.map(name => ` ${name},`), `)`].join("\n"); -} - -function groupComparator(left: ImportGroup, right: ImportGroup, style: PythonImportStyle): number { - const orderedGroups = [ - ...style.groupOrder, - ...DEFAULT_GROUP_ORDER.filter(group => !style.groupOrder.includes(group)), - ]; - return orderedGroups.indexOf(left) - orderedGroups.indexOf(right); -} - -function renderImportBlock(records: ParsedPythonImport[], style: PythonImportStyle): string[] { - const normalized = normalizeRecords(records).map(record => ({ - ...record, - parsedNames: [...record.parsedNames], - names: [...record.names], - })); - const grouped = new Map(); - for (const record of normalized) { - const groupRecords = grouped.get(record.group); - if (groupRecords) groupRecords.push(record); - else grouped.set(record.group, [record]); - } - const orderedGroups = [...grouped.keys()].sort((left, right) => groupComparator(left, right, style)); - const renderedLines: string[] = []; - for (const group of orderedGroups) { - const groupRecords = grouped.get(group) ?? []; - const orderedRecords = style.sorted - ? [...groupRecords].sort(compareRecords) - : [...groupRecords].sort((left, right) => left.order - right.order); - if (renderedLines.length > 0 && style.groupSeparator) renderedLines.push(""); - for (const record of orderedRecords) { - renderedLines.push(...renderRecord(record, style).split("\n")); - } - } - return renderedLines; -} - -function specToRecords(spec: ImportSpec, order: number): { records: ParsedPythonImport[]; warnings: string[] } { - const warnings: string[] = []; - if (spec.default) warnings.push(`Python imports do not support default import syntax for ${spec.from}`); - if (spec.namespace) warnings.push(`Python imports do not support namespace import syntax for ${spec.from}`); - if (spec.imports && spec.imports.length > 0) { - const parsedNames = spec.imports.flatMap((item, index) => { - const parsed = parseImportedName(item, order + index); - if (parsed) return [parsed]; - return []; - }); - if (parsedNames.length === 1 && spec.alias) parsedNames[0] = { ...parsedNames[0]!, alias: spec.alias }; - if (parsedNames.length === 0) return { records: [], warnings }; - return { - records: [ - { - raw: "", - source: spec.from, - names: parsedNames.map(formatImportedName), - parsedNames, - kind: "from", - group: classifyGroup(spec.from), - order, - multiline: false, - startLine: 0, - endLine: 0, - }, - ], - warnings, - }; - } - return { - records: [ - { - raw: "", - source: spec.from, - names: [], - parsedNames: [], - alias: spec.alias, - kind: "module", - group: classifyGroup(spec.from), - order, - multiline: false, - startLine: 0, - endLine: 0, - }, - ], - warnings, - }; -} - -function parsePythonRegion(content: string): ParsedPythonRegion | null { - const lines = content.split(/\r?\n/); - const eol = detectEol(content); - const prefixEnd = findPrefixEnd(lines); - let index = prefixEnd; - while (index < lines.length && (isBlankLine(lines[index] ?? "") || isCommentLine(lines[index] ?? ""))) index += 1; - if (!isImportStart(lines[index] ?? "")) return null; - const records: ParsedPythonImport[] = []; - let nextOrder = 0; - while (index < lines.length) { - const currentLine = lines[index] ?? ""; - if (isBlankLine(currentLine) || isCommentLine(currentLine)) { - index += 1; - continue; - } - if (currentLine.trimStart().startsWith("import ")) { - const parsed = parseImportStatement(currentLine, index + 1, nextOrder); - if (!parsed) break; - records.push(...parsed.records); - nextOrder = parsed.nextOrder; - index += 1; - continue; - } - if (currentLine.trimStart().startsWith("from ")) { - const parsed = parseFromStatement(lines, index, eol, nextOrder); - if (!parsed) break; - records.push(parsed.record); - nextOrder = parsed.nextOrder; - index = parsed.nextIndex; - continue; - } - break; - } - if (records.length === 0) return null; - const normalizedRecords = normalizeRecords(records); - return { - startLine: normalizedRecords[0]!.startLine, - endLine: Math.max(...normalizedRecords.map(record => record.endLine)), - imports: normalizedRecords, - style: inferStyle(normalizedRecords, lines), - }; -} - -function parseExisting(content: string): ImportRegion | null { - return parsePythonRegion(content); -} - -function specToLine(spec: ImportSpec, style: ImportStyle): string { - const pythonStyle = getPythonStyle(style); - const { records } = specToRecords(spec, 0); - return records.length === 0 ? "" : renderRecord(records[0]!, pythonStyle); -} - -function mergeRecords( - existing: ParsedPythonImport[], - requested: ParsedPythonImport[], - style: PythonImportStyle, -): { records: ParsedPythonImport[]; added: string[] } { - const records = normalizeRecords(existing).map(record => ({ - ...record, - parsedNames: [...record.parsedNames], - names: [...record.names], - })); - let nextOrder = records.reduce((max, record) => Math.max(max, record.order), -1) + 1; - const added: string[] = []; - for (const record of requested) { - if (record.kind === "module") { - const exists = records.some( - existingRecord => - existingRecord.kind === "module" && - existingRecord.source === record.source && - (existingRecord.alias ?? "") === (record.alias ?? ""), - ); - if (exists) continue; - records.push({ ...record, order: nextOrder, startLine: 0, endLine: 0 }); - added.push(renderRecord(record, style)); - nextOrder += 1; - continue; - } - const existingRecord = records.find(candidate => candidate.kind === "from" && candidate.source === record.source); - if (!existingRecord || existingRecord.kind !== "from") { - records.push({ ...record, order: nextOrder, startLine: 0, endLine: 0 }); - added.push(renderRecord(record, style)); - nextOrder += 1; - continue; - } - const missingNames = record.parsedNames.filter( - name => - !existingRecord.parsedNames.some( - existingName => existingName.name === name.name && (existingName.alias ?? "") === (name.alias ?? ""), - ), - ); - if (missingNames.length === 0) continue; - for (const name of missingNames) { - existingRecord.parsedNames.push({ ...name, order: existingRecord.parsedNames.length }); - } - if (style.sorted) existingRecord.parsedNames.sort(compareImportedNames); - existingRecord.names = existingRecord.parsedNames.map(formatImportedName); - existingRecord.multiline ||= record.multiline; - added.push( - renderRecord( - { ...existingRecord, parsedNames: missingNames, names: missingNames.map(formatImportedName) }, - style, - ), - ); - } - return { records, added }; -} - -function insertWithoutRegion(content: string, lines: string[], blockLines: string[], eol: string): string { - if (content.length === 0) return blockLines.join(eol); - const insertAt = findPrefixEnd(lines); - const before = lines.slice(0, insertAt); - const after = lines.slice(insertAt); - const needsTrailingBlank = - after.length > 0 && after.some(line => line.trim().length > 0) && !isBlankLine(after[0] ?? ""); - const inserted = [...before, ...blockLines, ...(needsTrailingBlank ? [""] : []), ...after]; - const nextContent = inserted.join(eol); - if (content.endsWith(eol) && !nextContent.endsWith(eol)) return `${nextContent}${eol}`; - return nextContent; -} - -function replaceRegion( - content: string, - lines: string[], - region: ParsedPythonRegion, - blockLines: string[], - eol: string, -): string { - const nextLines = [...lines.slice(0, region.startLine - 1), ...blockLines, ...lines.slice(region.endLine)]; - const nextContent = nextLines.join(eol); - if (content.endsWith(eol) && !nextContent.endsWith(eol)) return `${nextContent}${eol}`; - return nextContent; -} - -export const pythonImportHandler: ImportHandler = { - parseExisting, - specToLine, - apply(content, specs) { - const region = parsePythonRegion(content); - const style = getPythonStyle( - region?.style ?? { - ...defaultImportStyle, - groupSeparator: true, - groupOrder: [...DEFAULT_GROUP_ORDER], - sorted: true, - parenthesized: false, - }, - ); - const warnings: string[] = []; - const requested: ParsedPythonImport[] = []; - let order = 0; - for (const spec of specs) { - const result = specToRecords(spec, order); - warnings.push(...result.warnings); - requested.push(...result.records); - order += Math.max(result.records.length, 1); - } - if (requested.length === 0) return { content, added: [], warnings }; - const { records, added } = mergeRecords(region?.imports ?? [], requested, style); - if (added.length === 0) return { content, added: [], warnings }; - const eol = detectEol(content); - const lines = content.split(/\r?\n/); - const blockLines = renderImportBlock(records, style); - const nextContent = region - ? replaceRegion(content, lines, region, blockLines, eol) - : insertWithoutRegion(content, lines, blockLines, eol); - return { content: nextContent, added, warnings }; - }, -}; - -export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/imports/rust.ts b/packages/coding-agent/src/patch/imports/rust.ts deleted file mode 100644 index 7f299ffd8..000000000 --- a/packages/coding-agent/src/patch/imports/rust.ts +++ /dev/null @@ -1,594 +0,0 @@ -import type { ImportSpec } from "../types"; -import { - defaultImportStyle, - type ImportHandler, - type ImportRegion, - type ImportStyle, - type ParsedImport, -} from "./types"; - -type RustImportGroup = "std" | "external" | "local"; -type RustImportKind = "simple" | "group"; - -interface RustImportEntry extends ParsedImport { - group: RustImportGroup; - kind: RustImportKind; - path: string; - prefix?: string; - members: string[]; - order: number; -} - -interface ParsedRustRegion extends ImportRegion { - imports: RustImportEntry[]; - entries: RustImportEntry[]; - newline: string; -} - -const DEFAULT_GROUP_ORDER: RustImportGroup[] = ["std", "external", "local"]; - -function detectNewline(content: string): string { - return content.includes("\r\n") ? "\r\n" : "\n"; -} - -function hasTrailingNewline(content: string): boolean { - return content.endsWith("\n"); -} - -function splitLines(content: string): string[] { - return content.split(/\r?\n/); -} - -function joinLines(lines: string[], newline: string, trailingNewline: boolean): string { - let result = lines.join(newline); - if (trailingNewline && (lines.length === 0 || lines[lines.length - 1] !== "")) { - result += newline; - } - return result; -} - -function isBlank(line: string): boolean { - return line.trim().length === 0; -} - -function isLineComment(line: string): boolean { - const trimmed = line.trimStart(); - return trimmed.startsWith("//"); -} - -function isBlockCommentStart(line: string): boolean { - return line.trimStart().startsWith("/*"); -} - -function isOuterAttribute(line: string): boolean { - const trimmed = line.trimStart(); - return trimmed.startsWith("#![") || trimmed.startsWith("#["); -} - -function consumeBlockComment(lines: string[], startIndex: number): number { - let index = startIndex; - while (index < lines.length) { - if ((lines[index] ?? "").includes("*/")) { - return index + 1; - } - index += 1; - } - return lines.length; -} - -function stripLineComment(line: string): string { - const markerIndex = line.indexOf("//"); - return markerIndex === -1 ? line : line.slice(0, markerIndex); -} - -function normalizeWhitespace(text: string): string { - return text.trim().replace(/\s+/g, " "); -} - -function normalizeMemberName(member: string): string { - return normalizeWhitespace(member).replace(/\s+as\s+/g, " as "); -} - -function classifyGroup(path: string): RustImportGroup { - const root = path.split("::")[0] ?? path; - if (root === "std" || root === "core") { - return "std"; - } - if (root === "crate" || root === "self" || root === "super") { - return "local"; - } - return "external"; -} - -function detectGroupOrder(entries: RustImportEntry[]): string[] { - const seen = new Set(); - const order: string[] = []; - for (const entry of entries) { - if (seen.has(entry.group)) { - continue; - } - seen.add(entry.group); - order.push(entry.group); - } - return order.length > 0 ? order : [...DEFAULT_GROUP_ORDER]; -} - -function findPrefixEnd(lines: string[]): number { - let index = 0; - while (index < lines.length && isBlank(lines[index] ?? "")) { - index += 1; - } - - if ((lines[index] ?? "").startsWith("#!") && !(lines[index] ?? "").trimStart().startsWith("#![")) { - index += 1; - } - - while (index < lines.length) { - const line = lines[index] ?? ""; - if (isBlank(line) || isLineComment(line) || isOuterAttribute(line)) { - index += 1; - continue; - } - if (isBlockCommentStart(line)) { - index = consumeBlockComment(lines, index); - continue; - } - break; - } - - return index; -} - -function parseSimpleExpression(expression: string): { path: string; alias?: string } | null { - const match = /^(?.+?)(?:\s+as\s+(?[A-Za-z_][A-Za-z0-9_]*))?$/.exec(expression.trim()); - if (!match?.groups?.path) { - return null; - } - - const path = normalizeWhitespace(match.groups.path); - if (path.length === 0 || path.includes("{") || path.includes("}")) { - return null; - } - - return { - path, - alias: match.groups.alias, - }; -} - -function parseMembers(rawMembers: string): string[] | null { - const members: string[] = []; - for (const token of rawMembers.split(",")) { - const normalized = normalizeMemberName(token); - if (normalized.length === 0) { - continue; - } - if (normalized.includes("{") || normalized.includes("}")) { - return null; - } - members.push(normalized); - } - return members.length > 0 ? members : null; -} - -function parseUseStatement(line: string, order: number): RustImportEntry | null { - const withoutComment = stripLineComment(line).trim(); - const match = /^use\s+(.+);$/.exec(withoutComment); - if (!match) { - return null; - } - - const expression = normalizeWhitespace(match[1]); - const groupMatch = /^(?.+?)::\{(?[^{}]+)\}$/.exec(expression); - if (groupMatch?.groups?.prefix && groupMatch.groups.members) { - const prefix = normalizeWhitespace(groupMatch.groups.prefix); - const members = parseMembers(groupMatch.groups.members); - if (!members) { - return null; - } - return { - raw: line, - source: prefix, - names: [...members], - path: prefix, - prefix, - members, - kind: "group", - group: classifyGroup(prefix), - order, - }; - } - - const simple = parseSimpleExpression(expression); - if (!simple) { - return null; - } - - return { - raw: line, - source: simple.path, - names: [], - alias: simple.alias, - path: simple.path, - members: [], - kind: "simple", - group: classifyGroup(simple.path), - order, - }; -} - -function parseExistingRegion(content: string): ParsedRustRegion | null { - const lines = splitLines(content); - const prefixEnd = findPrefixEnd(lines); - let cursor = prefixEnd; - while (cursor < lines.length && isBlank(lines[cursor] ?? "")) { - cursor += 1; - } - const startLine = cursor + 1; - if (!(lines[cursor] ?? "").trimStart().startsWith("use ")) { - return null; - } - - const entries: RustImportEntry[] = []; - let endLine = cursor + 1; - let order = 0; - let sawBlankGroupSeparator = false; - let seenImport = false; - - while (cursor < lines.length) { - const line = lines[cursor] ?? ""; - if (isBlank(line)) { - if (seenImport) { - sawBlankGroupSeparator = true; - endLine = cursor + 1; - } - cursor += 1; - continue; - } - if (isLineComment(line)) { - if (seenImport) { - endLine = cursor + 1; - } - cursor += 1; - continue; - } - if (isBlockCommentStart(line)) { - if (!seenImport) { - break; - } - const nextCursor = consumeBlockComment(lines, cursor); - endLine = nextCursor; - cursor = nextCursor; - continue; - } - const parsed = parseUseStatement(line, order); - if (!parsed) { - break; - } - entries.push(parsed); - seenImport = true; - endLine = cursor + 1; - cursor += 1; - order += 1; - } - - if (entries.length === 0) { - return null; - } - - return { - startLine, - endLine, - imports: entries, - entries, - style: { - ...defaultImportStyle, - groupSeparator: sawBlankGroupSeparator, - groupOrder: detectGroupOrder(entries), - sorted: true, - }, - newline: detectNewline(content), - }; -} - -function parseExisting(content: string): ImportRegion | null { - return parseExistingRegion(content); -} - -function renderSimpleExpression(path: string, alias?: string): string { - return alias ? `${path} as ${alias}` : path; -} - -function renderUseLine(entry: RustImportEntry): string { - if (entry.kind === "group") { - const prefix = entry.prefix ?? entry.path; - return `use ${prefix}::{${entry.members.join(", ")}};`; - } - return `use ${renderSimpleExpression(entry.path, entry.alias)};`; -} - -function specToLine(spec: ImportSpec, _style: ImportStyle): string { - if (spec.imports && spec.imports.length > 0) { - const members = spec.imports.map(item => normalizeMemberName(item)).filter(item => item.length > 0); - return `use ${spec.from}::{${members.join(", ")}};`; - } - return `use ${renderSimpleExpression(spec.from, spec.alias)};`; -} - -function trimLeadingBlankLines(lines: string[]): string[] { - let index = 0; - while (index < lines.length && isBlank(lines[index] ?? "")) { - index += 1; - } - return lines.slice(index); -} - -function createSimpleEntry(path: string, alias: string | undefined, order: number): RustImportEntry { - return { - raw: `use ${renderSimpleExpression(path, alias)};`, - source: path, - names: [], - alias, - path, - members: [], - kind: "simple", - group: classifyGroup(path), - order, - }; -} - -function createGroupEntry(prefix: string, members: string[], order: number): RustImportEntry { - const normalizedMembers = [ - ...new Set(members.map(member => normalizeMemberName(member)).filter(member => member.length > 0)), - ]; - return { - raw: `use ${prefix}::{${normalizedMembers.join(", ")}};`, - source: prefix, - names: [...normalizedMembers], - path: prefix, - prefix, - members: normalizedMembers, - kind: "group", - group: classifyGroup(prefix), - order, - }; -} - -function normalizeRequestedImports(specs: ImportSpec[]): { entries: RustImportEntry[]; warnings: string[] } { - const warnings: string[] = []; - const entries: RustImportEntry[] = []; - let order = 0; - - for (const spec of specs) { - if (spec.default) { - warnings.push(`Rust imports do not support default imports from ${spec.from}; ignoring default binding.`); - } - if (spec.namespace) { - warnings.push(`Rust imports do not support namespace imports from ${spec.from}; ignoring namespace binding.`); - } - if (spec.system) { - warnings.push(`Rust imports do not use system import mode for ${spec.from}; ignoring system flag.`); - } - - const normalizedFrom = normalizeWhitespace(spec.from); - if (normalizedFrom.length === 0) { - continue; - } - - if (spec.imports && spec.imports.length > 0) { - const members = spec.imports.map(item => normalizeMemberName(item)).filter(item => item.length > 0); - if (members.length === 0) { - continue; - } - entries.push(createGroupEntry(normalizedFrom, members, order)); - order += 1; - continue; - } - - entries.push(createSimpleEntry(normalizedFrom, spec.alias, order)); - order += 1; - } - - return { entries, warnings }; -} - -function simpleKey(path: string, alias: string | undefined): string { - return alias ? `${path} as ${alias}` : path; -} - -function splitSimplePath(path: string): { prefix: string; member: string } | null { - const separatorIndex = path.lastIndexOf("::"); - if (separatorIndex <= 0 || separatorIndex === path.length - 2) { - return null; - } - return { - prefix: path.slice(0, separatorIndex), - member: path.slice(separatorIndex + 2), - }; -} - -function sortMembers(members: Iterable): string[] { - return [...new Set(members)].sort((left, right) => left.localeCompare(right)); -} - -function compareEntries(left: RustImportEntry, right: RustImportEntry): number { - const leftKey = left.kind === "group" ? `${left.path}::{` : renderSimpleExpression(left.path, left.alias); - const rightKey = right.kind === "group" ? `${right.path}::{` : renderSimpleExpression(right.path, right.alias); - return leftKey.localeCompare(rightKey); -} - -function mergeImports( - existing: RustImportEntry[], - requested: RustImportEntry[], -): { merged: RustImportEntry[]; added: RustImportEntry[] } { - const simpleEntries = new Map(); - const aliaslessSimplePaths = new Set(); - const groupEntries = new Map(); - let nextOrder = existing.length; - - for (const entry of existing) { - if (entry.kind === "simple") { - simpleEntries.set(simpleKey(entry.path, entry.alias), createSimpleEntry(entry.path, entry.alias, entry.order)); - if (!entry.alias) { - aliaslessSimplePaths.add(entry.path); - } - continue; - } - - const prior = groupEntries.get(entry.path); - if (!prior) { - groupEntries.set(entry.path, createGroupEntry(entry.path, entry.members, entry.order)); - continue; - } - prior.members = sortMembers([...prior.members, ...entry.members]); - prior.names = [...prior.members]; - } - - const addedSimple = new Map(); - const addedGroupMembers = new Map>(); - - for (const entry of requested) { - if (entry.kind === "simple") { - const key = simpleKey(entry.path, entry.alias); - if (simpleEntries.has(key)) { - continue; - } - if (!entry.alias) { - const pathParts = splitSimplePath(entry.path); - if (pathParts) { - const grouped = groupEntries.get(pathParts.prefix); - if (grouped?.members.includes(pathParts.member)) { - continue; - } - } - } - const normalizedEntry = createSimpleEntry(entry.path, entry.alias, nextOrder); - nextOrder += 1; - simpleEntries.set(key, normalizedEntry); - addedSimple.set(key, normalizedEntry); - if (!entry.alias) { - aliaslessSimplePaths.add(entry.path); - } - continue; - } - - let mergedGroup = groupEntries.get(entry.path); - if (!mergedGroup) { - mergedGroup = createGroupEntry(entry.path, [], nextOrder); - nextOrder += 1; - groupEntries.set(entry.path, mergedGroup); - } - - const groupAdditions = addedGroupMembers.get(entry.path) ?? new Set(); - for (const member of entry.members) { - const simplePath = `${entry.path}::${member}`; - if (aliaslessSimplePaths.has(simplePath) || mergedGroup.members.includes(member)) { - continue; - } - mergedGroup.members = sortMembers([...mergedGroup.members, member]); - mergedGroup.names = [...mergedGroup.members]; - groupAdditions.add(member); - } - if (groupAdditions.size > 0) { - addedGroupMembers.set(entry.path, groupAdditions); - } - } - - const merged = [...simpleEntries.values(), ...groupEntries.values()].sort(compareEntries); - const added = [ - ...addedSimple.values(), - ...Array.from(addedGroupMembers.entries(), ([prefix, members]) => { - const entry = createGroupEntry(prefix, sortMembers(members), nextOrder); - nextOrder += 1; - return entry; - }), - ].sort(compareEntries); - return { merged, added }; -} - -function buildImportSection(entries: RustImportEntry[], style: ImportStyle): string[] { - const requestedOrder = style.groupOrder.filter( - (group): group is RustImportGroup => group === "std" || group === "external" || group === "local", - ); - const groupOrder = [...requestedOrder]; - for (const fallbackGroup of DEFAULT_GROUP_ORDER) { - if (!groupOrder.includes(fallbackGroup)) { - groupOrder.push(fallbackGroup); - } - } - - const lines: string[] = []; - let emittedGroups = 0; - for (const group of groupOrder) { - const groupEntries = entries.filter(entry => entry.group === group).sort(compareEntries); - if (groupEntries.length === 0) { - continue; - } - if (style.groupSeparator && emittedGroups > 0) { - lines.push(""); - } - for (const entry of groupEntries) { - lines.push(renderUseLine(entry)); - } - emittedGroups += 1; - } - return lines; -} - -function apply(content: string, specs: ImportSpec[]): { content: string; added: string[]; warnings: string[] } { - const requested = normalizeRequestedImports(specs); - const parsedRegion = parseExistingRegion(content); - const style: ImportStyle = parsedRegion?.style ?? { - ...defaultImportStyle, - groupSeparator: true, - groupOrder: [...DEFAULT_GROUP_ORDER], - sorted: true, - }; - const mergeResult = mergeImports(parsedRegion?.entries ?? [], requested.entries); - if (mergeResult.added.length === 0) { - return { content, added: [], warnings: requested.warnings }; - } - - const lines = splitLines(content); - const newline = parsedRegion?.newline ?? detectNewline(content); - const trailingNewline = hasTrailingNewline(content); - const importSection = buildImportSection(mergeResult.merged, style); - let nextLines: string[]; - - if (parsedRegion) { - const before = lines.slice(0, parsedRegion.startLine - 1); - const after = trimLeadingBlankLines(lines.slice(parsedRegion.endLine)); - nextLines = [...before, ...importSection]; - if (after.length > 0 && !isBlank(nextLines[nextLines.length - 1] ?? "")) { - nextLines.push(""); - } - nextLines.push(...after); - } else { - const insertionLine = findPrefixEnd(lines); - const before = lines.slice(0, insertionLine); - const after = trimLeadingBlankLines(lines.slice(insertionLine)); - nextLines = [...before]; - if (nextLines.length > 0 && !isBlank(nextLines[nextLines.length - 1] ?? "")) { - nextLines.push(""); - } - nextLines.push(...importSection); - if (after.length > 0) { - nextLines.push(""); - nextLines.push(...after); - } - } - - return { - content: joinLines(nextLines, newline, trailingNewline), - added: mergeResult.added.map(entry => renderUseLine(entry)), - warnings: requested.warnings, - }; -} - -export const rustImportHandler: ImportHandler = { - parseExisting, - specToLine, - apply, -}; - -export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/imports/types.ts b/packages/coding-agent/src/patch/imports/types.ts deleted file mode 100644 index 6bf67bc3a..000000000 --- a/packages/coding-agent/src/patch/imports/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ImportSpec } from "../types"; - -export interface ImportStyle { - groupSeparator: boolean; - groupOrder: string[]; - sorted: boolean; - typeImportStyle?: "separate" | "inline"; - quoteStyle?: "single" | "double"; - semicolons?: boolean; -} - -export interface ParsedImport { - raw: string; - source: string; - names: string[]; - defaultImport?: string; - namespaceImport?: string; - typeOnly?: boolean; - assertion?: string; - system?: boolean; - alias?: string; -} - -export interface ImportRegion { - startLine: number; - endLine: number; - imports: ParsedImport[]; - style: ImportStyle; -} - -export interface ImportApplyResult { - content: string; - added: string[]; - warnings: string[]; -} - -export interface ImportHandler { - parseExisting(content: string): ImportRegion | null; - specToLine(spec: ImportSpec, style: ImportStyle): string; - apply(content: string, specs: ImportSpec[]): ImportApplyResult; -} - -export const defaultImportStyle: ImportStyle = { - groupSeparator: true, - groupOrder: [], - sorted: true, -}; diff --git a/packages/coding-agent/src/patch/imports/typescript.ts b/packages/coding-agent/src/patch/imports/typescript.ts deleted file mode 100644 index fccbb9ac3..000000000 --- a/packages/coding-agent/src/patch/imports/typescript.ts +++ /dev/null @@ -1,1150 +0,0 @@ -import type { ImportSpec } from "../types"; -import { - defaultImportStyle, - type ImportHandler, - type ImportRegion, - type ImportStyle, - type ParsedImport, -} from "./types"; - -type ImportGroup = "side-effect" | "external" | "local"; -type QuoteStyle = "single" | "double"; -type TypeImportStyle = "separate" | "inline"; - -interface ImportedBinding { - imported: string; - local?: string; - typeOnly: boolean; - order: number; -} - -interface TypeScriptImportStyle extends ImportStyle { - typeImportStyle: TypeImportStyle; - quoteStyle: QuoteStyle; - semicolons: boolean; -} - -interface ParsedTypeScriptImport extends ParsedImport { - kind: "side-effect" | "binding"; - typeOnly: boolean; - order: number; - startLine: number; - endLine: number; - group: ImportGroup; - quoteStyle: QuoteStyle; - hasSemicolon: boolean; - namedBindings: ImportedBinding[]; - assertion?: string; -} - -interface ParsedTypeScriptRegion extends ImportRegion { - imports: ParsedTypeScriptImport[]; - style: TypeScriptImportStyle; -} - -interface ParsedImportStatement { - kind: "side-effect" | "binding"; - typeOnly: boolean; - source: string; - quoteStyle: QuoteStyle; - assertion?: string; - defaultImport?: string; - namespaceImport?: string; - namedBindings: ImportedBinding[]; -} - -interface ParsedSpecifierName { - imported: string; - local?: string; -} - -interface ParsedSpecRequest { - source: string; - defaultImport?: string; - namespaceImport?: string; - valueBindings: ImportedBinding[]; - typeBindings: ImportedBinding[]; - assertion?: string; -} - -const DEFAULT_GROUP_ORDER: ImportGroup[] = ["side-effect", "external", "local"]; -const ASSET_IMPORT_TYPES: Record = { - ".adoc": "text", - ".css": "css", - ".json": "json", - ".md": "text", - ".txt": "text", -}; - -function getTypeScriptStyle(style: ImportStyle): TypeScriptImportStyle { - const maybeStyle = style as Partial; - return { - ...style, - typeImportStyle: maybeStyle.typeImportStyle ?? "inline", - quoteStyle: maybeStyle.quoteStyle ?? "double", - semicolons: maybeStyle.semicolons ?? true, - }; -} - -function detectEol(content: string): string { - return content.includes("\r\n") ? "\r\n" : "\n"; -} - -function isBlankLine(line: string): boolean { - return line.trim().length === 0; -} - -function isLineComment(line: string): boolean { - return line.trimStart().startsWith("//"); -} - -function startsBlockComment(line: string): boolean { - return line.trimStart().startsWith("/*"); -} - -function lineStartsImport(line: string): boolean { - return line.trimStart().startsWith("import "); -} - -function isImportGroup(value: string): value is ImportGroup { - return value === "side-effect" || value === "external" || value === "local"; -} - -function classifyGroup(record: Pick): ImportGroup { - if (record.kind === "side-effect") return "side-effect"; - if (record.source.startsWith(".") || record.source.startsWith("/")) return "local"; - return "external"; -} - -function findPrefixEnd(lines: string[]): number { - let index = 0; - if (lines[0]?.startsWith("#!")) index += 1; - while (index < lines.length) { - const line = lines[index] ?? ""; - if (isBlankLine(line) || isLineComment(line)) { - index += 1; - continue; - } - if (startsBlockComment(line)) { - index += 1; - while (index < lines.length && !(lines[index - 1] ?? "").includes("*/")) index += 1; - continue; - } - break; - } - return index; -} - -function stripComments(text: string): string { - let result = ""; - let index = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - let inBlockComment = false; - let escaped = false; - while (index < text.length) { - const char = text[index] ?? ""; - const next = text[index + 1] ?? ""; - if (inBlockComment) { - if (char === "*" && next === "/") { - inBlockComment = false; - index += 2; - continue; - } - if (char === "\n" || char === "\r") result += char; - index += 1; - continue; - } - if (inSingle) { - result += char; - if (escaped) { - escaped = false; - } else if (char === "\\") { - escaped = true; - } else if (char === "'") { - inSingle = false; - } - index += 1; - continue; - } - if (inDouble) { - result += char; - if (escaped) { - escaped = false; - } else if (char === "\\") { - escaped = true; - } else if (char === '"') { - inDouble = false; - } - index += 1; - continue; - } - if (inTemplate) { - result += char; - if (escaped) { - escaped = false; - } else if (char === "\\") { - escaped = true; - } else if (char === "`") { - inTemplate = false; - } - index += 1; - continue; - } - if (char === "/" && next === "/") { - index += 2; - while (index < text.length) { - const commentChar = text[index] ?? ""; - if (commentChar === "\n" || commentChar === "\r") break; - index += 1; - } - continue; - } - if (char === "/" && next === "*") { - inBlockComment = true; - index += 2; - continue; - } - result += char; - if (char === "'") inSingle = true; - else if (char === '"') inDouble = true; - else if (char === "`") inTemplate = true; - index += 1; - } - return result; -} - -function parseStringLiteral(text: string): { value: string; quoteStyle: QuoteStyle; nextIndex: number } | null { - const quote = text[0]; - if (quote !== '"' && quote !== "'") return null; - let value = ""; - let index = 1; - let escaped = false; - while (index < text.length) { - const char = text[index] ?? ""; - if (escaped) { - value += char; - escaped = false; - index += 1; - continue; - } - if (char === "\\") { - value += char; - escaped = true; - index += 1; - continue; - } - if (char === quote) { - return { - value, - quoteStyle: quote === "'" ? "single" : "double", - nextIndex: index + 1, - }; - } - value += char; - index += 1; - } - return null; -} - -function scanBalancedState(text: string): { balanced: boolean } { - let braceDepth = 0; - let bracketDepth = 0; - let parenDepth = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - let inBlockComment = false; - let escaped = false; - for (let index = 0; index < text.length; index += 1) { - const char = text[index] ?? ""; - const next = text[index + 1] ?? ""; - if (inBlockComment) { - if (char === "*" && next === "/") { - inBlockComment = false; - index += 1; - } - continue; - } - if (inSingle) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "'") inSingle = false; - continue; - } - if (inDouble) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === '"') inDouble = false; - continue; - } - if (inTemplate) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "`") inTemplate = false; - continue; - } - if (char === "/" && next === "*") { - inBlockComment = true; - index += 1; - continue; - } - if (char === "/" && next === "/") break; - if (char === "'") { - inSingle = true; - continue; - } - if (char === '"') { - inDouble = true; - continue; - } - if (char === "`") { - inTemplate = true; - continue; - } - if (char === "{") braceDepth += 1; - else if (char === "}") braceDepth -= 1; - else if (char === "[") bracketDepth += 1; - else if (char === "]") bracketDepth -= 1; - else if (char === "(") parenDepth += 1; - else if (char === ")") parenDepth -= 1; - } - return { - balanced: - !inSingle && - !inDouble && - !inTemplate && - !inBlockComment && - braceDepth === 0 && - bracketDepth === 0 && - parenDepth === 0, - }; -} - -function findKeywordOutside(text: string, keyword: string): number { - let braceDepth = 0; - let bracketDepth = 0; - let parenDepth = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - let escaped = false; - for (let index = 0; index <= text.length - keyword.length; index += 1) { - const char = text[index] ?? ""; - if (inSingle) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "'") inSingle = false; - continue; - } - if (inDouble) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === '"') inDouble = false; - continue; - } - if (inTemplate) { - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "`") inTemplate = false; - continue; - } - if (char === "'") { - inSingle = true; - continue; - } - if (char === '"') { - inDouble = true; - continue; - } - if (char === "`") { - inTemplate = true; - continue; - } - if (char === "{") { - braceDepth += 1; - continue; - } - if (char === "}") { - braceDepth -= 1; - continue; - } - if (char === "[") { - bracketDepth += 1; - continue; - } - if (char === "]") { - bracketDepth -= 1; - continue; - } - if (char === "(") { - parenDepth += 1; - continue; - } - if (char === ")") { - parenDepth -= 1; - continue; - } - if (braceDepth !== 0 || bracketDepth !== 0 || parenDepth !== 0) continue; - if (text.slice(index, index + keyword.length) !== keyword) continue; - const before = index === 0 ? "" : (text[index - 1] ?? ""); - const after = text[index + keyword.length] ?? ""; - const beforeOk = before.length === 0 || /\s|,|\{|\}/.test(before); - const afterOk = after.length === 0 || /\s|['"]/.test(after); - if (beforeOk && afterOk) return index; - } - return -1; -} - -function splitTopLevel(text: string, delimiter: string): string[] { - const parts: string[] = []; - let current = ""; - let braceDepth = 0; - let bracketDepth = 0; - let parenDepth = 0; - let inSingle = false; - let inDouble = false; - let inTemplate = false; - let escaped = false; - for (let index = 0; index < text.length; index += 1) { - const char = text[index] ?? ""; - if (inSingle) { - current += char; - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "'") inSingle = false; - continue; - } - if (inDouble) { - current += char; - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === '"') inDouble = false; - continue; - } - if (inTemplate) { - current += char; - if (escaped) escaped = false; - else if (char === "\\") escaped = true; - else if (char === "`") inTemplate = false; - continue; - } - if (char === "'") { - inSingle = true; - current += char; - continue; - } - if (char === '"') { - inDouble = true; - current += char; - continue; - } - if (char === "`") { - inTemplate = true; - current += char; - continue; - } - if (char === "{") braceDepth += 1; - else if (char === "}") braceDepth -= 1; - else if (char === "[") bracketDepth += 1; - else if (char === "]") bracketDepth -= 1; - else if (char === "(") parenDepth += 1; - else if (char === ")") parenDepth -= 1; - if (char === delimiter && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) { - parts.push(current); - current = ""; - continue; - } - current += char; - } - parts.push(current); - return parts; -} - -function parseNamedBinding(token: string, order: number, impliedTypeOnly: boolean): ImportedBinding | null { - const trimmed = token.trim(); - if (trimmed.length === 0) return null; - const hasInlineType = trimmed.startsWith("type "); - const typeOnly = impliedTypeOnly || hasInlineType; - const body = hasInlineType ? trimmed.slice(5).trim() : trimmed; - const match = /^(?[A-Za-z_$][\w$]*)(?:\s+as\s+(?[A-Za-z_$][\w$]*))?$/.exec(body); - if (!match?.groups?.imported) return null; - return { - imported: match.groups.imported, - local: match.groups.local, - typeOnly, - order, - }; -} - -function parseSpecifierName(token: string): ParsedSpecifierName | null { - const trimmed = token.trim(); - if (trimmed.length === 0) return null; - const match = /^(?[A-Za-z_$][\w$]*)(?:\s+as\s+(?[A-Za-z_$][\w$]*))?$/.exec(trimmed); - if (!match?.groups?.imported) return null; - return { - imported: match.groups.imported, - local: match.groups.local, - }; -} - -function parseClause(clauseText: string): Omit { - let clause = clauseText.trim(); - let typeOnly = false; - if (clause.startsWith("type ")) { - typeOnly = true; - clause = clause.slice(5).trim(); - } - let defaultImport: string | undefined; - let namespaceImport: string | undefined; - const namedBindings: ImportedBinding[] = []; - const parts = splitTopLevel(clause, ",") - .map(part => part.trim()) - .filter(part => part.length > 0); - let bindingOrder = 0; - if (parts.length === 1) { - const only = parts[0] ?? ""; - if (only.startsWith("{")) { - const inner = only.slice(1, only.endsWith("}") ? -1 : undefined); - for (const token of splitTopLevel(inner, ",")) { - const parsed = parseNamedBinding(token, bindingOrder, typeOnly); - if (!parsed) continue; - namedBindings.push(parsed); - bindingOrder += 1; - } - } else if (only.startsWith("* as ")) { - namespaceImport = only.slice(5).trim(); - } else { - defaultImport = only; - } - } else if (parts.length >= 2) { - defaultImport = parts[0] ?? undefined; - const second = parts[1] ?? ""; - if (second.startsWith("* as ")) { - namespaceImport = second.slice(5).trim(); - } else if (second.startsWith("{")) { - const inner = second.slice(1, second.endsWith("}") ? -1 : undefined); - for (const token of splitTopLevel(inner, ",")) { - const parsed = parseNamedBinding(token, bindingOrder, typeOnly); - if (!parsed) continue; - namedBindings.push(parsed); - bindingOrder += 1; - } - } - } - return { - typeOnly, - defaultImport, - namespaceImport, - namedBindings, - }; -} - -function parseImportStatementText(statement: string): ParsedImportStatement | null { - const withoutComments = stripComments(statement).trim(); - if (!withoutComments.startsWith("import ")) return null; - const normalized = withoutComments.endsWith(";") ? withoutComments.slice(0, -1).trimEnd() : withoutComments; - const remainder = normalized.slice(6).trimStart(); - const sideEffectLiteral = parseStringLiteral(remainder); - if (sideEffectLiteral) { - const assertionText = remainder.slice(sideEffectLiteral.nextIndex).trim(); - return { - kind: "side-effect", - typeOnly: false, - source: sideEffectLiteral.value, - quoteStyle: sideEffectLiteral.quoteStyle, - assertion: assertionText.startsWith("with") ? assertionText : undefined, - namedBindings: [], - }; - } - const fromIndex = findKeywordOutside(remainder, "from"); - if (fromIndex === -1) return null; - const clause = remainder.slice(0, fromIndex).trim(); - const sourceSection = remainder.slice(fromIndex + 4).trimStart(); - const sourceLiteral = parseStringLiteral(sourceSection); - if (!sourceLiteral) return null; - const assertionText = sourceSection.slice(sourceLiteral.nextIndex).trim(); - const parsedClause = parseClause(clause); - return { - kind: "binding", - typeOnly: parsedClause.typeOnly, - source: sourceLiteral.value, - quoteStyle: sourceLiteral.quoteStyle, - assertion: assertionText.startsWith("with") ? assertionText : undefined, - defaultImport: parsedClause.defaultImport, - namespaceImport: parsedClause.namespaceImport, - namedBindings: parsedClause.namedBindings, - }; -} - -function collectImportStatement( - lines: string[], - startIndex: number, -): { statement: string; nextIndex: number; endLine: number; hasSemicolon: boolean } | null { - const collected: string[] = []; - for (let index = startIndex; index < lines.length; index += 1) { - collected.push(lines[index] ?? ""); - const joined = collected.join("\n"); - const { balanced } = scanBalancedState(joined); - if (!balanced) continue; - const parsed = parseImportStatementText(joined); - if (!parsed) continue; - const trimmed = stripComments(joined).trimEnd(); - return { - statement: joined, - nextIndex: index + 1, - endLine: index + 1, - hasSemicolon: trimmed.endsWith(";"), - }; - } - return null; -} - -function hasBlankLineBetween(lines: string[], startIndex: number, endIndex: number): boolean { - for (let index = startIndex; index < endIndex; index += 1) { - if (isBlankLine(lines[index] ?? "")) return true; - } - return false; -} - -function inferStyle(records: ParsedTypeScriptImport[], lines: string[]): TypeScriptImportStyle { - const singleQuotes = records.filter(record => record.quoteStyle === "single").length; - const semicolons = records.filter(record => record.hasSemicolon).length; - const separateTypeStatements = records.filter(record => record.kind === "binding" && record.typeOnly).length; - const inlineTypeBindings = records.reduce((count, record) => { - if (record.kind !== "binding" || record.typeOnly) return count; - return count + record.namedBindings.filter(binding => binding.typeOnly).length; - }, 0); - const groupOrder = records.reduce((order, record) => { - if (!order.includes(record.group)) order.push(record.group); - return order; - }, []); - let groupSeparator = false; - for (let index = 1; index < records.length; index += 1) { - if (hasBlankLineBetween(lines, records[index - 1]!.endLine, records[index]!.startLine - 1)) { - groupSeparator = true; - break; - } - } - return { - ...defaultImportStyle, - groupSeparator, - groupOrder: groupOrder.length > 0 ? groupOrder : [...DEFAULT_GROUP_ORDER], - sorted: true, - typeImportStyle: separateTypeStatements > inlineTypeBindings ? "separate" : "inline", - quoteStyle: singleQuotes > records.length - singleQuotes ? "single" : "double", - semicolons: semicolons >= Math.ceil(records.length / 2), - }; -} - -function normalizeAssertion(assertion: string | undefined): string | undefined { - return assertion?.trim() || undefined; -} - -function cloneBinding(binding: ImportedBinding, order: number): ImportedBinding { - return { - imported: binding.imported, - local: binding.local, - typeOnly: binding.typeOnly, - order, - }; -} - -function cloneRecord(record: ParsedTypeScriptImport, order: number): ParsedTypeScriptImport { - return { - ...record, - order, - startLine: 0, - endLine: 0, - namedBindings: record.namedBindings.map((binding, index) => cloneBinding(binding, index)), - }; -} - -function compareBindings(left: ImportedBinding, right: ImportedBinding): number { - const leftName = `${left.typeOnly ? "1" : "0"}:${left.imported}:${left.local ?? left.imported}`; - const rightName = `${right.typeOnly ? "1" : "0"}:${right.imported}:${right.local ?? right.imported}`; - return leftName.localeCompare(rightName); -} - -function compareRecords(left: ParsedTypeScriptImport, right: ParsedTypeScriptImport): number { - if (left.group !== right.group) return left.group.localeCompare(right.group); - if (left.source !== right.source) return left.source.localeCompare(right.source); - if (left.kind !== right.kind) return left.kind === "side-effect" ? -1 : 1; - if (left.kind === "binding" && right.kind === "binding" && left.typeOnly !== right.typeOnly) - return left.typeOnly ? 1 : -1; - return left.order - right.order; -} - -function formatBinding(binding: ImportedBinding, statementTypeOnly = false): string { - const prefix = !statementTypeOnly && binding.typeOnly ? "type " : ""; - const localPart = binding.local ? ` as ${binding.local}` : ""; - return `${prefix}${binding.imported}${localPart}`; -} - -function formatSource(source: string, quoteStyle: QuoteStyle): string { - const quote = quoteStyle === "single" ? "'" : '"'; - return `${quote}${source}${quote}`; -} - -function renderAssertion(assertion: string | undefined): string { - return assertion ? ` ${assertion}` : ""; -} - -function renderRecord(record: ParsedTypeScriptImport, style: TypeScriptImportStyle): string { - const semicolon = style.semicolons ? ";" : ""; - if (record.kind === "side-effect") { - return `import ${formatSource(record.source, style.quoteStyle)}${renderAssertion(record.assertion)}${semicolon}`; - } - const namedBindings = [...record.namedBindings]; - if (style.sorted) namedBindings.sort(compareBindings); - const namedPart = - namedBindings.length > 0 - ? `{ ${namedBindings.map(binding => formatBinding(binding, record.typeOnly)).join(", ")} }` - : ""; - const defaultPart = record.defaultImport ?? ""; - const namespacePart = record.namespaceImport ? `* as ${record.namespaceImport}` : ""; - const bindingParts = [defaultPart, namespacePart, namedPart].filter(part => part.length > 0); - const typePrefix = record.typeOnly ? "type " : ""; - return `import ${typePrefix}${bindingParts.join(", ")} from ${formatSource(record.source, style.quoteStyle)}${renderAssertion(record.assertion)}${semicolon}`; -} - -function renderImportBlock(records: ParsedTypeScriptImport[], style: TypeScriptImportStyle): string[] { - const groups = new Map(); - for (const record of records) { - const list = groups.get(record.group) ?? []; - list.push(record); - groups.set(record.group, list); - } - const orderedGroups = [...style.groupOrder.filter(isImportGroup), ...DEFAULT_GROUP_ORDER].filter( - (group, index, values) => values.indexOf(group) === index, - ); - const lines: string[] = []; - for (const group of orderedGroups) { - const recordsInGroup = groups.get(group); - if (!recordsInGroup || recordsInGroup.length === 0) continue; - const ordered = [...recordsInGroup]; - if (style.sorted) ordered.sort(compareRecords); - else ordered.sort((left, right) => left.order - right.order); - for (const record of ordered) lines.push(renderRecord(record, style)); - if (style.groupSeparator) lines.push(""); - } - if (lines[lines.length - 1] === "") lines.pop(); - return lines; -} - -function deriveAssertion(source: string): string | undefined { - for (const [extension, type] of Object.entries(ASSET_IMPORT_TYPES)) { - if (!source.toLowerCase().endsWith(extension)) continue; - return `with { type: ${JSON.stringify(type)} }`; - } - return undefined; -} - -function parseImportSpecName(token: string, order: number, typeOnly: boolean): ImportedBinding | null { - const parsed = parseSpecifierName(token); - if (!parsed) return null; - return { - imported: parsed.imported, - local: parsed.local, - typeOnly, - order, - }; -} - -function specToRequest(spec: ImportSpec): ParsedSpecRequest { - const valueBindings: ImportedBinding[] = []; - const typeBindings: ImportedBinding[] = []; - let order = 0; - for (const imported of spec.imports ?? []) { - const trimmed = imported.trim(); - if (trimmed.length === 0) continue; - const isTypeOnly = trimmed.startsWith("type "); - const parsed = parseImportSpecName(isTypeOnly ? trimmed.slice(5).trim() : trimmed, order, isTypeOnly); - if (!parsed) continue; - if (parsed.typeOnly) typeBindings.push(parsed); - else valueBindings.push(parsed); - order += 1; - } - return { - source: spec.from, - defaultImport: spec.default, - namespaceImport: spec.namespace, - valueBindings, - typeBindings, - assertion: deriveAssertion(spec.from), - }; -} - -function requestToRecords( - request: ParsedSpecRequest, - style: TypeScriptImportStyle, - startOrder: number, -): ParsedTypeScriptImport[] { - const records: ParsedTypeScriptImport[] = []; - let order = startOrder; - const group = classifyGroup({ - kind: - request.defaultImport || - request.namespaceImport || - request.valueBindings.length > 0 || - request.typeBindings.length > 0 - ? "binding" - : "side-effect", - source: request.source, - }); - if ( - !request.defaultImport && - !request.namespaceImport && - request.valueBindings.length === 0 && - request.typeBindings.length === 0 - ) { - records.push({ - raw: "", - source: request.source, - names: [], - kind: "side-effect", - typeOnly: false, - order, - startLine: 0, - endLine: 0, - group, - quoteStyle: style.quoteStyle, - hasSemicolon: style.semicolons, - namedBindings: [], - assertion: request.assertion, - }); - return records; - } - if (request.defaultImport || request.namespaceImport || request.valueBindings.length > 0) { - const bindings = request.valueBindings.map((binding, index) => cloneBinding(binding, index)); - if (style.typeImportStyle === "inline") { - for (const typeBinding of request.typeBindings) bindings.push(cloneBinding(typeBinding, bindings.length)); - } - records.push({ - raw: "", - source: request.source, - names: bindings.map(binding => formatBinding(binding, false)), - defaultImport: request.defaultImport, - namespaceImport: request.namespaceImport, - kind: "binding", - typeOnly: false, - order, - startLine: 0, - endLine: 0, - group, - quoteStyle: style.quoteStyle, - hasSemicolon: style.semicolons, - namedBindings: bindings, - assertion: request.assertion, - }); - order += 1; - } - if ( - request.typeBindings.length > 0 && - (style.typeImportStyle === "separate" || - (!request.defaultImport && !request.namespaceImport && request.valueBindings.length === 0)) - ) { - const bindings = request.typeBindings.map((binding, index) => - cloneBinding({ ...binding, typeOnly: false }, index), - ); - records.push({ - raw: "", - source: request.source, - names: bindings.map(binding => formatBinding(binding, true)), - kind: "binding", - typeOnly: true, - order, - startLine: 0, - endLine: 0, - group, - quoteStyle: style.quoteStyle, - hasSemicolon: style.semicolons, - namedBindings: bindings, - assertion: request.assertion, - }); - } - return records; -} - -function bindingKey(binding: ImportedBinding, statementTypeOnly = false): string { - const typeKey = statementTypeOnly || binding.typeOnly ? "type" : "value"; - return `${typeKey}:${binding.imported}:${binding.local ?? binding.imported}`; -} - -function hasBinding( - records: ParsedTypeScriptImport[], - source: string, - binding: ImportedBinding, - statementTypeOnly = false, -): boolean { - return records.some( - record => - record.kind === "binding" && - record.source === source && - record.namedBindings.some( - existing => bindingKey(existing, record.typeOnly) === bindingKey(binding, statementTypeOnly), - ), - ); -} - -function getSourceRecords(records: ParsedTypeScriptImport[], source: string): ParsedTypeScriptImport[] { - return records.filter(record => record.source === source); -} - -function getBindingRecord( - records: ParsedTypeScriptImport[], - source: string, - typeOnly: boolean, -): ParsedTypeScriptImport | undefined { - return records.find(record => record.kind === "binding" && record.source === source && record.typeOnly === typeOnly); -} - -function syncNames(record: ParsedTypeScriptImport): void { - record.names = record.namedBindings.map(binding => formatBinding(binding, record.typeOnly)); -} - -function mergeNamedBindings( - target: ParsedTypeScriptImport, - bindings: ImportedBinding[], - typeOnly: boolean, - style: TypeScriptImportStyle, -): ImportedBinding[] { - const added: ImportedBinding[] = []; - for (const binding of bindings) { - const candidate = typeOnly - ? { ...binding, typeOnly: style.typeImportStyle === "inline" && !target.typeOnly } - : binding; - const existing = target.namedBindings.some( - current => bindingKey(current, target.typeOnly) === bindingKey(candidate, target.typeOnly), - ); - if (existing) continue; - target.namedBindings.push(cloneBinding(candidate, target.namedBindings.length)); - added.push(candidate); - } - if (style.sorted) target.namedBindings.sort(compareBindings); - syncNames(target); - return added; -} - -function buildPreviewRecord( - base: ParsedTypeScriptImport, - parts: { defaultImport?: string; namespaceImport?: string; namedBindings?: ImportedBinding[]; typeOnly?: boolean }, -): ParsedTypeScriptImport { - const namedBindings = parts.namedBindings?.map((binding, index) => cloneBinding(binding, index)) ?? []; - return { - ...base, - defaultImport: parts.defaultImport, - namespaceImport: parts.namespaceImport, - namedBindings, - names: namedBindings.map(binding => formatBinding(binding, parts.typeOnly ?? base.typeOnly)), - typeOnly: parts.typeOnly ?? base.typeOnly, - }; -} - -function mergeRecords( - existing: ParsedTypeScriptImport[], - requested: ParsedTypeScriptImport[], - style: TypeScriptImportStyle, -): { records: ParsedTypeScriptImport[]; added: string[] } { - const records = existing.map((record, index) => cloneRecord(record, index)); - const added: string[] = []; - let nextOrder = records.reduce((max, record) => Math.max(max, record.order), -1) + 1; - for (const record of requested) { - if (record.kind === "side-effect") { - const exists = records.some( - existingRecord => existingRecord.kind === "side-effect" && existingRecord.source === record.source, - ); - if (exists) continue; - const inserted = cloneRecord(record, nextOrder); - records.push(inserted); - added.push(renderRecord(inserted, style)); - nextOrder += 1; - continue; - } - if (record.typeOnly && style.typeImportStyle !== "separate") { - const inlineTarget = getBindingRecord(records, record.source, false); - if (inlineTarget) { - const normalizedBindings = record.namedBindings.map(binding => ({ ...binding, typeOnly: true })); - const missingBindings = normalizedBindings.filter(binding => !hasBinding(records, record.source, binding)); - if (missingBindings.length === 0) continue; - mergeNamedBindings(inlineTarget, normalizedBindings, true, style); - added.push( - renderRecord( - buildPreviewRecord(inlineTarget, { namedBindings: missingBindings, typeOnly: false }), - style, - ), - ); - continue; - } - } - const target = getBindingRecord(records, record.source, record.typeOnly); - if (!target) { - const inserted = cloneRecord(record, nextOrder); - records.push(inserted); - added.push(renderRecord(inserted, style)); - nextOrder += 1; - continue; - } - const addedDefault = - record.defaultImport && - !getSourceRecords(records, record.source).some( - sourceRecord => sourceRecord.defaultImport === record.defaultImport, - ) - ? record.defaultImport - : undefined; - if (addedDefault) target.defaultImport = addedDefault; - const addedNamespace = - record.namespaceImport && - !getSourceRecords(records, record.source).some( - sourceRecord => sourceRecord.namespaceImport === record.namespaceImport, - ) - ? record.namespaceImport - : undefined; - if (addedNamespace) target.namespaceImport = addedNamespace; - const missingBindings = record.namedBindings.filter( - binding => !hasBinding(records, record.source, binding, record.typeOnly), - ); - if (missingBindings.length > 0) mergeNamedBindings(target, missingBindings, record.typeOnly, style); - if (!addedDefault && !addedNamespace && missingBindings.length === 0) continue; - added.push( - renderRecord( - buildPreviewRecord(target, { - defaultImport: addedDefault, - namespaceImport: addedNamespace, - namedBindings: missingBindings, - typeOnly: record.typeOnly, - }), - style, - ), - ); - } - return { records, added }; -} - -function parseTypeScriptRegion(content: string): ParsedTypeScriptRegion | null { - const lines = content.split(/\r?\n/); - const prefixEnd = findPrefixEnd(lines); - let index = prefixEnd; - while (index < lines.length && isBlankLine(lines[index] ?? "")) index += 1; - if (!lineStartsImport(lines[index] ?? "")) return null; - const records: ParsedTypeScriptImport[] = []; - while (index < lines.length) { - const line = lines[index] ?? ""; - if (isBlankLine(line) || isLineComment(line)) { - index += 1; - continue; - } - if (startsBlockComment(line)) { - index += 1; - while (index < lines.length && !(lines[index - 1] ?? "").includes("*/")) index += 1; - continue; - } - if (!lineStartsImport(line)) break; - const collected = collectImportStatement(lines, index); - if (!collected) break; - const parsed = parseImportStatementText(collected.statement); - if (!parsed) break; - records.push({ - raw: collected.statement, - source: parsed.source, - names: parsed.namedBindings.map(binding => formatBinding(binding, parsed.typeOnly)), - defaultImport: parsed.defaultImport, - namespaceImport: parsed.namespaceImport, - typeOnly: parsed.typeOnly, - assertion: normalizeAssertion(parsed.assertion), - kind: parsed.kind, - order: records.length, - startLine: index + 1, - endLine: collected.endLine, - group: classifyGroup({ kind: parsed.kind, source: parsed.source }), - quoteStyle: parsed.quoteStyle, - hasSemicolon: collected.hasSemicolon, - namedBindings: parsed.namedBindings.map((binding, bindingIndex) => cloneBinding(binding, bindingIndex)), - }); - index = collected.nextIndex; - } - if (records.length === 0) return null; - return { - startLine: records[0]!.startLine, - endLine: Math.max(...records.map(record => record.endLine)), - imports: records, - style: inferStyle(records, lines), - }; -} - -function parseExisting(content: string): ImportRegion | null { - return parseTypeScriptRegion(content); -} - -function specToLine(spec: ImportSpec, style: ImportStyle): string { - const tsStyle = getTypeScriptStyle(style); - const request = specToRequest(spec); - const records = requestToRecords(request, tsStyle, 0); - return records.map(record => renderRecord(record, tsStyle)).join("\n"); -} - -function insertWithoutRegion(content: string, lines: string[], blockLines: string[], eol: string): string { - if (content.length === 0) return blockLines.join(eol); - const insertAt = findPrefixEnd(lines); - const before = lines.slice(0, insertAt); - const after = lines.slice(insertAt); - const needsTrailingBlank = - after.length > 0 && after.some(line => line.trim().length > 0) && !isBlankLine(after[0] ?? ""); - const nextLines = [...before, ...blockLines, ...(needsTrailingBlank ? [""] : []), ...after]; - const nextContent = nextLines.join(eol); - if (content.endsWith(eol) && !nextContent.endsWith(eol)) return `${nextContent}${eol}`; - return nextContent; -} - -function replaceRegion( - content: string, - lines: string[], - region: ParsedTypeScriptRegion, - blockLines: string[], - eol: string, -): string { - const nextLines = [...lines.slice(0, region.startLine - 1), ...blockLines, ...lines.slice(region.endLine)]; - const nextContent = nextLines.join(eol); - if (content.endsWith(eol) && !nextContent.endsWith(eol)) return `${nextContent}${eol}`; - return nextContent; -} - -export const typescriptImportHandler: ImportHandler = { - parseExisting, - specToLine, - apply(content, specs) { - const region = parseTypeScriptRegion(content); - const style = getTypeScriptStyle( - region?.style ?? { - ...defaultImportStyle, - groupSeparator: true, - groupOrder: [...DEFAULT_GROUP_ORDER], - sorted: true, - typeImportStyle: "inline", - quoteStyle: "double", - semicolons: true, - }, - ); - const requested: ParsedTypeScriptImport[] = []; - let order = 0; - for (const spec of specs) { - const request = specToRequest(spec); - const records = requestToRecords(request, style, order); - requested.push(...records); - order += Math.max(records.length, 1); - } - if (requested.length === 0) return { content, added: [], warnings: [] }; - const { records, added } = mergeRecords(region?.imports ?? [], requested, style); - if (added.length === 0) return { content, added: [], warnings: [] }; - const eol = detectEol(content); - const lines = content.split(/\r?\n/); - const blockLines = renderImportBlock(records, style); - const nextContent = region - ? replaceRegion(content, lines, region, blockLines, eol) - : insertWithoutRegion(content, lines, blockLines, eol); - return { content: nextContent, added, warnings: [] }; - }, -}; - -export { defaultImportStyle }; diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index a80089173..0b47ee05f 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -44,11 +44,10 @@ import { type HashlineEdit, parseTag, } from "./hashline"; -// Internal imports -import { applyImports } from "./imports"; import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize"; import { type EditToolDetails, getLspBatchRequest } from "./shared"; -import type { FileSystem, ImportSpec, Operation, PatchInput } from "./types"; +// Internal imports +import type { FileSystem, Operation, PatchInput } from "./types"; import { EditMatchError } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ @@ -64,7 +63,6 @@ export * from "./diff"; export * from "./fuzzy"; // Hashline export * from "./hashline"; -export * from "./imports"; // Normalization export * from "./normalize"; // Parsing @@ -78,44 +76,14 @@ export * from "./types"; // Schemas // ═══════════════════════════════════════════════════════════════════════════ -const importSpecSchema = Type.Object( - { - from: Type.String({ description: "Module/crate/package path" }), - imports: Type.Optional( - Type.Array(Type.String(), { - description: "Named imports. Prefix TS type imports with `type ` (for example `type FC`)", - }), - ), - default: Type.Optional(Type.String({ description: "Default import name (TS/JS)" })), - namespace: Type.Optional(Type.String({ description: "Namespace import name (TS/JS)" })), - alias: Type.Optional(Type.String({ description: "Import alias (Go/Python)" })), - system: Type.Optional(Type.Boolean({ description: "System include for C/C++ headers" })), - }, - { additionalProperties: false }, -); - -const replaceEditSchemaBase = Type.Object({ +const replaceEditSchema = Type.Object({ path: Type.String({ description: "File path (relative or absolute)" }), old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }), new_text: Type.String({ description: "Replacement text" }), all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })), }); -const replaceEditSchemaWithImports = Type.Object( - { - ...replaceEditSchemaBase.properties, - imports: Type.Optional( - Type.Array(importSpecSchema, { description: "Imports to add or merge into the file after editing" }), - ), - }, - { additionalProperties: false }, -); - -function buildReplaceEditSchema(importsEnabled: boolean) { - return importsEnabled ? replaceEditSchemaWithImports : replaceEditSchemaBase; -} - -const patchEditSchemaBase = Type.Object({ +const patchEditSchema = Type.Object({ path: Type.String({ description: "File path" }), op: Type.Optional( StringEnum(["create", "delete", "update"], { @@ -126,22 +94,8 @@ const patchEditSchemaBase = Type.Object({ diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })), }); -const patchEditSchemaWithImports = Type.Object( - { - ...patchEditSchemaBase.properties, - imports: Type.Optional( - Type.Array(importSpecSchema, { description: "Imports to add or merge into the file after editing" }), - ), - }, - { additionalProperties: false }, -); - -function buildPatchEditSchema(importsEnabled: boolean) { - return importsEnabled ? patchEditSchemaWithImports : patchEditSchemaBase; -} - -export type ReplaceParams = Static; -export type PatchParams = Static; +export type ReplaceParams = Static; +export type PatchParams = Static; /** * Pattern matching hashline display format prefixes: `LINE#ID:CONTENT`, `#ID:CONTENT`, and `+ID:CONTENT`. @@ -250,7 +204,7 @@ const hashlineEditSchema = Type.Object( { additionalProperties: false }, ); -const hashlineEditParamsSchemaBase = Type.Object( +const hashlineEditParamsSchema = Type.Object( { path: Type.String({ description: "path" }), edits: Type.Array(hashlineEditSchema, { description: "edits over $path" }), @@ -260,22 +214,8 @@ const hashlineEditParamsSchemaBase = Type.Object( { additionalProperties: false }, ); -const hashlineEditParamsSchemaWithImports = Type.Object( - { - ...hashlineEditParamsSchemaBase.properties, - imports: Type.Optional( - Type.Array(importSpecSchema, { description: "Imports to add or merge into the file after editing" }), - ), - }, - { additionalProperties: false }, -); - -function buildHashlineEditParamsSchema(importsEnabled: boolean) { - return importsEnabled ? hashlineEditParamsSchemaWithImports : hashlineEditParamsSchemaBase; -} - export type HashlineToolEdit = Static; -export type HashlineParams = Static; +export type HashlineParams = Static; // ═══════════════════════════════════════════════════════════════════════════ // Resilient anchor resolution @@ -417,31 +357,11 @@ function mergeDiagnosticsWithWarnings( }; } -function applyRequestedImports( - filePath: string, - content: string, - imports: ImportSpec[] | undefined, - importsEnabled: boolean, -): { content: string; warnings: string[] } { - if (!importsEnabled || !imports || imports.length === 0) { - return { content, warnings: [] }; - } - - const result = applyImports(filePath, content, imports); - return { content: result.content, warnings: result.warnings }; -} - // ═══════════════════════════════════════════════════════════════════════════ // Tool Class // ═══════════════════════════════════════════════════════════════════════════ -type TInput = - | typeof replaceEditSchemaBase - | typeof replaceEditSchemaWithImports - | typeof patchEditSchemaBase - | typeof patchEditSchemaWithImports - | typeof hashlineEditParamsSchemaBase - | typeof hashlineEditParamsSchemaWithImports; +type TInput = typeof replaceEditSchema | typeof patchEditSchema | typeof hashlineEditParamsSchema; export type EditMode = "replace" | "patch" | "hashline"; @@ -459,15 +379,6 @@ function isReplaceParams(params: ReplaceParams | PatchParams | HashlineParams): return "old_text" in params && "new_text" in params; } -function getRequestedImports(params: ReplaceParams | PatchParams | HashlineParams): ImportSpec[] | undefined { - if (!("imports" in params)) { - return undefined; - } - - const { imports } = params as { imports?: unknown }; - return Array.isArray(imports) ? (imports as ImportSpec[]) : undefined; -} - /** * Edit tool implementation. * @@ -482,12 +393,8 @@ export class EditTool implements AgentTool { readonly #allowFuzzy: boolean; readonly #fuzzyThreshold: number; - readonly #manageImports: boolean; readonly #writethrough: WritethroughCallback; readonly #editMode?: EditMode | null; - readonly #replaceSchema: TInput; - readonly #patchSchema: TInput; - readonly #hashlineSchema: TInput; constructor(private readonly session: ToolSession) { const { @@ -530,10 +437,6 @@ export class EditTool implements AgentTool { } break; } - this.#manageImports = session.settings.get("edit.manageImports"); - this.#replaceSchema = buildReplaceEditSchema(this.#manageImports); - this.#patchSchema = buildPatchEditSchema(this.#manageImports); - this.#hashlineSchema = buildHashlineEditParamsSchema(this.#manageImports); const enableLsp = session.enableLsp ?? true; const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnEdit"); @@ -566,14 +469,13 @@ export class EditTool implements AgentTool { * Dynamic description based on current edit mode (which depends on current model). */ get description(): string { - const promptContext = { editManageImportsEnabled: this.#manageImports }; switch (this.mode) { case "patch": - return renderPromptTemplate(patchDescription, promptContext); + return renderPromptTemplate(patchDescription); case "hashline": - return renderPromptTemplate(hashlineDescription, promptContext); + return renderPromptTemplate(hashlineDescription); default: - return renderPromptTemplate(replaceDescription, promptContext); + return renderPromptTemplate(replaceDescription); } } @@ -583,11 +485,11 @@ export class EditTool implements AgentTool { get parameters(): TInput { switch (this.mode) { case "patch": - return this.#patchSchema; + return patchEditSchema; case "hashline": - return this.#hashlineSchema; + return hashlineEditParamsSchema; default: - return this.#replaceSchema; + return replaceEditSchema; } } @@ -608,7 +510,6 @@ export class EditTool implements AgentTool { throw new Error("Invalid edit parameters for hashline mode."); } const { path, edits, delete: deleteFile, move } = params; - const imports = getRequestedImports(params); enforcePlanModeWrite(this.session, path, { op: deleteFile ? "delete" : "update", move }); @@ -673,13 +574,9 @@ export class EditTool implements AgentTool { throw new Error(`File not found: ${path}`); } } - const createdResult = applyRequestedImports(move ?? path, lines.join("\n"), imports, this.#manageImports); - await fs.writeFile(absolutePath, createdResult.content); - const warningsBlock = createdResult.warnings.length - ? `\n\nWarnings:\n${createdResult.warnings.join("\n")}` - : ""; + await fs.writeFile(absolutePath, lines.join("\n")); return { - content: [{ type: "text", text: `Created ${path}${warningsBlock}` }], + content: [{ type: "text", text: `Created ${path}` }], details: { diff: "", op: "create", @@ -701,11 +598,10 @@ export class EditTool implements AgentTool { const anchorResult = applyHashlineEdits(normalizedText, anchorEdits); normalizedText = anchorResult.lines; - const importResult = applyRequestedImports(move ?? path, normalizedText, imports, this.#manageImports); const result = { - text: importResult.content, + text: normalizedText, firstChangedLine: anchorResult.firstChangedLine, - warnings: [...(anchorResult.warnings ?? []), ...importResult.warnings], + warnings: anchorResult.warnings, noopEdits: anchorResult.noopEdits, }; if (originalNormalized === result.text && !move) { @@ -815,7 +711,6 @@ export class EditTool implements AgentTool { throw new Error("Invalid edit parameters for patch mode."); } const { path, op: rawOp, rename, diff } = params; - const imports = getRequestedImports(params); // Normalize unrecognized operations to "update" const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update"; @@ -841,36 +736,6 @@ export class EditTool implements AgentTool { fuzzyThreshold: this.#fuzzyThreshold, allowFuzzy: this.#allowFuzzy, }); - const writePath = result.change.newPath ?? resolvedPath; - let importDiagnostics: FileDiagnosticsResult | undefined; - const importWarnings: string[] = []; - if ( - imports && - imports.length > 0 && - result.change.type !== "delete" && - result.change.newContent !== undefined - ) { - const importResult = applyRequestedImports( - result.change.newPath ?? path, - result.change.newContent, - imports, - this.#manageImports, - ); - importWarnings.push(...importResult.warnings); - if (importResult.content !== result.change.newContent) { - result.change.newContent = importResult.content; - importDiagnostics = await this.#writethrough( - writePath, - result.change.newContent, - signal, - Bun.file(writePath), - batchRequest, - ); - } else { - result.change.newContent = importResult.content; - } - } - if (resolvedRename) { invalidateFsScanAfterRename(resolvedPath, resolvedRename); } else if (result.change.type === "delete") { @@ -904,12 +769,12 @@ export class EditTool implements AgentTool { break; } - let diagnostics = importDiagnostics ?? fs.getDiagnostics(); + let diagnostics = fs.getDiagnostics(); if (op === "delete" && batchRequest?.flush) { const flushedDiagnostics = await flushLspWritethroughBatch(batchRequest.id, this.session.cwd, signal); diagnostics ??= flushedDiagnostics; } - const patchWarnings = [...(result.warnings ?? []), ...importWarnings]; + const patchWarnings = result.warnings ?? []; const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, patchWarnings); const meta = outputMeta() @@ -936,7 +801,6 @@ export class EditTool implements AgentTool { throw new Error("Invalid edit parameters for replace mode."); } const { path, old_text, new_text, all } = params; - const imports = getRequestedImports(params); enforcePlanModeWrite(this.session, path); @@ -996,8 +860,7 @@ export class EditTool implements AgentTool { ); } - const importResult = applyRequestedImports(path, result.content, imports, this.#manageImports); - const finalContent = bom + restoreLineEndings(importResult.content, originalEnding); + const finalContent = bom + restoreLineEndings(result.content, originalEnding); const diagnostics = await this.#writethrough( absolutePath, finalContent, @@ -1006,8 +869,7 @@ export class EditTool implements AgentTool { batchRequest, ); invalidateFsScanAfterWrite(absolutePath); - const diffResult = generateDiffString(normalizedContent, importResult.content); - const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, importResult.warnings); + const diffResult = generateDiffString(normalizedContent, result.content); const resultText = result.count > 1 @@ -1015,17 +877,12 @@ export class EditTool implements AgentTool { : `Successfully replaced text in ${path}.`; const meta = outputMeta() - .diagnostics(mergedDiagnostics?.summary ?? "", mergedDiagnostics?.messages ?? []) + .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) .get(); return { content: [{ type: "text", text: resultText }], - details: { - diff: diffResult.diff, - firstChangedLine: diffResult.firstChangedLine, - diagnostics: mergedDiagnostics, - meta, - }, + details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine, diagnostics, meta }, }; } } diff --git a/packages/coding-agent/src/patch/types.ts b/packages/coding-agent/src/patch/types.ts index e848e8a2a..d612c3ff7 100644 --- a/packages/coding-agent/src/patch/types.ts +++ b/packages/coding-agent/src/patch/types.ts @@ -174,26 +174,6 @@ export interface ApplyPatchOptions { fs?: FileSystem; } -// ═══════════════════════════════════════════════════════════════════════════ -// Import Management Types -// ═══════════════════════════════════════════════════════════════════════════ - -/** Structured import request supplied alongside an edit operation. */ -export interface ImportSpec { - /** Module/crate/package path or header name */ - from: string; - /** Named imports; TS type imports use a `type ` prefix */ - imports?: string[]; - /** Default import name (TS/JS) */ - default?: string; - /** Namespace import name (TS/JS) */ - namespace?: string; - /** Import alias (Go/Python) */ - alias?: string; - /** Whether a C/C++ include is a system include */ - system?: boolean; -} - // ═══════════════════════════════════════════════════════════════════════════ // Diff Generation Types // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index cba7cb2f5..1845dff40 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -8,16 +8,6 @@ Read the file first. Copy anchors exactly from the latest `read` output. In one - `move` — optional rename target - `delete` — optional whole-file delete - `edits` — array of `{ loc, content }` entries -{{#if editManageImportsEnabled}} -- `imports` — **OPTIONAL** import/include declarations to merge after the main edit -**Import entry** -- `from` — **REQUIRED** module/crate/package/header source -- `imports` — **OPTIONAL** named imports -- `default` — **OPTIONAL** default import name -- `namespace` — **OPTIONAL** namespace import name -- `alias` — **OPTIONAL** import alias where supported -- `system` — **OPTIONAL** system-include flag for C/C++ -{{/if}} **Edit entry**: `{ loc, content }` - `loc` — where to apply the edit (see below) @@ -27,11 +17,7 @@ Read the file first. Copy anchors exactly from the latest `read` output. In one - `"append"` / `"prepend"` — insert at end/start of file - `{ append: "N#ID" }` / `{ prepend: "N#ID" }` — insert after/before anchored line - `{ range: { pos: "N#ID", end: "N#ID" } }` — replace inclusive range of lines `pos..end` with new content - -{{#if editManageImportsEnabled}} -`imports` are merged after the main edit is applied. Use them when inserted or replaced code now depends on new imports/includes. -{{/if}} - + All examples below reference the same file: @@ -56,28 +42,6 @@ All examples below reference the same file: {{hline 18 "}"}} ``` -{{#if editManageImportsEnabled}} - -Replace only the catch body. Do not target the shared boundary line `} catch (err) {`. -``` -{ - path: "a.ts", - edits: [{ - loc: { range: { pos: {{href 15 "\t\tconsole.error(err);"}}, end: {{href 16 "\t\treturn null;"}} } }, - content: [ - "\t\tif (isEnoent(err)) return null;", - "\t\tthrow err;" - ] - }], - imports: [{ - from: "./errors", - imports: ["isEnoent"] - }] -} -``` -`imports` are merged after the anchored edit, so existing declarations are reused before new ones are inserted. - -{{else}} Replace only the catch body. Do not target the shared boundary line `} catch (err) {`. ``` @@ -93,7 +57,6 @@ Replace only the catch body. Do not target the shared boundary line `} catch (er } ``` -{{/if}} Replace the entire body of `alpha`, including its closing `}`. `end` **MUST** be {{href 7 "}"}} because `content` includes `}`. @@ -165,4 +128,4 @@ When adding a sibling declaration, prefer `prepend` on the next declaration. - For a range, either replace only the body or replace the whole range. Do not split range boundaries. - `content` must be literal file content with matching indentation. If the file uses tabs, use real tabs. - You **MUST NOT** use this tool to reformat or clean up unrelated code. **ALWAYS** use project-specific tooling like linters or code formatters which are much more efficient and reliable. - + diff --git a/packages/coding-agent/src/prompts/tools/patch.md b/packages/coding-agent/src/prompts/tools/patch.md index 191c0bef4..6ad8b460e 100644 --- a/packages/coding-agent/src/prompts/tools/patch.md +++ b/packages/coding-agent/src/prompts/tools/patch.md @@ -14,12 +14,6 @@ Patches files given diff hunks. Primary tool for existing-file edits. **Context Lines:** Use enough ` `-prefixed lines to make match unique (usually 2–8) When editing structured blocks (nested braces, tags, indented regions), include opening and closing lines so edit stays inside block -{{#if editManageImportsEnabled}} -**Imports:** -- `imports` is **OPTIONAL**. Use it when the edit adds code that now requires new imports/includes. -- Each entry starts with `from` and **MAY** include named `imports`, `default`, `namespace`, `alias`, or `system`, depending on language. -- Import management runs after the main edit, so the diff should focus on the code change and `imports` should describe declarations to merge or add. -{{/if}} @@ -29,22 +23,13 @@ type T = // - Each hunk begins with "@@" (anchor optional). // - Each hunk body only has lines starting with ' ' | '+' | '-'. // - Each hunk includes at least one change (+ or -). - | { path: string, op: "update", diff: string{{#if editManageImportsEnabled}}, imports?: ImportSpec[]{{/if}} } + | { path: string, op: "update", diff: string } // Diff is full file content, no prefixes. - | { path: string, op: "create", diff: string{{#if editManageImportsEnabled}}, imports?: ImportSpec[]{{/if}} } + | { path: string, op: "create", diff: string } // No diff for delete. | { path: string, op: "delete" } -{{#if editManageImportsEnabled}} - -type ImportSpec = { - from: string; - imports?: string[]; - default?: string; - namespace?: string; - alias?: string; - system?: boolean; -} -{{/if}} + // New path for update+move. + | { path: string, op: "update", rename: string, diff: string } ``` @@ -64,50 +49,24 @@ Returns success/failure; on failure, error message indicates: - **NEVER** use edit to fix indentation, whitespace, or reformat code. Formatting is a single command run once at the end (`bun fmt`, `cargo fmt`, `prettier —write`, etc.)—not N individual edits. If you see inconsistent indentation after an edit, leave it; the formatter will fix all of it in one pass. -{{#if editManageImportsEnabled}} - -```json -{ - "path": "src/app.ts", - "op": "update", - "diff": "@@ function run() {\n function run() {\n-\treturn value;\n+\treturn format(value);\n }\n", - "imports": [ - { - "from": "./format", - "imports": ["format"] - } - ] -} -``` -`imports` are merged after the diff applies, so existing imports are reused when possible and only missing declarations are added. + +edit {"path":"hello.txt","op":"create","diff":"Hello\n"} - -```json -{ - "path": "src/app.ts", - "op": "update", - "diff": "@@\n ...\n", - "imports": [ - { - "from": "react", - "default": "React", - "imports": [ - "useMemo", - "type FC", - "useState" - ] - } - ] -} -``` -Use only supported fields: `from`, optional `imports`, `default`, `namespace`, `alias`, and `system`. + +edit {"path":"src/app.py","op":"update","diff":"@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n"} + + + +edit {"path":"src/app.py","op":"update","rename":"src/main.py","diff":"@@\n …\n"} + + + +edit {"path":"obsolete.txt","op":"delete"} -{{/if}} -{{#if editManageImportsEnabled}} -- Do not duplicate imports already present in the file; describe the desired imports once and let merge logic dedupe. -- Do not rely on `imports` to perform unrelated code edits; it only manages import/include declarations after the main edit. -{{/if}} +- Generic anchors: `import`, `export`, `describe`, `function`, `const` +- Repeating same addition in multiple hunks (duplicate blocks) +- Full-file overwrites for minor changes (acceptable for major restructures or short files) diff --git a/packages/coding-agent/src/prompts/tools/replace.md b/packages/coding-agent/src/prompts/tools/replace.md index ff7d0e2bf..eb6b13879 100644 --- a/packages/coding-agent/src/prompts/tools/replace.md +++ b/packages/coding-agent/src/prompts/tools/replace.md @@ -5,34 +5,8 @@ Performs string replacements in files with fuzzy whitespace matching. - If `old_text` not unique, you **MUST** expand to include more context or use `all: true` to replace all occurrences - Fuzzy matching handles minor whitespace/indentation differences automatically - You **SHOULD** prefer editing existing files over creating new ones -{{#if editManageImportsEnabled}} -- `imports` is **OPTIONAL**. Use it when the replacement introduces code that needs imports/includes. -- Each `imports` entry starts with `from` and **MAY** include named `imports`, `default`, `namespace`, `alias`, or `system`. -- Import management runs after the main replacement, so use `new_text` for the code change and `imports` for declarations to merge or add. -{{/if}} - -```ts -type T = { - path: string; - old_text: string; - new_text: string; - all?: boolean; -{{#if editManageImportsEnabled}} - imports?: Array<{ - from: string; - imports?: string[]; - default?: string; - namespace?: string; - alias?: string; - system?: boolean; - }>; -{{/if}} -}; -``` - - Returns success/failure status. On success, file modified in place with replacement applied. On failure (e.g., `old_text` not found or matches multiple locations without `all: true`), returns error describing issue. @@ -41,44 +15,8 @@ Returns success/failure status. On success, file modified in place with replacem - You **MUST** read the file at least once in the conversation before editing. Tool errors if you attempt edit without reading file first. -{{#if editManageImportsEnabled}} - -```json -{ - "path": "src/widget.ts", - "old_text": "return value;", - "new_text": "return formatValue(value);", - "imports": [ - { - "from": "./format", - "imports": ["formatValue"] - } - ] -} -``` -`imports` are merged after `new_text` is written, so existing imports stay canonical and only missing ones are added. - - - -```json -{ - "path": "src/widget.ts", - "old_text": "uuid()", - "new_text": "crypto.randomUUID()", - "imports": [ - { - "from": "node:crypto", - "namespace": "crypto" - } - ] -} -``` -Use only supported fields: `from`, optional `imports`, `default`, `namespace`, `alias`, and `system`. - -{{/if}} - -Replace for content-addressed changes—you identify _what_ to change by its text. +Replace for content-addressed changes—you identify \_what* to change by its text. For position-addressed or pattern-addressed changes, bash more efficient: diff --git a/packages/coding-agent/test/patch/imports/cpp.test.ts b/packages/coding-agent/test/patch/imports/cpp.test.ts deleted file mode 100644 index 34717c6e2..000000000 --- a/packages/coding-agent/test/patch/imports/cpp.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { cppImportHandler } from "../../../src/patch/imports/cpp"; -import type { ImportSpec } from "../../../src/patch/types"; - -function apply(content: string, imports: ImportSpec[]) { - return cppImportHandler.apply(content, imports); -} - -describe("c and c++ import handler", () => { - it("inserts includes after #pragma once and keeps system includes before local includes", () => { - const source = ["#pragma once", "", "class Widget {};", ""].join("\n"); - - const result = apply(source, [ - { from: "vector", system: true }, - { from: "widget/detail.h", system: false }, - ]); - - expect(result.content).toBe( - ["#pragma once", "", "#include ", "", '#include "widget/detail.h"', "", "class Widget {};", ""].join( - "\n", - ), - ); - expect(result.added).toEqual(["#include ", '#include "widget/detail.h"']); - expect(result.warnings).toEqual([]); - }); - - it("inserts includes after a header guard", () => { - const source = ["#ifndef WIDGET_H", "#define WIDGET_H", "", "struct Widget {};", ""].join("\n"); - - const result = apply(source, [{ from: "widget/detail.h", system: false }]); - - expect(result.content).toBe( - ["#ifndef WIDGET_H", "#define WIDGET_H", "", '#include "widget/detail.h"', "", "struct Widget {};", ""].join( - "\n", - ), - ); - expect(result.added).toEqual(['#include "widget/detail.h"']); - expect(result.warnings).toEqual([]); - }); - - it("deduplicates requested includes while preserving grouped ordering", () => { - const source = ["#include ", "", '#include "app/foo.h"', "", "int main();", ""].join("\n"); - - const result = apply(source, [ - { from: "string", system: true }, - { from: "vector", system: true }, - { from: "app/foo.h", system: false }, - { from: "app/foo.h", system: false }, - { from: "app/bar.h", system: false }, - ]); - - expect(result.content).toBe( - [ - "#include ", - "#include ", - "", - '#include "app/bar.h"', - '#include "app/foo.h"', - "", - "int main();", - "", - ].join("\n"), - ); - expect(result.added).toEqual(["#include ", '#include "app/bar.h"']); - expect(result.warnings).toEqual([]); - }); -}); diff --git a/packages/coding-agent/test/patch/imports/go.test.ts b/packages/coding-agent/test/patch/imports/go.test.ts deleted file mode 100644 index 8ee8727a7..000000000 --- a/packages/coding-agent/test/patch/imports/go.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { goImportHandler } from "../../../src/patch/imports/go"; -import type { ImportSpec } from "../../../src/patch/types"; - -function apply(content: string, imports: ImportSpec[]) { - return goImportHandler.apply(content, imports); -} - -describe("go import handler", () => { - it("converts a single import into a block when adding another import", () => { - const source = ["package main", "", 'import "fmt"', "", "func main() {}", ""].join("\n"); - - const result = apply(source, [{ from: "os" }]); - - expect(result.content).toBe( - ["package main", "", "import (", '\t"fmt"', '\t"os"', ")", "", "func main() {}", ""].join("\n"), - ); - expect(result.added).toEqual(['"os"']); - expect(result.warnings).toEqual([]); - }); - - it("groups stdlib imports before third-party imports in a new block", () => { - const source = ["package main", "", "func main() {}", ""].join("\n"); - - const result = apply(source, [{ from: "github.com/acme/project" }, { from: "fmt" }]); - - expect(result.content).toBe( - [ - "package main", - "", - "import (", - '\t"fmt"', - "", - '\t"github.com/acme/project"', - ")", - "", - "func main() {}", - "", - ].join("\n"), - ); - expect(result.warnings).toEqual([]); - }); - - it("deduplicates repeated requested imports", () => { - const source = ["package main", "", "func main() {}", ""].join("\n"); - - const result = apply(source, [ - { from: "fmt" }, - { from: "fmt" }, - { from: "github.com/acme/project" }, - { from: "github.com/acme/project" }, - ]); - - expect(result.content).toBe( - [ - "package main", - "", - "import (", - '\t"fmt"', - "", - '\t"github.com/acme/project"', - ")", - "", - "func main() {}", - "", - ].join("\n"), - ); - expect(result.added).toEqual(['"fmt"', '"github.com/acme/project"']); - expect(result.warnings).toEqual([]); - }); -}); diff --git a/packages/coding-agent/test/patch/imports/integration.test.ts b/packages/coding-agent/test/patch/imports/integration.test.ts deleted file mode 100644 index 26fc2d116..000000000 --- a/packages/coding-agent/test/patch/imports/integration.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { EditTool } from "@oh-my-pi/pi-coding-agent/patch"; -import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { Snowflake } from "@oh-my-pi/pi-utils"; - -function createTestToolSession(cwd: string, settings: Settings = Settings.isolated()): ToolSession { - const sessionFile = path.join(cwd, "session.jsonl"); - const sessionDir = path.join(cwd, "session"); - return { - cwd, - hasUI: false, - getSessionFile: () => sessionFile, - getSessionSpawns: () => "*", - getArtifactsDir: () => sessionDir, - allocateOutputArtifact: async (toolType: string) => { - fs.mkdirSync(sessionDir, { recursive: true }); - return { - id: `${toolType}-${Snowflake.next()}`, - path: path.join(sessionDir, `${toolType}-${Snowflake.next()}.log`), - }; - }, - settings, - }; -} - -function getTextOutput(result: { content?: Array<{ type: string; text?: string }> }): string { - return ( - result.content - ?.filter(block => block.type === "text") - .map(block => block.text ?? "") - .join("\n") ?? "" - ); -} - -function getSchemaProperties(tool: EditTool): Record { - const schema = tool.parameters as unknown as { properties?: Record }; - return schema.properties ?? {}; -} - -describe("EditTool import management integration", () => { - let tempDir: string; - let originalEditVariant: string | undefined; - - beforeEach(() => { - originalEditVariant = Bun.env.PI_EDIT_VARIANT; - Bun.env.PI_EDIT_VARIANT = "replace"; - tempDir = path.join(os.tmpdir(), `coding-agent-imports-${Snowflake.next()}`); - fs.mkdirSync(tempDir, { recursive: true }); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - if (originalEditVariant === undefined) { - delete Bun.env.PI_EDIT_VARIANT; - } else { - Bun.env.PI_EDIT_VARIANT = originalEditVariant; - } - }); - - it("applies a textual edit and then manages imports in replace mode", async () => { - const filePath = path.join(tempDir, "main.go"); - fs.writeFileSync(filePath, ["package main", "", "func main() {", '\tprintln("old")', "}", ""].join("\n")); - - const tool = new EditTool(createTestToolSession(tempDir, Settings.isolated({ "edit.manageImports": true }))); - const result = await tool.execute("replace-go-imports", { - path: filePath, - old_text: 'println("old")', - new_text: 'fmt.Println("new")', - imports: [{ from: "fmt" }], - }); - - expect(getTextOutput(result)).toContain("Successfully replaced"); - expect(await Bun.file(filePath).text()).toBe( - ["package main", "", 'import "fmt"', "", "func main() {", '\tfmt.Println("new")', "}", ""].join("\n"), - ); - }); - it("omits imports from prompts and schemas when the setting is disabled", () => { - const tool = new EditTool(createTestToolSession(tempDir, Settings.isolated({ "edit.manageImports": false }))); - - expect(tool.description).not.toContain("`imports`"); - expect(getSchemaProperties(tool)).not.toHaveProperty("imports"); - }); - - it("ignores import requests when the setting is disabled", async () => { - const filePath = path.join(tempDir, "main.go"); - fs.writeFileSync(filePath, ["package main", "", "func main() {", '\tprintln("old")', "}", ""].join("\n")); - - const tool = new EditTool(createTestToolSession(tempDir, Settings.isolated({ "edit.manageImports": false }))); - await tool.execute("replace-go-imports-disabled", { - path: filePath, - old_text: 'println("old")', - new_text: 'fmt.Println("new")', - imports: [{ from: "fmt" }], - }); - - expect(await Bun.file(filePath).text()).toBe( - ["package main", "", "func main() {", '\tfmt.Println("new")', "}", ""].join("\n"), - ); - }); - - it("includes imports in prompts and schemas when the setting is enabled", () => { - const tool = new EditTool(createTestToolSession(tempDir, Settings.isolated({ "edit.manageImports": true }))); - - expect(tool.description).toContain("`imports`"); - expect(getSchemaProperties(tool)).toHaveProperty("imports"); - }); -}); diff --git a/packages/coding-agent/test/patch/imports/python.test.ts b/packages/coding-agent/test/patch/imports/python.test.ts deleted file mode 100644 index a8d11a3b2..000000000 --- a/packages/coding-agent/test/patch/imports/python.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { pythonImportHandler } from "../../../src/patch/imports/python"; - -describe("pythonImportHandler.apply", () => { - test("inserts after shebang, encoding, and module docstring", () => { - const content = '#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n"""module docs"""\n\nvalue = 1\n'; - - const result = pythonImportHandler.apply(content, [{ from: "os", imports: ["path"] }]); - - expect(result).toEqual({ - content: - '#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n"""module docs"""\n\nfrom os import path\n\nvalue = 1\n', - added: ["from os import path"], - warnings: [], - }); - }); - - test("merges names into an existing from-import", () => { - const content = "from pathlib import Path\n\nvalue = 1\n"; - - const result = pythonImportHandler.apply(content, [{ from: "pathlib", imports: ["PurePath"] }]); - - expect(result.content).toBe("from pathlib import Path, PurePath\n\nvalue = 1\n"); - expect(result.added).toEqual(["from pathlib import PurePath"]); - expect(result.warnings).toEqual([]); - }); - - test("preserves existing import grouping when adding to an existing group", () => { - const content = "import os\n\nimport requests\n\nfrom .local import thing\n\nvalue = 1\n"; - - const result = pythonImportHandler.apply(content, [{ from: "sys" }]); - - expect(result.content).toBe( - "import os\nimport sys\n\nimport requests\n\nfrom .local import thing\n\nvalue = 1\n", - ); - expect(result.added).toEqual(["import sys"]); - expect(result.warnings).toEqual([]); - }); - - test("dedupes an already present import", () => { - const content = "from pathlib import Path\n"; - - const result = pythonImportHandler.apply(content, [{ from: "pathlib", imports: ["Path"] }]); - - expect(result).toEqual({ - content, - added: [], - warnings: [], - }); - }); -}); diff --git a/packages/coding-agent/test/patch/imports/rust.test.ts b/packages/coding-agent/test/patch/imports/rust.test.ts deleted file mode 100644 index 9ca92c914..000000000 --- a/packages/coding-agent/test/patch/imports/rust.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { rustImportHandler } from "../../../src/patch/imports/rust"; - -describe("rustImportHandler.apply", () => { - test("adds a use statement to a file with no imports", () => { - const result = rustImportHandler.apply("fn main() {}\n", [{ from: "std::fmt::Debug" }]); - - expect(result).toEqual({ - content: "use std::fmt::Debug;\n\nfn main() {}\n", - added: ["use std::fmt::Debug;"], - warnings: [], - }); - }); - - test("merges a requested member into an existing brace-group import", () => { - const content = "use std::fmt::{Debug};\n\nfn main() {}\n"; - - const result = rustImportHandler.apply(content, [{ from: "std::fmt", imports: ["Display"] }]); - - expect(result.content).toBe("use std::fmt::{Debug, Display};\n\nfn main() {}\n"); - expect(result.added).toEqual(["use std::fmt::{Display};"]); - expect(result.warnings).toEqual([]); - }); - - test("keeps distinct simple-path imports when they cannot be merged semantically", () => { - const content = "use crate::config;\n\nfn main() {}\n"; - - const result = rustImportHandler.apply(content, [{ from: "crate::config::load" }]); - - expect(result.content).toBe("use crate::config;\nuse crate::config::load;\n\nfn main() {}\n"); - expect(result.added).toEqual(["use crate::config::load;"]); - expect(result.warnings).toEqual([]); - }); - - test("dedupes an already present use statement", () => { - const content = "use std::fmt::Debug;\n"; - - const result = rustImportHandler.apply(content, [{ from: "std::fmt::Debug" }]); - - expect(result).toEqual({ - content, - added: [], - warnings: [], - }); - }); -}); diff --git a/packages/coding-agent/test/patch/imports/typescript.test.ts b/packages/coding-agent/test/patch/imports/typescript.test.ts deleted file mode 100644 index d9bd1d32f..000000000 --- a/packages/coding-agent/test/patch/imports/typescript.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { typescriptImportHandler } from "../../../src/patch/imports/typescript"; - -describe("typescriptImportHandler.apply", () => { - test("adds an import block to a file with no imports", () => { - const result = typescriptImportHandler.apply("const answer = 42;\n", [{ from: "react", imports: ["useMemo"] }]); - - expect(result).toEqual({ - content: 'import { useMemo } from "react";\n\nconst answer = 42;\n', - added: ['import { useMemo } from "react";'], - warnings: [], - }); - }); - - test("merges named imports into an existing module import", () => { - const content = 'import { useState } from "react";\n\nconst answer = 42;\n'; - - const result = typescriptImportHandler.apply(content, [{ from: "react", imports: ["useMemo"] }]); - - expect(result.content).toBe('import { useMemo, useState } from "react";\n\nconst answer = 42;\n'); - expect(result.added).toEqual(['import { useMemo } from "react";']); - expect(result.warnings).toEqual([]); - }); - - test("dedupes an already present import", () => { - const content = 'import { useMemo } from "react";\n'; - - const result = typescriptImportHandler.apply(content, [{ from: "react", imports: ["useMemo"] }]); - - expect(result).toEqual({ - content, - added: [], - warnings: [], - }); - }); - - test("derives an import assertion for known asset types", () => { - const result = typescriptImportHandler.apply("const answer = 42;\n", [ - { from: "./guide.adoc", default: "guide" }, - ]); - - expect(result).toEqual({ - content: 'import guide from "./guide.adoc" with { type: "text" };\n\nconst answer = 42;\n', - added: ['import guide from "./guide.adoc" with { type: "text" };'], - warnings: [], - }); - }); -}); From 61c4c1d0c53cfa8f72b53491d7641b80eed67abe Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 5 Apr 2026 01:08:00 +0200 Subject: [PATCH 13/14] feat(ai): added 40+ AI models and updated context windows across providers - Added 40+ new AI model entries across multiple providers including Alibaba, Google, ByteDance, NVIDIA, OpenRouter, and others. - Updated context window and token limits for existing models including GitHub Copilot Opus 4.6 (1M tokens) and Gemini 2.5 Pro (1M tokens). - Fixed GitHub Copilot and Gemini 2.5 Pro model detection logic in context window calculation. - Enhanced model configurations with reasoning capabilities, thinking modes, and updated pricing across providers. --- packages/ai/CHANGELOG.md | 45 +- packages/ai/src/model-thinking.ts | 7 + packages/ai/src/models.json | 687 +++++++++++++++++- .../ai/src/provider-models/openai-compat.ts | 4 +- .../test/github-copilot-model-limits.test.ts | 4 +- 5 files changed, 707 insertions(+), 40 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b9e7397ce..1116ab9c7 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,49 @@ # Changelog ## [Unreleased] +### Added + +- Added support for Qwen3.6 Plus model via Alibaba Coding Plan provider +- Added Gemma 4 26B and 31B models with reasoning capabilities via Google provider +- Added ByteDance Seed Dola 2.0 Pro free tier model via Kilo provider +- Added Gemma 4 26B and 31B models via Kilo provider +- Added Arcee AI Trinity Large Thinking model via NanoGPT provider +- Added GLM 4.7 Flash Heretic model via NanoGPT provider +- Added Gemma 4 26B and 31B models via NanoGPT provider +- Added Qwen3.6 Plus free tier model via Kilo provider +- Added Gemma 4 31B model via NVIDIA provider +- Added MiMo V2 Omni and Pro models with reasoning capabilities via OpenCode Go provider +- Added Qwen3.6 Plus free tier model via OpenRouter provider +- Added Gemma 4 26B and 31B models via OpenRouter provider +- Added Arcee Trinity Large Thinking model via Venice provider +- Added Gemma 4 26B and 31B models via Venice provider +- Added GLM 5V Turbo model via Z.ai provider +- Added Qwen3.6 Plus model via Zenmux provider +- Added GLM 5V Turbo model via Zenmux provider + +### Changed + +- Updated GitHub Copilot Opus 4.6 context window from 144K to 1M tokens to reflect actual model capabilities +- Updated GLM 5V Turbo model to support reasoning and image input across multiple providers +- Updated xAI Grok 4.20 to support reasoning and image input with updated pricing +- Updated MiniMax M2.5 API from Anthropic Messages to OpenAI Completions +- Updated MiniMax M2.5 thinking mode from budget to effort +- Updated Trinity Large Thinking model pricing and max tokens +- Updated Claude 3.5 Sonnet context window from 1M to 204.8K tokens +- Updated Trinity Large Thinking max tokens from 80K to 262K +- Updated Claude 3.5 Sonnet max tokens from 65536 to 65535 +- Updated Arcee Trinity Large Thinking pricing on OpenRouter +- Updated Claude 3.5 Sonnet pricing on OpenRouter +- Updated KAT-Coder-Pro-V2 model name and max tokens from 8888 to 80000 +- Updated Deepseek R1 context window from 128K to 163.84K tokens +- Updated Kimi K2 context window from 128K to 32.768K tokens +- Updated GLM 5V Turbo context window and max tokens across providers + +### Fixed + +- Fixed GitHub Copilot model context window detection by correcting fallback priority for maxContextWindowTokens and maxPromptTokens +- Fixed Gemini 2.5 Pro context window detection in GitHub Copilot model limits test +- Fixed Claude Opus 4.6 context window detection in GitHub Copilot model limits test ## [13.17.6] - 2026-04-01 @@ -1965,4 +2008,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_ ## [0.9.4] - 2025-11-26 -Initial release with multi-provider LLM support. +Initial release with multi-provider LLM support. \ No newline at end of file diff --git a/packages/ai/src/model-thinking.ts b/packages/ai/src/model-thinking.ts index 621ec677a..ee675275a 100644 --- a/packages/ai/src/model-thinking.ts +++ b/packages/ai/src/model-thinking.ts @@ -289,6 +289,13 @@ function applyAnthropicCatalogPolicy(model: ApiModel, parsedModel: Anthropi model.cost.cacheWrite = 6.25; } + // GitHub Copilot Opus 4.6: discovery currently reports a stale 144K prompt window, + // but the model supports a 1M context window. Keep the bundled catalog truthful + // until Copilot fixes the upstream metadata. + if (model.provider === "github-copilot" && parsedModel.kind === "opus" && semverEqual(parsedModel.version, "4.6")) { + model.contextWindow = 1000000; + } + // Bedrock Opus 4.6: upstream metadata is stale for cache pricing and context. if (model.provider === "amazon-bedrock" && parsedModel.kind === "opus" && semverEqual(parsedModel.version, "4.6")) { model.cost.cacheRead = 0.5; diff --git a/packages/ai/src/models.json b/packages/ai/src/models.json index aa5dd5259..0e6beb02c 100644 --- a/packages/ai/src/models.json +++ b/packages/ai/src/models.json @@ -202,6 +202,34 @@ "minLevel": "minimal", "maxLevel": "high" } + }, + "qwen3.6-plus": { + "id": "qwen3.6-plus", + "name": "Qwen3.6 Plus", + "api": "openai-completions", + "provider": "alibaba-coding-plan", + "baseUrl": "https://coding-intl.dashscope.aliyuncs.com/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 65536, + "compat": { + "supportsDeveloperRole": false + }, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "high" + } } }, "amazon-bedrock": { @@ -4605,7 +4633,7 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 144000, + "contextWindow": 1000000, "maxTokens": 64000, "headers": { "User-Agent": "GitHubCopilotChat/0.35.0", @@ -6290,6 +6318,56 @@ }, "contextWindow": 131072, "maxTokens": 8192 + }, + "gemma-4-26b": { + "id": "gemma-4-26b", + "name": "Gemma 4 26B", + "api": "google-generative-ai", + "provider": "google", + "baseUrl": "https://generativelanguage.googleapis.com/v1beta", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 8192, + "thinking": { + "mode": "budget", + "minLevel": "minimal", + "maxLevel": "high" + } + }, + "gemma-4-31b": { + "id": "gemma-4-31b", + "name": "Gemma 4 31B", + "api": "google-generative-ai", + "provider": "google", + "baseUrl": "https://generativelanguage.googleapis.com/v1beta", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 8192, + "thinking": { + "mode": "budget", + "minLevel": "minimal", + "maxLevel": "high" + } } }, "google-antigravity": { @@ -8543,6 +8621,25 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "bytedance-seed/dola-seed-2.0-pro:free": { + "id": "bytedance-seed/dola-seed-2.0-pro:free", + "name": "ByteDance Seed: Dola Seed 2.0 Pro (free)", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, "bytedance-seed/seed-1.6": { "id": "bytedance-seed/seed-1.6", "name": "ByteDance Seed: Seed 1.6", @@ -9529,6 +9626,50 @@ "contextWindow": 128000, "maxTokens": 4096 }, + "google/gemma-4-26b-a4b-it": { + "id": "google/gemma-4-26b-a4b-it", + "name": "Google: Gemma 4 26B A4B", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, + "google/gemma-4-31b-it": { + "id": "google/gemma-4-31b-it", + "name": "Gemma-4-31B-IT", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "google/lyria-3-clip-preview": { "id": "google/lyria-3-clip-preview", "name": "Google: Lyria 3 Clip Preview", @@ -13914,6 +14055,25 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "qwen/qwen3.6-plus:free": { + "id": "qwen/qwen3.6-plus:free", + "name": "Qwen: Qwen3.6 Plus (free)", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, "qwen/qwq-32b": { "id": "qwen/qwq-32b", "name": "Qwen: QwQ 32B", @@ -14994,13 +15154,14 @@ }, "z-ai/glm-5v-turbo": { "id": "z-ai/glm-5v-turbo", - "name": "Z.ai: GLM 5V Turbo", + "name": "GLM 5V Turbo", "api": "openai-completions", "provider": "kilo", "baseUrl": "https://api.kilo.ai/api/gateway", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { "input": 0, @@ -15008,8 +15169,13 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 222222, - "maxTokens": 8888 + "contextWindow": 200000, + "maxTokens": 128000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } } }, "kimi-code": { @@ -18282,6 +18448,30 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "arcee-ai/trinity-large-thinking": { + "id": "arcee-ai/trinity-large-thinking", + "name": "Arcee AI: Trinity Large Thinking", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0.25, + "output": 0.8999999999999999, + "cacheRead": 0.06, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 80000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "arcee-ai/trinity-mini": { "id": "arcee-ai/trinity-mini", "name": "Arcee AI: Trinity Mini", @@ -21587,6 +21777,25 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "glm-4.7-flash-heretic": { + "id": "glm-4.7-flash-heretic", + "name": "glm-4.7-flash-heretic", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, "glm-z1-air": { "id": "glm-z1-air", "name": "glm-z1-air", @@ -21872,6 +22081,56 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "google/gemma-4-26b-a4b-it": { + "id": "google/gemma-4-26b-a4b-it", + "name": "Google: Gemma 4 26B A4B", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.13, + "output": 0.39999999999999997, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 262144, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, + "google/gemma-4-31b-it": { + "id": "google/gemma-4-31b-it", + "name": "Gemma-4-31B-IT", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "grok-3-beta": { "id": "grok-3-beta", "name": "grok-3-beta", @@ -25970,6 +26229,25 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "qwen-3.6-plus": { + "id": "qwen-3.6-plus", + "name": "qwen-3.6-plus", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, "qwen-long": { "id": "qwen-long", "name": "qwen-long", @@ -27539,6 +27817,25 @@ "contextWindow": 222222, "maxTokens": 8888 }, + "TEE/gemma4-31b": { + "id": "TEE/gemma4-31b", + "name": "TEE/gemma4-31b", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888 + }, "TEE/glm-4.6": { "id": "TEE/glm-4.6", "name": "TEE/glm-4.6", @@ -28568,22 +28865,28 @@ }, "x-ai/grok-4.20": { "id": "x-ai/grok-4.20", - "name": "x-ai/grok-4.20", + "name": "xAI: Grok 4.20", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ - "text" + "text", + "image" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 2, + "output": 6, + "cacheRead": 0.19999999999999998, "cacheWrite": 0 }, - "contextWindow": 222222, - "maxTokens": 8888 + "contextWindow": 2000000, + "maxTokens": 8888, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } }, "x-ai/grok-4.20-beta-non-reasoning": { "id": "x-ai/grok-4.20-beta-non-reasoning", @@ -28941,6 +29244,31 @@ "maxLevel": "xhigh" } }, + "z-ai/glm-5v-turbo": { + "id": "z-ai/glm-5v-turbo", + "name": "GLM 5V Turbo", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 128000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "zai-org/glm-4.5": { "id": "zai-org/glm-4.5", "name": "zai-org/glm-4.5", @@ -29511,6 +29839,31 @@ "contextWindow": 128000, "maxTokens": 4096 }, + "google/gemma-4-31b-it": { + "id": "google/gemma-4-31b-it", + "name": "Gemma-4-31B-IT", + "api": "openai-completions", + "provider": "nvidia", + "baseUrl": "https://integrate.api.nvidia.com/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "meta/llama-3.1-405b-instruct": { "id": "meta/llama-3.1-405b-instruct", "name": "Llama 3.1 405b Instruct", @@ -32067,12 +32420,61 @@ "maxLevel": "xhigh" } }, + "mimo-v2-omni": { + "id": "mimo-v2-omni", + "name": "MiMo V2 Omni", + "api": "openai-completions", + "provider": "opencode-go", + "baseUrl": "https://opencode.ai/zen/go/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.4, + "output": 2, + "cacheRead": 0.08, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 64000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, + "mimo-v2-pro": { + "id": "mimo-v2-pro", + "name": "MiMo V2 Pro", + "api": "openai-completions", + "provider": "opencode-go", + "baseUrl": "https://opencode.ai/zen/go/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 1, + "output": 3, + "cacheRead": 0.2, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 64000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } + }, "minimax-m2.5": { "id": "minimax-m2.5", "name": "MiniMax M2.5", - "api": "anthropic-messages", + "api": "openai-completions", "provider": "opencode-go", - "baseUrl": "https://opencode.ai/zen/go", + "baseUrl": "https://opencode.ai/zen/go/v1", "reasoning": true, "input": [ "text" @@ -32086,7 +32488,7 @@ "contextWindow": 204800, "maxTokens": 131072, "thinking": { - "mode": "budget", + "mode": "effort", "minLevel": "minimal", "maxLevel": "xhigh" } @@ -33087,7 +33489,7 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 1000000, + "contextWindow": 204800, "maxTokens": 128000, "thinking": { "mode": "effort", @@ -33656,13 +34058,13 @@ "text" ], "cost": { - "input": 0.25, - "output": 0.8999999999999999, + "input": 0.22, + "output": 0.85, "cacheRead": 0.06, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 80000, + "maxTokens": 262144, "thinking": { "mode": "effort", "minLevel": "minimal", @@ -34276,7 +34678,7 @@ "cacheWrite": 0.08333333333333334 }, "contextWindow": 1048576, - "maxTokens": 65536, + "maxTokens": 65535, "thinking": { "mode": "effort", "minLevel": "minimal", @@ -34548,6 +34950,56 @@ "contextWindow": 131072, "maxTokens": 8192 }, + "google/gemma-4-26b-a4b-it": { + "id": "google/gemma-4-26b-a4b-it", + "name": "Google: Gemma 4 26B A4B", + "api": "openai-completions", + "provider": "openrouter", + "baseUrl": "https://openrouter.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.13, + "output": 0.39999999999999997, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 262144, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "high" + } + }, + "google/gemma-4-31b-it": { + "id": "google/gemma-4-31b-it", + "name": "Gemma-4-31B-IT", + "api": "openai-completions", + "provider": "openrouter", + "baseUrl": "https://openrouter.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.14, + "output": 0.39999999999999997, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 256000, + "maxTokens": 16384, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "high" + } + }, "inception/mercury": { "id": "inception/mercury", "name": "Inception: Mercury", @@ -34904,9 +35356,9 @@ "text" ], "cost": { - "input": 0.12, - "output": 1, - "cacheRead": 0.06, + "input": 0.118, + "output": 0.9900000000000001, + "cacheRead": 0.059, "cacheWrite": 0 }, "contextWindow": 204800, @@ -35552,9 +36004,9 @@ "image" ], "cost": { - "input": 0.42, - "output": 2.2, - "cacheRead": 0.21, + "input": 0.3827, + "output": 1.72, + "cacheRead": 0.1935, "cacheWrite": 0 }, "contextWindow": 262144, @@ -38304,6 +38756,31 @@ "maxLevel": "high" } }, + "qwen/qwen3.6-plus:free": { + "id": "qwen/qwen3.6-plus:free", + "name": "Qwen: Qwen3.6 Plus (free)", + "api": "openai-completions", + "provider": "openrouter", + "baseUrl": "https://openrouter.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 65536, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "high" + } + }, "qwen/qwq-32b": { "id": "qwen/qwq-32b", "name": "Qwen: QwQ 32B", @@ -39199,7 +39676,7 @@ }, "z-ai/glm-5v-turbo": { "id": "z-ai/glm-5v-turbo", - "name": "Z.ai: GLM 5V Turbo", + "name": "GLM 5V Turbo", "api": "openai-completions", "provider": "openrouter", "baseUrl": "https://openrouter.ai/api/v1", @@ -39214,8 +39691,8 @@ "cacheRead": 0.24, "cacheWrite": 0 }, - "contextWindow": 202752, - "maxTokens": 131072, + "contextWindow": 200000, + "maxTokens": 128000, "thinking": { "mode": "effort", "minLevel": "minimal", @@ -39838,6 +40315,28 @@ "supportsUsageInStreaming": false } }, + "arcee-trinity-large-thinking": { + "id": "arcee-trinity-large-thinking", + "name": "arcee-trinity-large-thinking", + "api": "openai-completions", + "provider": "venice", + "baseUrl": "https://api.venice.ai/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888, + "compat": { + "supportsUsageInStreaming": false + } + }, "claude-opus-4-5": { "id": "claude-opus-4-5", "name": "Claude Opus 4.5 (latest)", @@ -40376,6 +40875,50 @@ "supportsUsageInStreaming": false } }, + "google.gemma-4-26b-a4b-it": { + "id": "google.gemma-4-26b-a4b-it", + "name": "google.gemma-4-26b-a4b-it", + "api": "openai-completions", + "provider": "venice", + "baseUrl": "https://api.venice.ai/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888, + "compat": { + "supportsUsageInStreaming": false + } + }, + "google.gemma-4-31b-it": { + "id": "google.gemma-4-31b-it", + "name": "google.gemma-4-31b-it", + "api": "openai-completions", + "provider": "venice", + "baseUrl": "https://api.venice.ai/api/v1", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 222222, + "maxTokens": 8888, + "compat": { + "supportsUsageInStreaming": false + } + }, "grok-4-20-beta": { "id": "grok-4-20-beta", "name": "grok-4-20-beta", @@ -45192,6 +45735,31 @@ "minLevel": "minimal", "maxLevel": "xhigh" } + }, + "glm-5v-turbo": { + "id": "glm-5v-turbo", + "name": "glm-5v-turbo", + "api": "anthropic-messages", + "provider": "zai", + "baseUrl": "https://api.z.ai/api/anthropic", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 131072, + "thinking": { + "mode": "budget", + "minLevel": "minimal", + "maxLevel": "xhigh" + } } }, "zenmux": { @@ -45539,7 +46107,7 @@ "cacheRead": 0.056, "cacheWrite": 0 }, - "contextWindow": 128000, + "contextWindow": 163840, "maxTokens": 8888 }, "deepseek/deepseek-r1-0528": { @@ -46114,7 +46682,7 @@ }, "kuaishou/kat-coder-pro-v2": { "id": "kuaishou/kat-coder-pro-v2", - "name": "KwaiKAT: KAT-Coder-Pro-V2", + "name": "KAT-Coder-Pro-V2", "api": "openai-completions", "provider": "zenmux", "baseUrl": "https://zenmux.ai/api/v1", @@ -46129,7 +46697,7 @@ "cacheWrite": 0 }, "contextWindow": 256000, - "maxTokens": 8888 + "maxTokens": 80000 }, "meta/llama-3.3-70b-instruct": { "id": "meta/llama-3.3-70b-instruct", @@ -46369,7 +46937,7 @@ "cacheRead": 0.2, "cacheWrite": 0.003 }, - "contextWindow": 128000, + "contextWindow": 32768, "maxTokens": 8888 }, "moonshotai/kimi-k2-0905": { @@ -47283,6 +47851,30 @@ "maxLevel": "high" } }, + "qwen/qwen3.6-plus": { + "id": "qwen/qwen3.6-plus", + "name": "Qwen3.6-Plus", + "api": "openai-completions", + "provider": "zenmux", + "baseUrl": "https://zenmux.ai/api/v1", + "reasoning": true, + "input": [ + "text" + ], + "cost": { + "input": 0.5, + "output": 3, + "cacheRead": 0.05, + "cacheWrite": 0.625 + }, + "contextWindow": 1000000, + "maxTokens": 64000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "high" + } + }, "sapiens-ai/agnes-1.5-lite": { "id": "sapiens-ai/agnes-1.5-lite", "name": "Sapiens AI: Agnes-1.5-Lite", @@ -48136,6 +48728,31 @@ "minLevel": "minimal", "maxLevel": "xhigh" } + }, + "z-ai/glm-5v-turbo": { + "id": "z-ai/glm-5v-turbo", + "name": "GLM 5V Turbo", + "api": "openai-completions", + "provider": "zenmux", + "baseUrl": "https://zenmux.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.726, + "output": 3.1946, + "cacheRead": 0.1743, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 128000, + "thinking": { + "mode": "effort", + "minLevel": "minimal", + "maxLevel": "xhigh" + } } } } \ No newline at end of file diff --git a/packages/ai/src/provider-models/openai-compat.ts b/packages/ai/src/provider-models/openai-compat.ts index b885ebfd9..9fd289a6e 100644 --- a/packages/ai/src/provider-models/openai-compat.ts +++ b/packages/ai/src/provider-models/openai-compat.ts @@ -1519,9 +1519,9 @@ export function githubCopilotModelManagerOptions(config?: GithubCopilotModelMana const contextWindow = toPositiveNumber( entry.context_length, toPositiveNumber( - copilotLimits.maxPromptTokens, + copilotLimits.maxContextWindowTokens, toPositiveNumber( - copilotLimits.maxContextWindowTokens, + copilotLimits.maxPromptTokens, reference?.contextWindow ?? defaults.contextWindow, ), ), diff --git a/packages/ai/test/github-copilot-model-limits.test.ts b/packages/ai/test/github-copilot-model-limits.test.ts index 907978dc7..da3fc5424 100644 --- a/packages/ai/test/github-copilot-model-limits.test.ts +++ b/packages/ai/test/github-copilot-model-limits.test.ts @@ -87,7 +87,7 @@ describe("github copilot model limits mapping", () => { const model = models.find(candidate => candidate.id === "gemini-2.5-pro"); expect(model).toBeDefined(); - expect(model?.contextWindow).toBe(128_000); + expect(model?.contextWindow).toBe(1_048_576); expect(model?.maxTokens).toBe(64_000); expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -137,7 +137,7 @@ describe("github copilot model limits mapping", () => { const model = models.find(candidate => candidate.id === "claude-opus-4.6"); expect(model).toBeDefined(); - expect(model?.contextWindow).toBe(128_000); + expect(model?.contextWindow).toBe(200_000); expect(model?.maxTokens).toBe(16_000); }); it("inherits bundled GPT-5.4 mini reasoning metadata during discovery", async () => { From 9240d2b3fb15cc6a1b6a463bf930aa192e03c43a Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 5 Apr 2026 01:15:08 +0200 Subject: [PATCH 14/14] fix: remove unsupported OPENAI_CODEX_API_KEY fallback from Codex env resolver --- packages/ai/src/stream.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 8c79a75dc..d45d8b76f 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -78,7 +78,7 @@ const serviceProviderMap: Record = { "opencode-go": "OPENCODE_API_KEY", "opencode-zen": "OPENCODE_API_KEY", cursor: "CURSOR_ACCESS_TOKEN", - "openai-codex": () => $pickenv("OPENAI_CODEX_OAUTH_TOKEN", "OPENAI_CODEX_API_KEY"), + "openai-codex": "OPENAI_CODEX_OAUTH_TOKEN", "azure-openai-responses": "AZURE_OPENAI_API_KEY", exa: "EXA_API_KEY", jina: "JINA_API_KEY",