diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 6392308c..9110bbb5 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3620,7 +3620,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 196608, - maxTokens: 131072, + maxTokens: 65536, } satisfies Model<"openai-completions">, "deepcogito/cogito-v2-preview-llama-405b": { id: "deepcogito/cogito-v2-preview-llama-405b", @@ -4623,7 +4623,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 128000, + maxTokens: 131072, } satisfies Model<"openai-completions">, "openai/gpt-oss-20b": { id: "openai/gpt-oss-20b", @@ -6104,9 +6104,9 @@ export const MODELS = { contextWindow: 32768, maxTokens: 4096, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku": { - id: "anthropic/claude-3.5-haiku", - name: "Anthropic: Claude 3.5 Haiku", + "anthropic/claude-3.5-haiku-20241022": { + id: "anthropic/claude-3.5-haiku-20241022", + name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6121,9 +6121,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"openai-completions">, - "anthropic/claude-3.5-haiku-20241022": { - id: "anthropic/claude-3.5-haiku-20241022", - name: "Anthropic: Claude 3.5 Haiku (2024-10-22)", + "anthropic/claude-3.5-haiku": { + id: "anthropic/claude-3.5-haiku", + name: "Anthropic: Claude 3.5 Haiku", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -6359,6 +6359,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-completions">, + "meta-llama/llama-3.1-8b-instruct": { + id: "meta-llama/llama-3.1-8b-instruct", + name: "Meta: Llama 3.1 8B Instruct", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.02, + output: 0.03, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "meta-llama/llama-3.1-405b-instruct": { id: "meta-llama/llama-3.1-405b-instruct", name: "Meta: Llama 3.1 405B Instruct", @@ -6393,23 +6410,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 4096, } satisfies Model<"openai-completions">, - "meta-llama/llama-3.1-8b-instruct": { - id: "meta-llama/llama-3.1-8b-instruct", - name: "Meta: Llama 3.1 8B Instruct", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.02, - output: 0.03, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", name: "Mistral: Mistral Nemo", @@ -6546,6 +6546,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-4o-2024-05-13": { + id: "openai/gpt-4o-2024-05-13", + name: "OpenAI: GPT-4o (2024-05-13)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 15, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "OpenAI: GPT-4o", @@ -6580,23 +6597,6 @@ export const MODELS = { contextWindow: 128000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "openai/gpt-4o-2024-05-13": { - id: "openai/gpt-4o-2024-05-13", - name: "OpenAI: GPT-4o (2024-05-13)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 5, - output: 15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "meta-llama/llama-3-70b-instruct": { id: "meta-llama/llama-3-70b-instruct", name: "Meta: Llama 3 70B Instruct", @@ -6835,23 +6835,6 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, - "openai/gpt-3.5-turbo": { - id: "openai/gpt-3.5-turbo", - name: "OpenAI: GPT-3.5 Turbo", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 16385, - maxTokens: 4096, - } satisfies Model<"openai-completions">, "openai/gpt-4": { id: "openai/gpt-4", name: "OpenAI: GPT-4", @@ -6869,6 +6852,23 @@ export const MODELS = { contextWindow: 8191, maxTokens: 4096, } satisfies Model<"openai-completions">, + "openai/gpt-3.5-turbo": { + id: "openai/gpt-3.5-turbo", + name: "OpenAI: GPT-3.5 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.5, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 16385, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "openrouter/auto": { id: "openrouter/auto", name: "OpenRouter: Auto Router", diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index cdc8577e..a46a209d 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -23,8 +23,13 @@ function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string { /** * Generate a unified diff string with line numbers and context + * Returns both the diff string and the first changed line number (in the new file) */ -function generateDiffString(oldContent: string, newContent: string, contextLines = 4): string { +function generateDiffString( + oldContent: string, + newContent: string, + contextLines = 4, +): { diff: string; firstChangedLine: number | undefined } { const parts = Diff.diffLines(oldContent, newContent); const output: string[] = []; @@ -36,6 +41,7 @@ function generateDiffString(oldContent: string, newContent: string, contextLines let oldLineNum = 1; let newLineNum = 1; let lastWasChange = false; + let firstChangedLine: number | undefined; for (let i = 0; i < parts.length; i++) { const part = parts[i]; @@ -45,6 +51,11 @@ function generateDiffString(oldContent: string, newContent: string, contextLines } if (part.added || part.removed) { + // Capture the first changed line (in the new file) + if (firstChangedLine === undefined) { + firstChangedLine = newLineNum; + } + // Show the change for (const line of raw) { if (part.added) { @@ -113,7 +124,7 @@ function generateDiffString(oldContent: string, newContent: string, contextLines } } - return output.join("\n"); + return { diff: output.join("\n"), firstChangedLine }; } const editSchema = Type.Object({ @@ -125,6 +136,8 @@ const editSchema = Type.Object({ export interface EditToolDetails { /** Unified diff of the changes made */ diff: string; + /** Line number of the first change in the new file (for editor navigation) */ + firstChangedLine?: number; } export function createEditTool(cwd: string): AgentTool { @@ -143,7 +156,7 @@ export function createEditTool(cwd: string): AgentTool { return new Promise<{ content: Array<{ type: "text"; text: string }>; - details: { diff: string } | undefined; + details: EditToolDetails | undefined; }>((resolve, reject) => { // Check if already aborted if (signal?.aborted) { @@ -262,6 +275,7 @@ export function createEditTool(cwd: string): AgentTool { signal.removeEventListener("abort", onAbort); } + const diffResult = generateDiffString(normalizedContent, normalizedNewContent); resolve({ content: [ { @@ -269,7 +283,7 @@ export function createEditTool(cwd: string): AgentTool { text: `Successfully replaced text in ${path}.`, }, ], - details: { diff: generateDiffString(normalizedContent, normalizedNewContent) }, + details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine }, }); } catch (error: any) { // Clean up abort handler diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index fe32a3b4..7124c84b 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -415,10 +415,14 @@ export class ToolExecutionComponent extends Container { } else if (this.toolName === "edit") { const rawPath = this.args?.file_path || this.args?.path || ""; const path = shortenPath(rawPath); - text = - theme.fg("toolTitle", theme.bold("edit")) + - " " + - (path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")); + + // Build path display, appending :line if we have a successful result with line info + let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); + if (this.result && !this.result.isError && this.result.details?.firstChangedLine) { + pathDisplay += theme.fg("warning", `:${this.result.details.firstChangedLine}`); + } + + text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`; if (this.result) { if (this.result.isError) { diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts index 24d104ec..18c3ca8b 100644 --- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts @@ -3,6 +3,8 @@ import { Container, Input, isArrowDown, + isArrowLeft, + isArrowRight, isArrowUp, isBackspace, isCtrlC, @@ -18,17 +20,23 @@ import type { SessionTreeNode } from "../../../core/session-manager.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +/** Gutter info: position (displayIndent where connector was) and whether to show │ */ +interface GutterInfo { + position: number; // displayIndent level where the connector was shown + show: boolean; // true = show │, false = show spaces +} + /** Flattened tree node for navigation */ interface FlatNode { node: SessionTreeNode; - /** Indentation level (each level = 2 spaces) */ + /** Indentation level (each level = 3 chars) */ indent: number; /** Whether to show connector (├─ or └─) - true if parent has multiple children */ showConnector: boolean; /** If showConnector, true = last sibling (└─), false = not last (├─) */ isLast: boolean; - /** For each ancestor branch point, true = show │ (more siblings below), false = show space */ - gutters: boolean[]; + /** Gutter info for each ancestor branch point */ + gutters: GutterInfo[]; /** True if this node is a root under a virtual branching root (multiple roots) */ isVirtualRootChild: boolean; } @@ -109,24 +117,36 @@ class TreeList implements Component { // - At indent 2+: stay flat for single-child chains, +1 only if parent branches // Stack items: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] - type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, boolean[], boolean]; + type StackItem = [SessionTreeNode, number, boolean, boolean, boolean, GutterInfo[], boolean]; const stack: StackItem[] = []; // Determine which subtrees contain the active leaf (to sort current branch first) + // Use iterative post-order traversal to avoid stack overflow const containsActive = new Map(); const leafId = this.currentLeafId; - const markContains = (node: SessionTreeNode): boolean => { - let has = leafId !== null && node.entry.id === leafId; - for (const child of node.children) { - if (markContains(child)) { - has = true; + { + // Build list in pre-order, then process in reverse for post-order effect + const allNodes: SessionTreeNode[] = []; + const preOrderStack: SessionTreeNode[] = [...roots]; + while (preOrderStack.length > 0) { + const node = preOrderStack.pop()!; + allNodes.push(node); + // Push children in reverse so they're processed left-to-right + for (let i = node.children.length - 1; i >= 0; i--) { + preOrderStack.push(node.children[i]); } } - containsActive.set(node, has); - return has; - }; - for (const root of roots) { - markContains(root); + // Process in reverse (post-order): children before parents + for (let i = allNodes.length - 1; i >= 0; i--) { + const node = allNodes[i]; + let has = leafId !== null && node.entry.id === leafId; + for (const child of node.children) { + if (containsActive.get(child)) { + has = true; + } + } + containsActive.set(node, has); + } } // Add roots in reverse order, prioritizing the one containing the active leaf @@ -189,7 +209,15 @@ class TreeList implements Component { // Build gutters for children // If this node showed a connector, add a gutter entry for descendants - const childGutters = showConnector ? [...gutters, !isLast] : gutters; + // Only add gutter if connector is actually displayed (not suppressed for virtual root children) + const connectorDisplayed = showConnector && !isVirtualRootChild; + // When connector is displayed, add a gutter entry at the connector's position + // Connector is at position (displayIndent - 1), so gutter should be there too + const currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent; + const connectorPosition = Math.max(0, currentDisplayIndent - 1); + const childGutters: GutterInfo[] = connectorDisplayed + ? [...gutters, { position: connectorPosition, show: !isLast }] + : gutters; // Add children in reverse order for (let i = orderedChildren.length - 1; i >= 0; i--) { @@ -378,22 +406,48 @@ class TreeList implements Component { const flatNode = this.filteredNodes[i]; const entry = flatNode.node.entry; const isSelected = i === this.selectedIndex; - const isCurrentLeaf = entry.id === this.currentLeafId; - // Build line: cursor + gutters + connector + extra indent + label + content + suffix + // Build line: cursor + prefix + path marker + label + content const cursor = isSelected ? theme.fg("accent", "› ") : " "; // If multiple roots, shift display (roots at 0, not 1) const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent; - // Build prefix: gutters + connector + extra spaces - const gutterStr = flatNode.gutters.map((g) => (g ? "│ " : " ")).join(""); + // Build prefix with gutters at their correct positions + // Each gutter has a position (displayIndent where its connector was shown) const connector = flatNode.showConnector && !flatNode.isVirtualRootChild ? (flatNode.isLast ? "└─ " : "├─ ") : ""; - // Extra indent for visual grouping beyond gutters/connector - const prefixLevels = flatNode.gutters.length + (connector ? 1 : 0); - const extraIndent = " ".repeat(Math.max(0, displayIndent - prefixLevels)); - const prefix = gutterStr + connector + extraIndent; + const connectorPosition = connector ? displayIndent - 1 : -1; + + // Build prefix char by char, placing gutters and connector at their positions + const totalChars = displayIndent * 3; + const prefixChars: string[] = []; + for (let i = 0; i < totalChars; i++) { + const level = Math.floor(i / 3); + const posInLevel = i % 3; + + // Check if there's a gutter at this level + const gutter = flatNode.gutters.find((g) => g.position === level); + if (gutter) { + if (posInLevel === 0) { + prefixChars.push(gutter.show ? "│" : " "); + } else { + prefixChars.push(" "); + } + } else if (connector && level === connectorPosition) { + // Connector at this level + if (posInLevel === 0) { + prefixChars.push(flatNode.isLast ? "└" : "├"); + } else if (posInLevel === 1) { + prefixChars.push("─"); + } else { + prefixChars.push(" "); + } + } else { + prefixChars.push(" "); + } + } + const prefix = prefixChars.join(""); // Active path marker - shown right before the entry text const isOnActivePath = this.activePathIds.has(entry.id); @@ -401,9 +455,8 @@ class TreeList implements Component { const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : ""; const content = this.getEntryDisplayText(flatNode.node, isSelected); - const suffix = isCurrentLeaf ? theme.fg("accent", " *") : ""; - const line = cursor + theme.fg("dim", prefix) + pathMarker + label + content + suffix; + const line = cursor + theme.fg("dim", prefix) + pathMarker + label + content; lines.push(truncateToWidth(line, width)); } @@ -437,12 +490,12 @@ class TreeList implements Component { if (textContent) { result = theme.fg("success", "assistant: ") + textContent; } else if (msgWithContent.stopReason === "aborted") { - result = theme.fg("warning", "assistant: ") + theme.fg("muted", "(aborted)"); + result = theme.fg("success", "assistant: ") + theme.fg("muted", "(aborted)"); } else if (msgWithContent.errorMessage) { const errMsg = normalize(msgWithContent.errorMessage).slice(0, 80); - result = theme.fg("error", "assistant: ") + errMsg; + result = theme.fg("success", "assistant: ") + theme.fg("error", errMsg); } else { - result = theme.fg("muted", "assistant: (no content)"); + result = theme.fg("success", "assistant: ") + theme.fg("muted", "(no content)"); } } else if (role === "toolResult") { const toolMsg = msg as { toolCallId?: string; toolName?: string }; @@ -586,6 +639,12 @@ class TreeList implements Component { this.selectedIndex = this.selectedIndex === 0 ? this.filteredNodes.length - 1 : this.selectedIndex - 1; } else if (isArrowDown(keyData)) { this.selectedIndex = this.selectedIndex === this.filteredNodes.length - 1 ? 0 : this.selectedIndex + 1; + } else if (isArrowLeft(keyData)) { + // Page up + this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisibleLines); + } else if (isArrowRight(keyData)) { + // Page down + this.selectedIndex = Math.min(this.filteredNodes.length - 1, this.selectedIndex + this.maxVisibleLines); } else if (isEnter(keyData)) { const selected = this.filteredNodes[this.selectedIndex]; if (selected && this.onSelect) { @@ -719,9 +778,11 @@ export class TreeSelectorComponent extends Container { this.labelInputContainer = new Container(); this.addChild(new Spacer(1)); - this.addChild(new Text(theme.bold("Session Tree"), 1, 0)); this.addChild(new DynamicBorder()); - this.addChild(new TruncatedText(theme.fg("muted", " Type to search. l: label. ^O: cycle filter"), 0, 0)); + this.addChild(new Text(theme.bold(" Session Tree"), 1, 0)); + this.addChild( + new TruncatedText(theme.fg("muted", " ↑/↓: move. ←/→: page. l: label. ^O: filter. Type to search"), 0, 0), + ); this.addChild(new SearchLine(this.treeList)); this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index bc2cab45..8d7109b4 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -56,6 +56,15 @@ import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; import { getAvailableThemes, getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js"; +/** Interface for components that can be expanded/collapsed */ +interface Expandable { + setExpanded(expanded: boolean): void; +} + +function isExpandable(obj: unknown): obj is Expandable { + return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function"; +} + export class InteractiveMode { private session: AgentSession; private ui: TUI; @@ -1303,13 +1312,7 @@ export class InteractiveMode { private toggleToolOutputExpansion(): void { this.toolOutputExpanded = !this.toolOutputExpanded; for (const child of this.chatContainer.children) { - if (child instanceof ToolExecutionComponent) { - child.setExpanded(this.toolOutputExpanded); - } else if (child instanceof CompactionSummaryMessageComponent) { - child.setExpanded(this.toolOutputExpanded); - } else if (child instanceof BashExecutionComponent) { - child.setExpanded(this.toolOutputExpanded); - } else if (child instanceof HookMessageComponent) { + if (isExpandable(child)) { child.setExpanded(this.toolOutputExpanded); } }