From 402773180a12bcedd64eec56e6b05b081f9b05f7 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 14 Aug 2026 15:23:59 +0200 Subject: [PATCH 01/12] feat(coding-agent): add ctrl+j toggle to expand edit diffs independently of tool output --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/keybindings.md | 1 + .../src/core/extensions/runner.ts | 1 + packages/coding-agent/src/core/keybindings.ts | 2 ++ .../components/conversation-components.ts | 3 ++ .../interactive/components/ipython-cell.ts | 3 +- .../interactive/components/tool-execution.ts | 28 ++++++++++++--- .../src/modes/interactive/interactive-mode.ts | 35 ++++++++++++++++++- .../test/interactive-mode-status.test.ts | 23 +++++++++++- .../test/ipython-cell-diff.test.ts | 30 ++++++++++++++++ .../test/tool-execution-component.test.ts | 31 ++++++++++++++++ 11 files changed, 150 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 60589450e1..659fa19021 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Added `app.edits.expand` (`ctrl+j`) to show full edit diffs on collapsed tool calls, separately from the `ctrl+o` tool-output toggle. - Fixed fullscreen wheel scrolling in Ghostty while retaining application link clicks; set `terminal.fullscreenMouse` to `false` to use native Cmd-click instead. - Changed the agents view to sort idle and inactive sessions by last message time, newest first, while keeping running agents in stable creation order. - Fixed `openai-codex` models being invisible to `rlm` subagents and `find_models` because model discovery reported Prime Agent's own version as the Codex client version ([#1375](https://github.com/PrimeIntellect-ai/prime-agent/pull/1375) by [@bilelrais](https://github.com/bilelrais)). diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index a2a492f7d2..94248e10da 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -131,6 +131,7 @@ Use `tab` to cycle forward and `shift+tab` to cycle backward through Providers, |--------|---------|-------------| | `app.tools.expand` | `ctrl+o` | Collapse or expand tool output | | `app.messages.expand` | `ctrl+p` | Collapse or expand agent-to-agent messages | +| `app.edits.expand` | `ctrl+j` | Collapse or expand edit diffs | | `app.message.followUp` | `alt+enter` | Queue follow-up message | | `app.message.navigateOlder` | `alt+up` | Select the next older pending message | | `app.message.navigateNewer` | `alt+down` | Select the next newer pending message or restore the draft | diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 17cc02e561..21c739c16a 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -67,6 +67,7 @@ const RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS = [ "app.model.select", "app.tools.expand", "app.messages.expand", + "app.edits.expand", "app.thinking.toggle", "app.subagents.focus", "app.editor.external", diff --git a/packages/coding-agent/src/core/keybindings.ts b/packages/coding-agent/src/core/keybindings.ts index 60b579a674..6c451d53e6 100644 --- a/packages/coding-agent/src/core/keybindings.ts +++ b/packages/coding-agent/src/core/keybindings.ts @@ -22,6 +22,7 @@ export interface AppKeybindings { "app.configuration.previousTab": true; "app.tools.expand": true; "app.messages.expand": true; + "app.edits.expand": true; "app.thinking.toggle": true; "app.subagents.focus": true; "app.heartbeats.open": true; @@ -92,6 +93,7 @@ export const KEYBINDINGS = { description: "Toggle agent message expansion", defaultKeyScope: "editor", }, + "app.edits.expand": { defaultKeys: "ctrl+j", description: "Toggle edit diffs", defaultKeyScope: "editor" }, "app.thinking.toggle": { defaultKeys: "ctrl+t", description: "Toggle thinking blocks", diff --git a/packages/coding-agent/src/modes/interactive/components/conversation-components.ts b/packages/coding-agent/src/modes/interactive/components/conversation-components.ts index cad454d071..dd2bb456e7 100644 --- a/packages/coding-agent/src/modes/interactive/components/conversation-components.ts +++ b/packages/coding-agent/src/modes/interactive/components/conversation-components.ts @@ -38,6 +38,7 @@ export interface ConversationComponentsOptions { hiddenThinkingLabel?: string; toolsExpanded?: boolean; agentMessagesExpanded?: boolean; + editDiffsExpanded?: boolean; isRecognizedSlashCommand?: (name: string) => boolean; } @@ -71,6 +72,7 @@ export function buildConversationComponents( const pendingTools = new Map(); const expanded = options.toolsExpanded ?? false; const agentMessagesExpanded = options.agentMessagesExpanded ?? false; + const editDiffsExpanded = options.editDiffsExpanded ?? false; for (const message of messages) { if (message.role === "assistant") { @@ -103,6 +105,7 @@ export function buildConversationComponents( ); tool.setExpanded(expanded); tool.setAgentMessagesExpanded(agentMessagesExpanded); + tool.setEditDiffsExpanded(editDiffsExpanded); tool.markExecutionStarted(); tool.setArgsComplete(); selectLatestToolExpandHint(components, tool); diff --git a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts index b7c0f30798..ff73efe5f5 100644 --- a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts +++ b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts @@ -33,6 +33,7 @@ export interface IPythonCellState { isError?: boolean; expanded?: boolean; agentMessagesExpanded?: boolean; + editDiffsExpanded?: boolean; showExpandHint?: boolean; executionStarted?: boolean; argsComplete?: boolean; @@ -383,7 +384,7 @@ export class IPythonCellComponent implements Component { const lines = [truncateToWidth(` ${this.collapsedLine(details)}`, safeWidth, "")]; const hasCode = this.state.expanded ? this.renderCode(lines, safeWidth) : false; - if ((details.diffs?.length ?? 0) > 0 && this.state.expanded) { + if ((details.diffs?.length ?? 0) > 0 && (this.state.expanded || this.state.editDiffsExpanded)) { this.renderDiffs(lines, safeWidth, details.diffs ?? [], this.marker(details)); } if ((details.sentAgentMessages?.length ?? 0) > 0) { 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 9c8ccc5650..4ebbdec00d 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -80,6 +80,7 @@ export class ToolExecutionComponent extends Container { private args: any; private expanded = false; private agentMessagesExpanded = false; + private editDiffsExpanded = false; private showExpandHint = true; private showImages: boolean; private includeImageDimensions: boolean; @@ -175,6 +176,17 @@ export class ToolExecutionComponent extends Container { return this.toolName === "ipython" && !this.toolDefinition?.renderCall && !this.toolDefinition?.renderResult; } + private isBuiltInEditTool(): boolean { + return ( + this.toolName === "edit" && + (this.toolDefinition === undefined || this.toolDefinition.replayBuiltInToolName === "edit") + ); + } + + private effectiveExpanded(): boolean { + return this.expanded || (this.editDiffsExpanded && this.isBuiltInEditTool()); + } + private getRenderContext(lastComponent: Component | undefined): ToolRenderContext { return { args: this.args, @@ -189,7 +201,7 @@ export class ToolExecutionComponent extends Container { executionStarted: this.executionStarted, argsComplete: this.argsComplete, isPartial: this.isPartial, - expanded: this.expanded, + expanded: this.effectiveExpanded(), showExpandHint: this.showExpandHint, showImages: this.showImages, includeImageDimensions: this.includeImageDimensions, @@ -276,6 +288,14 @@ export class ToolExecutionComponent extends Container { this.updateDisplay(); } + setEditDiffsExpanded(expanded: boolean): void { + if (this.editDiffsExpanded === expanded) { + return; + } + this.editDiffsExpanded = expanded; + this.updateDisplay(); + } + setShowExpandHint(show: boolean): void { if (this.showExpandHint === show) { return; @@ -341,6 +361,7 @@ export class ToolExecutionComponent extends Container { isError: this.result?.isError ?? false, expanded: this.expanded, agentMessagesExpanded: this.agentMessagesExpanded, + editDiffsExpanded: this.editDiffsExpanded, executionStarted: this.executionStarted, argsComplete: this.argsComplete, showExpandHint: this.showExpandHint, @@ -398,10 +419,7 @@ export class ToolExecutionComponent extends Container { } } - const isBuiltInEdit = - this.toolName === "edit" && - (this.toolDefinition === undefined || this.toolDefinition.replayBuiltInToolName === "edit"); - if (!this.expanded && this.result && (isBuiltInEdit || this.shouldUseIpythonRenderer())) { + if (!this.effectiveExpanded() && this.result && (this.isBuiltInEditTool() || this.shouldUseIpythonRenderer())) { const changes = getToolFileChanges(this.toolName, this.args, this.result, this.cwd); if (changes.length > 0) { const container = this.usesSelfRenderShell() ? this.selfRenderContainer : this.contentPanel; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index db3d89d006..d84e99a15d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -334,6 +334,19 @@ function hasAgentMessagesExpansion(obj: unknown): obj is AgentMessagesExpandable ); } +interface EditDiffsExpandable { + setEditDiffsExpanded(expanded: boolean): void; +} + +function hasEditDiffsExpansion(obj: unknown): obj is EditDiffsExpandable { + return ( + typeof obj === "object" && + obj !== null && + "setEditDiffsExpanded" in obj && + typeof (obj as EditDiffsExpandable).setEditDiffsExpanded === "function" + ); +} + class ExpandableText extends Text implements Expandable { constructor( private readonly getCollapsedText: () => string, @@ -963,6 +976,7 @@ export class InteractiveMode { // Tool output expansion state private toolOutputExpanded = false; private agentMessagesExpanded = false; + private editDiffsExpanded = false; // Thinking block visibility state private hideThinkingBlock = false; @@ -1391,6 +1405,7 @@ export class InteractiveMode { hint("app.model.select", "to select model"), hint("app.tools.expand", "to expand tools"), hint("app.messages.expand", "to expand agent messages"), + hint("app.edits.expand", "to expand edit diffs"), hint("app.thinking.toggle", "to expand thinking"), hint("app.subagents.focus", "to inspect subagents"), hint("app.editor.external", "for external editor"), @@ -3033,6 +3048,7 @@ export class InteractiveMode { ); component.setExpanded(this.toolOutputExpanded); component.setAgentMessagesExpanded(this.agentMessagesExpanded); + component.setEditDiffsExpanded(this.editDiffsExpanded); if (this.startedToolCalls.has(latestToolCall.id)) { component.markExecutionStarted(); } @@ -4180,6 +4196,7 @@ export class InteractiveMode { this.defaultEditor.onAction("app.model.select", () => this.showModelSelector()); this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion()); this.defaultEditor.onAction("app.messages.expand", () => this.toggleAgentMessageExpansion()); + this.defaultEditor.onAction("app.edits.expand", () => this.toggleEditDiffExpansion()); this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility()); this.defaultEditor.onAction("app.subagents.focus", () => this.focusSubagentSummary()); this.defaultEditor.onAction("app.heartbeats.open", () => { @@ -6004,6 +6021,10 @@ export class InteractiveMode { this.toggleAgentMessageExpansion(); return; } + if (this.keybindings.matches(data, "app.edits.expand")) { + this.toggleEditDiffExpansion(); + return; + } if (this.keybindings.matches(data, "app.thinking.toggle")) { this.toggleThinkingBlockVisibility(); return; @@ -6520,6 +6541,7 @@ export class InteractiveMode { ); component.setExpanded(this.toolOutputExpanded); component.setAgentMessagesExpanded(this.agentMessagesExpanded); + component.setEditDiffsExpanded(this.editDiffsExpanded); selectLatestToolExpandHint(this.chatContainer.children, component); this.chatContainer.addChild(component); this.registerIpythonToolComponent(content.name, content.id, component); @@ -7270,6 +7292,11 @@ export class InteractiveMode { this.applyChatExpansion(); } + private toggleEditDiffExpansion(): void { + this.editDiffsExpanded = !this.editDiffsExpanded; + this.applyChatExpansion(); + } + private setToolsExpanded(expanded: boolean): void { this.toolOutputExpanded = expanded; this.applyChatExpansion(); @@ -7292,6 +7319,9 @@ export class InteractiveMode { if (hasAgentMessagesExpansion(child)) { child.setAgentMessagesExpanded(this.agentMessagesExpanded); } + if (hasEditDiffsExpansion(child)) { + child.setEditDiffsExpanded(this.editDiffsExpanded); + } } // Expanding/collapsing changes blocks above the viewport, which would // otherwise force a full redraw that scrolls to the top and replays the @@ -9731,6 +9761,7 @@ export class InteractiveMode { const selectModel = this.getAppKeyDisplay("app.model.select"); const expandTools = this.getAppKeyDisplay("app.tools.expand"); const expandMessages = this.getAppKeyDisplay("app.messages.expand"); + const expandEdits = this.getAppKeyDisplay("app.edits.expand"); const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle"); const externalEditor = this.getAppKeyDisplay("app.editor.external"); const promptStash = this.getAppKeyDisplay("app.prompt.stash"); @@ -9744,7 +9775,7 @@ export class InteractiveMode { **Controls** \`${selectModel}\` select model · \`/effort\` set reasoning · \`${expandTools}\` tool output -\`${expandMessages}\` agent messages · \`${toggleThinking}\` thinking blocks · \`${promptStash}\` stash prompt · \`${externalEditor}\` edit in \`$EDITOR\` +\`${expandMessages}\` agent messages · \`${expandEdits}\` edit diffs · \`${toggleThinking}\` thinking blocks · \`${promptStash}\` stash prompt · \`${externalEditor}\` edit in \`$EDITOR\` \`${pasteImage}\` paste image **Help** @@ -9783,6 +9814,7 @@ ${shortcutsKey ? `\`${shortcutsKey}\` quick shortcuts · ` : ""}\`/hotkeys\` ful const selectModel = this.getAppKeyDisplay("app.model.select"); const expandTools = this.getAppKeyDisplay("app.tools.expand"); const expandMessages = this.getAppKeyDisplay("app.messages.expand"); + const expandEdits = this.getAppKeyDisplay("app.edits.expand"); const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle"); const focusSubagents = this.getAppKeyDisplay("app.subagents.focus"); const manageHeartbeats = this.getAppKeyDisplay("app.heartbeats.open"); @@ -9832,6 +9864,7 @@ ${interrupt ? `| \`${interrupt}\` | Interrupt current operation |\n` : ""}${shor | \`${selectModel}\` | Open model selector | | \`${expandTools}\` | Toggle tool output expansion | | \`${expandMessages}\` | Toggle agent message expansion | +| \`${expandEdits}\` | Toggle edit diff expansion | | \`${toggleThinking}\` | Toggle thinking block visibility | | \`${focusSubagents}\` | Focus the subagent summary / open the scoped agents view | | \`${manageHeartbeats}\` | Manage heartbeats | diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 3d8ae677ec..165d219d8d 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -4209,6 +4209,7 @@ describe("InteractiveMode.setToolsExpanded", () => { const fakeThis: any = { toolOutputExpanded: false, agentMessagesExpanded: false, + editDiffsExpanded: false, customHeader: undefined, builtInHeader: { setExpanded: vi.fn() }, chatContainer: { children: chatChildren }, @@ -4237,7 +4238,7 @@ describe("InteractiveMode.setToolsExpanded", () => { test("toggles agent messages separately from tools", () => { const toolChild = { setExpanded: vi.fn() }; - const ipythonChild = { setExpanded: vi.fn(), setAgentMessagesExpanded: vi.fn() }; + const ipythonChild = { setExpanded: vi.fn(), setAgentMessagesExpanded: vi.fn(), setEditDiffsExpanded: vi.fn() }; const messageChild = new AgentMessageComponent({ role: "custom", customType: "agent_message", @@ -4266,6 +4267,26 @@ describe("InteractiveMode.setToolsExpanded", () => { expect(ipythonChild.setAgentMessagesExpanded).toHaveBeenLastCalledWith(true); expect(fakeThis.agentMessagesExpanded).toBe(true); }); + + test("toggles edit diffs separately from tools and agent messages", () => { + const child = { setExpanded: vi.fn(), setAgentMessagesExpanded: vi.fn(), setEditDiffsExpanded: vi.fn() }; + const fakeThis = createExpansionFakeThis([child]); + + fakeThis.toggleEditDiffExpansion(); + + expect(fakeThis.editDiffsExpanded).toBe(true); + expect(fakeThis.toolOutputExpanded).toBe(false); + expect(fakeThis.agentMessagesExpanded).toBe(false); + expect(child.setEditDiffsExpanded).toHaveBeenCalledWith(true); + expect(child.setExpanded).toHaveBeenCalledWith(false); + expect(child.setAgentMessagesExpanded).toHaveBeenCalledWith(false); + + fakeThis.setToolsExpanded(true); + + expect(fakeThis.editDiffsExpanded).toBe(true); + expect(child.setEditDiffsExpanded).toHaveBeenLastCalledWith(true); + expect(child.setExpanded).toHaveBeenLastCalledWith(true); + }); }); describe("InteractiveMode.createExtensionUIContext setTheme", () => { diff --git a/packages/coding-agent/test/ipython-cell-diff.test.ts b/packages/coding-agent/test/ipython-cell-diff.test.ts index ce62599882..48851a8ad6 100644 --- a/packages/coding-agent/test/ipython-cell-diff.test.ts +++ b/packages/coding-agent/test/ipython-cell-diff.test.ts @@ -67,6 +67,36 @@ describe("IPythonCellComponent diff rendering", () => { expect(out).toContain('await edit(path="sample.py", old_str="gamma", new_str="GAMMA")'); }); + it("shows diffs on collapsed cells when edit diffs are expanded", () => { + const state = { + code: 'await edit(path="sample.py", old_str="gamma", new_str="GAMMA")\nprint("edit-done-marker")', + details: { + status: "ok", + durationMs: 12, + result: "'Edited sample.py'", + stdout: "unrelated stdout line", + diffs: [{ path: "sample.py", oldStr: "alpha\ngamma\ndelta", newStr: "alpha\nGAMMA\ndelta", startLine: 10 }], + }, + executionStarted: true, + argsComplete: true, + }; + + const collapsedWithDiffs = renderCell({ ...state, expanded: false, editDiffsExpanded: true }); + expect(collapsedWithDiffs).toMatch(/11 - .*gamma/); + expect(collapsedWithDiffs).toMatch(/11 \+ .*GAMMA/); + // The cell stays collapsed otherwise: no code body beyond the summary preview, no stdout. + expect(collapsedWithDiffs).not.toContain('print("edit-done-marker")'); + expect(collapsedWithDiffs).not.toContain("unrelated stdout line"); + + const collapsed = renderCell({ ...state, expanded: false, editDiffsExpanded: false }); + expect(collapsed).not.toMatch(/11 - .*gamma/); + expect(collapsed).not.toMatch(/11 \+ .*GAMMA/); + + const expanded = renderCell({ ...state, expanded: true, editDiffsExpanded: false }); + expect(expanded).toMatch(/11 - .*gamma/); + expect(expanded).toContain('print("edit-done-marker")'); + }); + it("renders diff rows as full-width colored blocks", () => { const width = 72; const lines = new IPythonCellComponent({ diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index f86b284fdd..cc6e0d7c38 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -428,6 +428,37 @@ describe("ToolExecutionComponent parity", () => { expect(rendered.match(/\bedit\b/g)?.length ?? 0).toBe(1); }); + test("shows the built-in edit diff on collapsed tool calls when edit diffs are expanded", () => { + const component = new ToolExecutionComponent( + "edit", + "tool-4e", + { path: "README.md", oldText: "before", newText: "after" }, + {}, + createEditToolDefinition(process.cwd()), + createFakeTui(), + process.cwd(), + ); + component.updateResult( + { content: [], details: { diff: "-1 before\n+1 after", firstChangedLine: 1 }, isError: false }, + false, + ); + + const collapsed = stripAnsi(component.render(120).join("\n")); + expect(collapsed).not.toContain("-1 before"); + expect(collapsed).toContain("+1 -1"); + + component.setEditDiffsExpanded(true); + const withDiffs = stripAnsi(component.render(120).join("\n")); + expect(withDiffs).toContain("-1 before"); + expect(withDiffs).toContain("+1 after"); + expect(withDiffs).not.toContain("+1 -1"); + + component.setEditDiffsExpanded(false); + const collapsedAgain = stripAnsi(component.render(120).join("\n")); + expect(collapsedAgain).not.toContain("-1 before"); + expect(collapsedAgain).toContain("+1 -1"); + }); + test("uses the generic result fallback for legacy-named custom tools", () => { const overrideDefinition: ToolDefinition = { ...createBaseToolDefinition("bash"), From 551c5308859e96b78d3207ddfa290f75dcc2ba9f Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 14 Aug 2026 17:06:41 +0200 Subject: [PATCH 02/12] feat(coding-agent): make ctrl+j sole owner of edit-diff visibility and hint the summary line --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/src/core/tools/edit.ts | 2 +- .../interactive/components/edit-summary.ts | 10 ++++++++-- .../interactive/components/ipython-cell.ts | 2 +- .../interactive/components/tool-execution.ts | 16 +++++++++------- .../test/edit-tool-no-full-redraw.test.ts | 4 ++-- .../coding-agent/test/ipython-cell-diff.test.ts | 17 +++++++++++++++-- .../test/tool-execution-component.test.ts | 17 ++++++++++++----- 8 files changed, 49 insertions(+), 21 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 659fa19021..929ee361ea 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -- Added `app.edits.expand` (`ctrl+j`) to show full edit diffs on collapsed tool calls, separately from the `ctrl+o` tool-output toggle. +- Added `app.edits.expand` (`ctrl+j`) to toggle edit diffs; diffs are now shown only by this toggle, and `ctrl+o` no longer affects them. - Fixed fullscreen wheel scrolling in Ghostty while retaining application link clicks; set `terminal.fullscreenMouse` to `false` to use native Cmd-click instead. - Changed the agents view to sort idle and inactive sessions by last message time, newest first, while keeping running agents in stable creation order. - Fixed `openai-codex` models being invisible to `rlm` subagents and `find_models` because model discovery reported Prime Agent's own version as the Codex client version ([#1375](https://github.com/PrimeIntellect-ai/prime-agent/pull/1375) by [@bilelrais](https://github.com/bilelrais)). diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 2582ead14a..f1e2f24def 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -259,7 +259,7 @@ function buildEditCallComponent( component.clear(); const canExpand = component.preview !== undefined && !("error" in component.preview); const expandHint = - canExpand && showExpandHint ? `${theme.fg("dim", " · ")}${expandCollapseHint("app.tools.expand", expanded)}` : ""; + canExpand && showExpandHint ? `${theme.fg("dim", " · ")}${expandCollapseHint("app.edits.expand", expanded)}` : ""; component.addChild(new Text(`${formatEditCall(args, theme)}${expandHint}`, 0, 0)); const body = diff --git a/packages/coding-agent/src/modes/interactive/components/edit-summary.ts b/packages/coding-agent/src/modes/interactive/components/edit-summary.ts index 2f8b142525..97c03c1e62 100644 --- a/packages/coding-agent/src/modes/interactive/components/edit-summary.ts +++ b/packages/coding-agent/src/modes/interactive/components/edit-summary.ts @@ -8,6 +8,7 @@ import type { IpythonToolDetails } from "../../../core/tools/ipython.js"; import { resolveToCwd } from "../../../core/tools/path-utils.js"; import { canonicalizePath, formatPathRelativeToCwdOrAbsolute } from "../../../utils/paths.js"; import { theme } from "../theme/theme.js"; +import { expandCollapseHint } from "./keybinding-hints.js"; export interface FileChangeSummary { path: string; @@ -94,13 +95,18 @@ export class FileChangeSummaryComponent implements Component { constructor( private readonly changes: readonly FileChangeSummary[], private readonly cwd: string, + private readonly diffsExpanded?: boolean, ) {} render(width: number): string[] { const safeWidth = Math.max(1, width); const prefix = theme.fg("dim", " ╰─ "); - return this.changes.map((change) => { - const suffix = `${theme.fg("dim", " ")}${counts(change)}`; + const hint = + this.diffsExpanded === undefined + ? "" + : `${theme.fg("dim", " · ")}${expandCollapseHint("app.edits.expand", this.diffsExpanded)}`; + return this.changes.map((change, index) => { + const suffix = `${theme.fg("dim", " ")}${counts(change)}${index === this.changes.length - 1 ? hint : ""}`; const available = Math.max(1, safeWidth - visibleWidth(prefix) - visibleWidth(suffix)); const path = truncateToWidth(formatFileChangePath(change.path, this.cwd), available, "…"); return truncateToWidth(`${prefix}${theme.fg("muted", path)}${suffix}`, safeWidth, ""); diff --git a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts index ff73efe5f5..7ef091830b 100644 --- a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts +++ b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts @@ -384,7 +384,7 @@ export class IPythonCellComponent implements Component { const lines = [truncateToWidth(` ${this.collapsedLine(details)}`, safeWidth, "")]; const hasCode = this.state.expanded ? this.renderCode(lines, safeWidth) : false; - if ((details.diffs?.length ?? 0) > 0 && (this.state.expanded || this.state.editDiffsExpanded)) { + if ((details.diffs?.length ?? 0) > 0 && this.state.editDiffsExpanded) { this.renderDiffs(lines, safeWidth, details.diffs ?? [], this.marker(details)); } if ((details.sentAgentMessages?.length ?? 0) > 0) { 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 4ebbdec00d..0182bfbd42 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -183,10 +183,6 @@ export class ToolExecutionComponent extends Container { ); } - private effectiveExpanded(): boolean { - return this.expanded || (this.editDiffsExpanded && this.isBuiltInEditTool()); - } - private getRenderContext(lastComponent: Component | undefined): ToolRenderContext { return { args: this.args, @@ -201,7 +197,7 @@ export class ToolExecutionComponent extends Container { executionStarted: this.executionStarted, argsComplete: this.argsComplete, isPartial: this.isPartial, - expanded: this.effectiveExpanded(), + expanded: this.isBuiltInEditTool() ? this.editDiffsExpanded : this.expanded, showExpandHint: this.showExpandHint, showImages: this.showImages, includeImageDimensions: this.includeImageDimensions, @@ -419,11 +415,17 @@ export class ToolExecutionComponent extends Container { } } - if (!this.effectiveExpanded() && this.result && (this.isBuiltInEditTool() || this.shouldUseIpythonRenderer())) { + if (!this.editDiffsExpanded && this.result && (this.isBuiltInEditTool() || this.shouldUseIpythonRenderer())) { const changes = getToolFileChanges(this.toolName, this.args, this.result, this.cwd); if (changes.length > 0) { const container = this.usesSelfRenderShell() ? this.selfRenderContainer : this.contentPanel; - container.addChild(new FileChangeSummaryComponent(changes, this.cwd)); + container.addChild( + new FileChangeSummaryComponent( + changes, + this.cwd, + this.showExpandHint ? this.editDiffsExpanded : undefined, + ), + ); hasContent = true; } } diff --git a/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts b/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts index e346f5c0a4..36983c76c7 100644 --- a/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts +++ b/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts @@ -126,7 +126,7 @@ describe("edit tool TUI rendering", () => { tui.start(); await waitForRender(); - component.setExpanded(true); + component.setEditDiffsExpanded(true); component.setArgsComplete(); tui.requestRender(); await waitForRender(); @@ -196,7 +196,7 @@ describe("edit tool TUI rendering", () => { tui.start(); await waitForRender(); - component.setExpanded(true); + component.setEditDiffsExpanded(true); component.updateResult( { content: [{ type: "text", text: `Successfully replaced ${edits.length} block(s) in ${filePath}.` }], diff --git a/packages/coding-agent/test/ipython-cell-diff.test.ts b/packages/coding-agent/test/ipython-cell-diff.test.ts index 48851a8ad6..c79715c721 100644 --- a/packages/coding-agent/test/ipython-cell-diff.test.ts +++ b/packages/coding-agent/test/ipython-cell-diff.test.ts @@ -51,6 +51,7 @@ describe("IPythonCellComponent diff rendering", () => { executionStarted: true, argsComplete: true, expanded: true, + editDiffsExpanded: true, }); // Header carries the path (no "edit" label) and the +/- line counts. @@ -93,7 +94,7 @@ describe("IPythonCellComponent diff rendering", () => { expect(collapsed).not.toMatch(/11 \+ .*GAMMA/); const expanded = renderCell({ ...state, expanded: true, editDiffsExpanded: false }); - expect(expanded).toMatch(/11 - .*gamma/); + expect(expanded).not.toMatch(/11 - .*gamma/); expect(expanded).toContain('print("edit-done-marker")'); }); @@ -108,6 +109,7 @@ describe("IPythonCellComponent diff rendering", () => { executionStarted: true, argsComplete: true, expanded: true, + editDiffsExpanded: true, }).render(width); const diffRows = lines.filter((line) => /alpha|gamma|GAMMA/.test(stripAnsi(line))); expect(diffRows.length).toBeGreaterThan(0); @@ -123,6 +125,7 @@ describe("IPythonCellComponent diff rendering", () => { executionStarted: true, argsComplete: true, expanded: true, + editDiffsExpanded: true, }).split("\n"); // Summary line and header share the same single-space indent. expect(done[0]).toMatch(/^ ✓ python/); @@ -134,6 +137,7 @@ describe("IPythonCellComponent diff rendering", () => { executionStarted: true, argsComplete: true, expanded: true, + editDiffsExpanded: true, isError: true, }); expect(failed).toMatch(/✗ a\.ts/); @@ -148,6 +152,7 @@ describe("IPythonCellComponent diff rendering", () => { executionStarted: true, argsComplete: true, expanded: true, + editDiffsExpanded: true, }); expect(inside).toContain("src/app.ts"); expect(inside).not.toContain(`${cwd}/src/app.ts`); @@ -159,6 +164,7 @@ describe("IPythonCellComponent diff rendering", () => { executionStarted: true, argsComplete: true, expanded: true, + editDiffsExpanded: true, }); expect(outside).toContain("/etc/hosts"); }); @@ -172,6 +178,7 @@ describe("IPythonCellComponent diff rendering", () => { executionStarted: true, argsComplete: true, expanded: true, + editDiffsExpanded: true, }).render(width); // No rendered row may exceed the terminal width (the TUI throws if one does). @@ -193,6 +200,7 @@ describe("IPythonCellComponent diff rendering", () => { executionStarted: true, argsComplete: true, expanded: true, + editDiffsExpanded: true, }).render(width); expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true); const header = lines.map(stripAnsi).find((line) => line.includes("…")); @@ -212,6 +220,7 @@ describe("IPythonCellComponent diff rendering", () => { executionStarted: true, argsComplete: true, expanded: true, + editDiffsExpanded: true, }).render(80), ).not.toThrow(); }); @@ -224,6 +233,7 @@ describe("IPythonCellComponent diff rendering", () => { executionStarted: true, argsComplete: true, expanded: true, + editDiffsExpanded: true, }).split("\n"); const addedRows = out.filter((line) => /arg\d/.test(line)); expect(addedRows.length).toBeGreaterThan(1); @@ -238,6 +248,7 @@ describe("IPythonCellComponent diff rendering", () => { executionStarted: true, argsComplete: true, expanded: true, + editDiffsExpanded: true, }).split("\n"); expect(out[0]).toContain("to collapse"); expect(out[1].trim()).toBe(""); @@ -267,7 +278,7 @@ describe("IPythonCellComponent diff rendering", () => { argsComplete: true, }; const collapsed = renderCell({ ...state, expanded: false }); - const expanded = renderCell({ ...state, expanded: true }); + const expanded = renderCell({ ...state, expanded: true, editDiffsExpanded: true }); expect(collapsed).not.toContain("hidden_side_effect"); expect(collapsed).toContain("a.py"); @@ -297,6 +308,7 @@ describe("IPythonCellComponent diff rendering", () => { const out = renderCell({ code: "await edit(...); await edit(...); await edit(...)", expanded: true, + editDiffsExpanded: true, details: { status: "ok", diffs: [ @@ -350,6 +362,7 @@ describe("IPythonCellComponent diff rendering", () => { executionStarted: true, argsComplete: true, expanded: true, + editDiffsExpanded: true, }); expect(out).toContain("a.ts"); expect(out).not.toContain("no output"); diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index cc6e0d7c38..be957d85d5 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -446,6 +446,8 @@ describe("ToolExecutionComponent parity", () => { const collapsed = stripAnsi(component.render(120).join("\n")); expect(collapsed).not.toContain("-1 before"); expect(collapsed).toContain("+1 -1"); + // The collapsed `╰─ path +N -M` summary line carries the ctrl+j hint. + expect(collapsed.split("\n").find((line) => line.includes("╰─"))).toContain("to expand"); component.setEditDiffsExpanded(true); const withDiffs = stripAnsi(component.render(120).join("\n")); @@ -725,14 +727,19 @@ describe("ToolExecutionComponent parity", () => { expect(collapsed).not.toMatch(/1 - before/); expect(collapsed).not.toMatch(/1 \+ after/); + // Tool expansion shows the full source but never the diff; that belongs to ctrl+j. component.setExpanded(true); const expanded = stripAnsi(component.render(120).join("\n")); expect(expanded).toContain('hidden_side_effect = "only in full source"'); - expect(expanded).toContain("before"); - expect(expanded).toContain("after"); - const expandedLines = expanded.split("\n"); - expect(expandedLines.findIndex((line) => line.includes("hidden_side_effect ="))).toBeLessThan( - expandedLines.findIndex((line) => /✓ README\.md\s+\+1 -1/.test(line)), + expect(expanded).toContain("╰─ README.md +1 -1"); + expect(expanded).not.toMatch(/1 - before/); + + component.setEditDiffsExpanded(true); + const withDiffs = stripAnsi(component.render(120).join("\n")); + const withDiffLines = withDiffs.split("\n"); + expect(withDiffLines.findIndex((line) => line.includes("hidden_side_effect ="))).toBeLessThan( + withDiffLines.findIndex((line) => /✓ README\.md\s+\+1 -1/.test(line)), ); + expect(withDiffs).not.toContain("╰─ README.md +1 -1"); }); }); From 07fdec5483f0f97c8eee78e0bd49e59597b512bb Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 14 Aug 2026 18:53:55 +0200 Subject: [PATCH 03/12] feat(coding-agent): always show the edit summary line and render the diff inline beneath it --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/tools/edit.ts | 85 +++++++++++++++---- .../interactive/components/edit-summary.ts | 58 +++++++------ .../interactive/components/ipython-cell.ts | 38 ++++++--- .../interactive/components/tool-execution.ts | 16 ---- .../test/ipython-cell-diff.test.ts | 57 +++++++------ .../test/tool-execution-component.test.ts | 83 ++++++++++++++++-- 7 files changed, 230 insertions(+), 108 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 929ee361ea..60eb9e88a6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - Added `app.edits.expand` (`ctrl+j`) to toggle edit diffs; diffs are now shown only by this toggle, and `ctrl+o` no longer affects them. +- Changed edit rendering so the `╰─ +N -M` summary line is always visible and `ctrl+j` toggles the diff inline beneath it, indented to the summary text. - Fixed fullscreen wheel scrolling in Ghostty while retaining application link clicks; set `terminal.fullscreenMouse` to `false` to use native Cmd-click instead. - Changed the agents view to sort idle and inactive sessions by last message time, newest first, while keeping running agents in stable creation order. - Fixed `openai-codex` models being invisible to `rlm` subagents and `find_models` because model discovery reported Prime Agent's own version as the Codex client version ([#1375](https://github.com/PrimeIntellect-ai/prime-agent/pull/1375) by [@bilelrais](https://github.com/bilelrais)). diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index f1e2f24def..46911e7bcb 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -1,10 +1,15 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; -import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui"; +import { Box, type Component, Container, Spacer, Text, wrapTextWithAnsi } from "@earendil-works/pi-tui"; import { constants } from "fs"; import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises"; import { type Static, Type } from "typebox"; import { renderDiff } from "../../modes/interactive/components/diff.js"; -import { expandCollapseHint } from "../../modes/interactive/components/keybinding-hints.js"; +import { + countChangedLines, + FILE_CHANGE_DIFF_INDENT, + formatFileChangePath, + formatFileChangeSummaryLine, +} from "../../modes/interactive/components/edit-summary.js"; import type { ToolDefinition } from "../extensions/types.js"; import { applyEditsToNormalizedContent, @@ -248,31 +253,69 @@ function getEditHeaderBg( return (text: string) => theme.bg("toolPendingBg", text); } +// Width-aware `╰─ +N -M` summary plus optional indented diff rows: the +// summary truncates to one row and wrapped diff lines keep the indent column. +class EditChangeSummaryComponent implements Component { + constructor( + private readonly displayPath: string, + private readonly change: { added: number; removed: number }, + private readonly diffsExpanded: boolean | undefined, + private readonly diffLines: readonly string[] | undefined, + ) {} + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const lines = [formatFileChangeSummaryLine(this.displayPath, this.change, this.diffsExpanded, safeWidth)]; + if (this.diffLines !== undefined) { + const indent = FILE_CHANGE_DIFF_INDENT.slice(0, Math.max(0, safeWidth - 1)); + const contentWidth = Math.max(1, safeWidth - indent.length); + for (const line of this.diffLines) { + for (const row of wrapTextWithAnsi(line, contentWidth)) { + lines.push(`${indent}${row}`); + } + } + } + return lines; + } + + invalidate(): void {} +} + function buildEditCallComponent( component: EditCallRenderComponent, args: RenderableEditArgs | undefined, theme: typeof import("../../modes/interactive/theme/theme.js").theme, expanded: boolean, showExpandHint: boolean, + cwd: string, ): EditCallRenderComponent { component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme)); component.clear(); - const canExpand = component.preview !== undefined && !("error" in component.preview); - const expandHint = - canExpand && showExpandHint ? `${theme.fg("dim", " · ")}${expandCollapseHint("app.edits.expand", expanded)}` : ""; - component.addChild(new Text(`${formatEditCall(args, theme)}${expandHint}`, 0, 0)); - - const body = - component.preview && - ("error" in component.preview - ? theme.fg("error", component.preview.error) - : expanded - ? renderDiff(component.preview.diff) - : undefined); - if (body) { + component.addChild(new Text(formatEditCall(args, theme), 0, 0)); + + if (component.preview && "error" in component.preview) { component.addChild(new Spacer(1)); - component.addChild(new Text(body, 0, 0)); + component.addChild(new Text(theme.fg("error", component.preview.error), 0, 0)); + return component; + } + if (!component.preview) { + return component; } + + // The `╰─ +N -M` summary line renders in both states; ctrl+j only + // attaches or removes the indented diff lines underneath it. + const rawPath = str(args?.file_path ?? args?.path); + const displayPath = rawPath !== null ? formatFileChangePath(rawPath, cwd) : "..."; + const change = countChangedLines(component.preview.diff); + component.addChild(new Spacer(1)); + component.addChild( + new EditChangeSummaryComponent( + displayPath, + change, + showExpandHint ? expanded : undefined, + expanded ? renderDiff(component.preview.diff).split("\n") : undefined, + ), + ); return component; } @@ -447,7 +490,14 @@ export function createEditToolDefinition( }); } - return buildEditCallComponent(component, args, theme, context.expanded, context.showExpandHint !== false); + return buildEditCallComponent( + component, + args, + theme, + context.expanded, + context.showExpandHint !== false, + context.cwd, + ); }, renderResult(result, _options, theme, context) { const callComponent = context.state.callComponent; @@ -478,6 +528,7 @@ export function createEditToolDefinition( theme, context.expanded, context.showExpandHint !== false, + context.cwd, ); } } diff --git a/packages/coding-agent/src/modes/interactive/components/edit-summary.ts b/packages/coding-agent/src/modes/interactive/components/edit-summary.ts index 97c03c1e62..710e122d63 100644 --- a/packages/coding-agent/src/modes/interactive/components/edit-summary.ts +++ b/packages/coding-agent/src/modes/interactive/components/edit-summary.ts @@ -1,7 +1,7 @@ import { isAbsolute } from "node:path"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import type { EditToolDetails } from "../../../core/tools/edit.js"; import { generateDiffString } from "../../../core/tools/edit-diff.js"; import type { IpythonToolDetails } from "../../../core/tools/ipython.js"; @@ -16,7 +16,7 @@ export interface FileChangeSummary { removed: number; } -function countChangedLines(diff: string): { added: number; removed: number } { +export function countChangedLines(diff: string): { added: number; removed: number } { let added = 0; let removed = 0; for (const line of diff.split("\n")) { @@ -80,40 +80,42 @@ export function mergeTurnFileChanges( } } -function counts(change: Pick): string { +/** Dim gutter that anchors every per-file change summary line. */ +export const FILE_CHANGE_SUMMARY_PREFIX = " ╰─ "; +/** Indent that aligns diff rows with the summary line's text column. */ +export const FILE_CHANGE_DIFF_INDENT = " ".repeat(visibleWidth(FILE_CHANGE_SUMMARY_PREFIX)); + +export function formatChangeCounts(change: Pick): string { return `${theme.fg("toolDiffAdded", `+${change.added}`)} ${theme.fg("toolDiffRemoved", `-${change.removed}`)}`; } -function formatFileChangePath(path: string, cwd: string): string { +export function formatFileChangePath(path: string, cwd: string): string { const resolvedPath = resolveToCwd(path, cwd); const lexicalPath = formatPathRelativeToCwdOrAbsolute(resolvedPath, cwd); if (!isAbsolute(lexicalPath)) return lexicalPath; return formatPathRelativeToCwdOrAbsolute(canonicalizePath(resolvedPath), canonicalizePath(cwd)); } -export class FileChangeSummaryComponent implements Component { - constructor( - private readonly changes: readonly FileChangeSummary[], - private readonly cwd: string, - private readonly diffsExpanded?: boolean, - ) {} - - render(width: number): string[] { - const safeWidth = Math.max(1, width); - const prefix = theme.fg("dim", " ╰─ "); - const hint = - this.diffsExpanded === undefined - ? "" - : `${theme.fg("dim", " · ")}${expandCollapseHint("app.edits.expand", this.diffsExpanded)}`; - return this.changes.map((change, index) => { - const suffix = `${theme.fg("dim", " ")}${counts(change)}${index === this.changes.length - 1 ? hint : ""}`; - const available = Math.max(1, safeWidth - visibleWidth(prefix) - visibleWidth(suffix)); - const path = truncateToWidth(formatFileChangePath(change.path, this.cwd), available, "…"); - return truncateToWidth(`${prefix}${theme.fg("muted", path)}${suffix}`, safeWidth, ""); - }); - } - - invalidate(): void {} +/** + * One ` ╰─ +N -M` row, truncated to width; the hint renders only when + * diffsExpanded is defined. + */ +export function formatFileChangeSummaryLine( + displayPath: string, + change: Pick, + diffsExpanded: boolean | undefined, + width: number, +): string { + const prefix = theme.fg("dim", FILE_CHANGE_SUMMARY_PREFIX); + const hint = + diffsExpanded === undefined + ? "" + : `${theme.fg("dim", " · ")}${expandCollapseHint("app.edits.expand", diffsExpanded)}`; + const suffix = `${theme.fg("dim", " ")}${formatChangeCounts(change)}${hint}`; + const safeWidth = Math.max(1, width); + const available = Math.max(1, safeWidth - visibleWidth(prefix) - visibleWidth(suffix)); + const path = truncateToWidth(displayPath, available, "…"); + return truncateToWidth(`${prefix}${theme.fg("muted", path)}${suffix}`, safeWidth, ""); } export function formatTotalChangeSummary(changes: readonly FileChangeSummary[]): string { @@ -122,5 +124,5 @@ export function formatTotalChangeSummary(changes: readonly FileChangeSummary[]): { added: 0, removed: 0 }, ); const files = `${changes.length} file${changes.length === 1 ? "" : "s"} changed`; - return `${theme.fg("muted", files)}${theme.fg("dim", " | ")}${counts(totals)}`; + return `${theme.fg("muted", files)}${theme.fg("dim", " | ")}${formatChangeCounts(totals)}`; } diff --git a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts index 7ef091830b..bd9cb6dc0e 100644 --- a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts +++ b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts @@ -16,6 +16,7 @@ import { getWorkingPulseFrame, WORKING_ICON_FRAMES, workingIconFrame } from "../ import { agentMessageBodyLines, agentMessagePreview, agentMessageSummaryLine } from "./agent-message.js"; import { normalizeErrorDetails, summarizeErrorDetails } from "./collapsible-error.js"; import { renderDiffSeparator, renderRichDiff } from "./diff.js"; +import { FILE_CHANGE_DIFF_INDENT, formatFileChangeSummaryLine } from "./edit-summary.js"; import { expandCollapseHint } from "./keybinding-hints.js"; export interface IPythonCellContentBlock { @@ -384,8 +385,8 @@ export class IPythonCellComponent implements Component { const lines = [truncateToWidth(` ${this.collapsedLine(details)}`, safeWidth, "")]; const hasCode = this.state.expanded ? this.renderCode(lines, safeWidth) : false; - if ((details.diffs?.length ?? 0) > 0 && this.state.editDiffsExpanded) { - this.renderDiffs(lines, safeWidth, details.diffs ?? [], this.marker(details)); + if ((details.diffs?.length ?? 0) > 0) { + this.renderDiffs(lines, safeWidth, details.diffs ?? [], hasCode); } if ((details.sentAgentMessages?.length ?? 0) > 0) { this.renderSentAgentMessages(lines, safeWidth, details.sentAgentMessages ?? []); @@ -665,16 +666,22 @@ export class IPythonCellComponent implements Component { } } - private renderDiffs(lines: string[], width: number, diffs: readonly DiffDisplay[], marker: string): void { + // The `╰─ +N -M` summary line renders in both states; ctrl+j only + // attaches or removes the indented diff rows underneath it. + private renderDiffs(lines: string[], width: number, diffs: readonly DiffDisplay[], hasCode: boolean): void { const diffsByPath = new Map(); for (const diff of diffs) { const existing = diffsByPath.get(diff.path); if (existing) existing.push(diff); else diffsByPath.set(diff.path, [diff]); } - for (const [path, edits] of diffsByPath) { + if (hasCode) { this.addPlain(lines, ""); - this.renderFileDiff(lines, width, path, edits, marker); + } + let index = 0; + for (const [path, edits] of diffsByPath) { + index += 1; + this.renderFileDiff(lines, width, path, edits, index === diffsByPath.size); } } @@ -683,9 +690,12 @@ export class IPythonCellComponent implements Component { width: number, path: string, edits: readonly DiffDisplay[], - marker: string, + showHint: boolean, ): void { const language = getLanguageFromPath(path); + // Diff rows align with the summary line's text column (after the `╰─ ` gutter). + const indent = FILE_CHANGE_DIFF_INDENT.slice(0, Math.max(0, width - 1)); + const contentWidth = Math.max(1, width - indent.length); let added = 0; let removed = 0; const rows: string[] = []; @@ -695,21 +705,21 @@ export class IPythonCellComponent implements Component { if (row.startsWith("+")) added++; else if (row.startsWith("-")) removed++; } + if (!this.state.editDiffsExpanded) { + return; + } if (index > 0) { - rows.push(renderDiffSeparator(width)); + rows.push(`${indent}${renderDiffSeparator(contentWidth)}`); } // Append, not spread: a huge edit's diff can exceed the JS arg-count limit. - for (const row of renderRichDiff(diffText, width, { language })) { - rows.push(row); + for (const row of renderRichDiff(diffText, contentWidth, { language })) { + rows.push(`${indent}${row}`); } }); - const counts = `${theme.fg("toolDiffAdded", `+${added}`)} ${theme.fg("toolDiffRemoved", `-${removed}`)}`; const displayPath = displayEditPath(path, this.state.cwd); - // Truncate the path (not the counts) so it can't push the header past width. - const fixed = visibleWidth(marker) + 1 + 2 + visibleWidth(counts); - const shownPath = truncateToWidth(displayPath, Math.max(1, width - 1 - fixed), "…"); - this.addPlain(lines, `${marker} ${shownPath} ${counts}`); + const hint = showHint && this.state.showExpandHint !== false ? this.state.editDiffsExpanded === true : undefined; + lines.push(formatFileChangeSummaryLine(displayPath, { added, removed }, hint, width)); for (const row of rows) { lines.push(row); 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 0182bfbd42..3785b4c64d 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -9,7 +9,6 @@ import { getTextOutput as getRenderedTextOutput } from "../../../core/tools/rend import type { AgentConnectionToolDefinition } from "../../agent-connection/index.js"; import { type Theme, theme } from "../theme/theme.js"; import { getWorkingPulseFrame, workingIconFrame } from "../theme/working-icon.js"; -import { FileChangeSummaryComponent, getToolFileChanges } from "./edit-summary.js"; import { getIpythonCodeFromArgs, IPythonCellComponent } from "./ipython-cell.js"; import { ToolPanel } from "./tool-panel.js"; @@ -415,21 +414,6 @@ export class ToolExecutionComponent extends Container { } } - if (!this.editDiffsExpanded && this.result && (this.isBuiltInEditTool() || this.shouldUseIpythonRenderer())) { - const changes = getToolFileChanges(this.toolName, this.args, this.result, this.cwd); - if (changes.length > 0) { - const container = this.usesSelfRenderShell() ? this.selfRenderContainer : this.contentPanel; - container.addChild( - new FileChangeSummaryComponent( - changes, - this.cwd, - this.showExpandHint ? this.editDiffsExpanded : undefined, - ), - ); - hasContent = true; - } - } - if (this.hasRendererDefinition() && !hasContent && this.imageComponents.length === 0) { this.hideComponent = true; } diff --git a/packages/coding-agent/test/ipython-cell-diff.test.ts b/packages/coding-agent/test/ipython-cell-diff.test.ts index c79715c721..8f8af7568d 100644 --- a/packages/coding-agent/test/ipython-cell-diff.test.ts +++ b/packages/coding-agent/test/ipython-cell-diff.test.ts @@ -118,29 +118,34 @@ describe("IPythonCellComponent diff rendering", () => { expect(diffRows.some(hasBackground)).toBe(true); }); - it("prefixes the header with the cell's status marker and aligns it with the summary line", () => { - const done = renderCell({ + it("always shows the summary line and indents diff rows to its text column", () => { + const state = { code: "await edit(...)", details: { status: "ok", diffs: [{ path: "a.ts", oldStr: "x", newStr: "X", startLine: 1 }] }, executionStarted: true, argsComplete: true, - expanded: true, - editDiffsExpanded: true, - }).split("\n"); - // Summary line and header share the same single-space indent. - expect(done[0]).toMatch(/^ ✓ python/); - expect(done.find((l) => l.includes("a.ts"))).toMatch(/^ ✓ a\.ts/); + expanded: false, + }; - const failed = renderCell({ - code: "await edit(...)", - details: { status: "error", diffs: [{ path: "a.ts", oldStr: "x", newStr: "X", startLine: 1 }] }, - executionStarted: true, - argsComplete: true, - expanded: true, - editDiffsExpanded: true, - isError: true, - }); - expect(failed).toMatch(/✗ a\.ts/); + const hidden = renderCell({ ...state, editDiffsExpanded: false }).split("\n"); + const hiddenSummary = hidden.find((l) => l.includes("╰─ a.ts")); + expect(hiddenSummary).toMatch(/^ {4}╰─ a\.ts \+1 -1 · .*to expand\)$/); + expect(hidden.some((l) => /1 - .*x/.test(l))).toBe(false); + + const shown = renderCell({ ...state, editDiffsExpanded: true }).split("\n"); + const summary = shown.find((l) => l.includes("╰─ a.ts")); + expect(summary).toMatch(/^ {4}╰─ a\.ts \+1 -1 · .*to collapse\)$/); + // Diff rows align with the summary's text column (after the ` ╰─ ` gutter). + const textColumn = (summary ?? "").indexOf("a.ts"); + const removed = shown.find((l) => /1 - .*x/.test(l)); + const added = shown.find((l) => /1 \+ .*X/.test(l)); + expect(removed).toBeDefined(); + expect(added).toBeDefined(); + for (const row of [removed ?? "", added ?? ""]) { + expect(row.startsWith(" ".repeat(textColumn))).toBe(true); + } + // Toggling only adds the diff rows underneath; the summary line is stable. + expect(shown.filter((l) => !/\d+ [-+ ] /.test(l) && !l.includes("⋮")).length).toBe(hidden.length); }); it("renders an edit path relative to the session cwd, or absolute when outside it", () => { @@ -191,7 +196,7 @@ describe("IPythonCellComponent diff rendering", () => { expect(lines.filter((line) => /arg\d/.test(stripAnsi(line))).length).toBeGreaterThan(1); }); - it("truncates a long header path so it never overflows the width, keeping the counts", () => { + it("truncates a long summary path so it never overflows the width, keeping the counts", () => { const width = 40; const longPath = `src/${"very-long-directory-name/".repeat(8)}file.ts`; const lines = new IPythonCellComponent({ @@ -203,10 +208,10 @@ describe("IPythonCellComponent diff rendering", () => { editDiffsExpanded: true, }).render(width); expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true); - const header = lines.map(stripAnsi).find((line) => line.includes("…")); - expect(header).toBeDefined(); - // The +/- counts survive truncation; only the path is shortened. - expect(header).toMatch(/\+1 -1\s*$/); + const summary = lines.map(stripAnsi).find((line) => line.includes("…")); + expect(summary).toBeDefined(); + // The +/- counts and hint survive truncation; only the path is shortened. + expect(summary).toMatch(/\+1 -1 · /); }); it("renders a large diff without spreading the row array (no RangeError)", () => { @@ -256,7 +261,7 @@ describe("IPythonCellComponent diff rendering", () => { expect(out.findIndex((line) => line.includes("a.ts"))).toBeGreaterThan(2); }); - it("shows the full diff when collapsed", () => { + it("keeps the summary line but hides diff rows when edit diffs are collapsed", () => { const collapsed = renderCell({ code: "await edit(...)", details: { status: "ok", diffs: [{ path: "big.py", oldStr: "old", newStr: "NEW", startLine: 1 }] }, @@ -264,8 +269,8 @@ describe("IPythonCellComponent diff rendering", () => { argsComplete: true, expanded: false, }); + expect(collapsed).toContain("╰─ big.py +1 -1"); expect(collapsed).toContain("to expand"); - expect(collapsed).not.toContain("big.py"); expect(collapsed).not.toContain("old"); expect(collapsed).not.toContain("NEW"); }); @@ -286,7 +291,7 @@ describe("IPythonCellComponent diff rendering", () => { expect(expanded).toContain("a.py"); const expandedLines = expanded.split("\n"); expect(expandedLines.findIndex((line) => line.includes("hidden_side_effect ="))).toBeLessThan( - expandedLines.findIndex((line) => /✓ a\.py\s+\+1 -1/.test(line)), + expandedLines.findIndex((line) => /╰─ a\.py \+1 -1/.test(line)), ); }); diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index be957d85d5..c9221d6df2 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -446,14 +446,20 @@ describe("ToolExecutionComponent parity", () => { const collapsed = stripAnsi(component.render(120).join("\n")); expect(collapsed).not.toContain("-1 before"); expect(collapsed).toContain("+1 -1"); - // The collapsed `╰─ path +N -M` summary line carries the ctrl+j hint. + // The `╰─ path +N -M` summary line carries the ctrl+j hint. expect(collapsed.split("\n").find((line) => line.includes("╰─"))).toContain("to expand"); component.setEditDiffsExpanded(true); - const withDiffs = stripAnsi(component.render(120).join("\n")); - expect(withDiffs).toContain("-1 before"); - expect(withDiffs).toContain("+1 after"); - expect(withDiffs).not.toContain("+1 -1"); + const withDiffLines = stripAnsi(component.render(120).join("\n")).split("\n"); + // The summary line stays put; the diff renders under it, indented to its text column. + const summaryIndex = withDiffLines.findIndex((line) => line.includes("╰─ README.md +1 -1")); + expect(summaryIndex).toBeGreaterThanOrEqual(0); + expect(withDiffLines[summaryIndex]).toContain("to collapse"); + const textColumn = withDiffLines[summaryIndex].indexOf("README.md"); + const removed = withDiffLines.find((line) => line.includes("-1 before")); + const added = withDiffLines.find((line) => line.includes("+1 after")); + expect(removed?.startsWith(" ".repeat(textColumn))).toBe(true); + expect(added?.startsWith(" ".repeat(textColumn))).toBe(true); component.setEditDiffsExpanded(false); const collapsedAgain = stripAnsi(component.render(120).join("\n")); @@ -461,6 +467,66 @@ describe("ToolExecutionComponent parity", () => { expect(collapsedAgain).toContain("+1 -1"); }); + test("built-in edit summary truncates a long path to one row and keeps counts and hint", () => { + const longPath = "deeply/nested/directory/structure/with/a/really/long/file-name-that-overflows.md"; + const component = new ToolExecutionComponent( + "edit", + "tool-4f", + { path: longPath, oldText: "before", newText: "after" }, + {}, + createEditToolDefinition(process.cwd()), + createFakeTui(), + process.cwd(), + ); + component.updateResult( + { content: [], details: { diff: "-1 before\n+1 after", firstChangedLine: 1 }, isError: false }, + false, + ); + const lines = stripAnsi(component.render(40).join("\n")).split("\n"); + const summaryLines = lines.filter((line) => line.includes("╰─")); + expect(summaryLines.length).toBe(1); + expect(summaryLines[0]).toContain("…"); + expect(summaryLines[0]).toContain("+1 -1"); + expect(summaryLines[0]).toContain("to expand"); + for (const line of lines) { + expect(line.length).toBeLessThanOrEqual(40); + } + }); + + test("built-in edit diff rows keep the summary text column when a diff line wraps", () => { + const component = new ToolExecutionComponent( + "edit", + "tool-4g", + { path: "README.md", oldText: "before", newText: "after" }, + {}, + createEditToolDefinition(process.cwd()), + createFakeTui(), + process.cwd(), + ); + const longLine = + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau"; + component.updateResult( + { content: [], details: { diff: `+1 ${longLine}`, firstChangedLine: 1 }, isError: false }, + false, + ); + component.setEditDiffsExpanded(true); + const lines = stripAnsi(component.render(60).join("\n")).split("\n"); + const summaryIndex = lines.findIndex((line) => line.includes("╰─ README.md")); + expect(summaryIndex).toBeGreaterThanOrEqual(0); + const textColumn = lines[summaryIndex].indexOf("README.md"); + const diffRows: string[] = []; + for (let i = summaryIndex + 1; i < lines.length && lines[i].trim() !== ""; i++) { + diffRows.push(lines[i]); + } + // The single logical diff line wraps; every continuation row stays anchored at the text column. + expect(diffRows.length).toBeGreaterThan(1); + for (const row of diffRows) { + expect(row.startsWith(" ".repeat(textColumn))).toBe(true); + expect(row[textColumn]).not.toBe(" "); + } + expect(diffRows.join(" ")).toContain("tau"); + }); + test("uses the generic result fallback for legacy-named custom tools", () => { const overrideDefinition: ToolDefinition = { ...createBaseToolDefinition("bash"), @@ -738,8 +804,11 @@ describe("ToolExecutionComponent parity", () => { const withDiffs = stripAnsi(component.render(120).join("\n")); const withDiffLines = withDiffs.split("\n"); expect(withDiffLines.findIndex((line) => line.includes("hidden_side_effect ="))).toBeLessThan( - withDiffLines.findIndex((line) => /✓ README\.md\s+\+1 -1/.test(line)), + withDiffLines.findIndex((line) => line.includes("╰─ README.md +1 -1")), ); - expect(withDiffs).not.toContain("╰─ README.md +1 -1"); + // Exactly one summary line — the cell owns the block; no extra component doubles it. + expect(withDiffLines.filter((line) => line.includes("╰─ README.md +1 -1")).length).toBe(1); + expect(withDiffs).toMatch(/1 - before/); + expect(withDiffs).toMatch(/1 \+ after/); }); }); From 50ad136de170163176f261b6082eca0d1f7eea4b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 14 Aug 2026 19:41:43 +0200 Subject: [PATCH 04/12] fix(coding-agent): suppress edit summary on failed edits, unify summary path formatting, trim dead exports --- packages/coding-agent/src/core/tools/edit.ts | 13 ++++--- .../interactive/components/edit-summary.ts | 14 ++++--- .../interactive/components/ipython-cell.ts | 17 +------- .../test/ipython-cell-diff.test.ts | 30 ++++++++++++++ .../test/tool-execution-component.test.ts | 39 +++++++++++++++++++ 5 files changed, 85 insertions(+), 28 deletions(-) diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 46911e7bcb..6ee38b5f17 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -7,7 +7,6 @@ import { renderDiff } from "../../modes/interactive/components/diff.js"; import { countChangedLines, FILE_CHANGE_DIFF_INDENT, - formatFileChangePath, formatFileChangeSummaryLine, } from "../../modes/interactive/components/edit-summary.js"; import type { ToolDefinition } from "../extensions/types.js"; @@ -257,7 +256,8 @@ function getEditHeaderBg( // summary truncates to one row and wrapped diff lines keep the indent column. class EditChangeSummaryComponent implements Component { constructor( - private readonly displayPath: string, + private readonly rawPath: string, + private readonly cwd: string, private readonly change: { added: number; removed: number }, private readonly diffsExpanded: boolean | undefined, private readonly diffLines: readonly string[] | undefined, @@ -265,7 +265,7 @@ class EditChangeSummaryComponent implements Component { render(width: number): string[] { const safeWidth = Math.max(1, width); - const lines = [formatFileChangeSummaryLine(this.displayPath, this.change, this.diffsExpanded, safeWidth)]; + const lines = [formatFileChangeSummaryLine(this.rawPath, this.cwd, this.change, this.diffsExpanded, safeWidth)]; if (this.diffLines !== undefined) { const indent = FILE_CHANGE_DIFF_INDENT.slice(0, Math.max(0, safeWidth - 1)); const contentWidth = Math.max(1, safeWidth - indent.length); @@ -298,19 +298,20 @@ function buildEditCallComponent( component.addChild(new Text(theme.fg("error", component.preview.error), 0, 0)); return component; } - if (!component.preview) { + // A failed execution must not present the predicted diff as applied changes. + if (!component.preview || component.settledError) { return component; } // The `╰─ +N -M` summary line renders in both states; ctrl+j only // attaches or removes the indented diff lines underneath it. const rawPath = str(args?.file_path ?? args?.path); - const displayPath = rawPath !== null ? formatFileChangePath(rawPath, cwd) : "..."; const change = countChangedLines(component.preview.diff); component.addChild(new Spacer(1)); component.addChild( new EditChangeSummaryComponent( - displayPath, + rawPath ?? "...", + cwd, change, showExpandHint ? expanded : undefined, expanded ? renderDiff(component.preview.diff).split("\n") : undefined, diff --git a/packages/coding-agent/src/modes/interactive/components/edit-summary.ts b/packages/coding-agent/src/modes/interactive/components/edit-summary.ts index 710e122d63..c298184a56 100644 --- a/packages/coding-agent/src/modes/interactive/components/edit-summary.ts +++ b/packages/coding-agent/src/modes/interactive/components/edit-summary.ts @@ -81,15 +81,15 @@ export function mergeTurnFileChanges( } /** Dim gutter that anchors every per-file change summary line. */ -export const FILE_CHANGE_SUMMARY_PREFIX = " ╰─ "; +const FILE_CHANGE_SUMMARY_PREFIX = " ╰─ "; /** Indent that aligns diff rows with the summary line's text column. */ export const FILE_CHANGE_DIFF_INDENT = " ".repeat(visibleWidth(FILE_CHANGE_SUMMARY_PREFIX)); -export function formatChangeCounts(change: Pick): string { +function formatChangeCounts(change: Pick): string { return `${theme.fg("toolDiffAdded", `+${change.added}`)} ${theme.fg("toolDiffRemoved", `-${change.removed}`)}`; } -export function formatFileChangePath(path: string, cwd: string): string { +function formatFileChangePath(path: string, cwd: string): string { const resolvedPath = resolveToCwd(path, cwd); const lexicalPath = formatPathRelativeToCwdOrAbsolute(resolvedPath, cwd); if (!isAbsolute(lexicalPath)) return lexicalPath; @@ -97,11 +97,12 @@ export function formatFileChangePath(path: string, cwd: string): string { } /** - * One ` ╰─ +N -M` row, truncated to width; the hint renders only when - * diffsExpanded is defined. + * One ` ╰─ +N -M` row, truncated to width; the path renders relative + * to cwd where possible and the hint renders only when diffsExpanded is defined. */ export function formatFileChangeSummaryLine( - displayPath: string, + rawPath: string, + cwd: string | undefined, change: Pick, diffsExpanded: boolean | undefined, width: number, @@ -114,6 +115,7 @@ export function formatFileChangeSummaryLine( const suffix = `${theme.fg("dim", " ")}${formatChangeCounts(change)}${hint}`; const safeWidth = Math.max(1, width); const available = Math.max(1, safeWidth - visibleWidth(prefix) - visibleWidth(suffix)); + const displayPath = cwd === undefined ? rawPath : formatFileChangePath(rawPath, cwd); const path = truncateToWidth(displayPath, available, "…"); return truncateToWidth(`${prefix}${theme.fg("muted", path)}${suffix}`, safeWidth, ""); } diff --git a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts index bd9cb6dc0e..2fe2e85995 100644 --- a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts +++ b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts @@ -1,4 +1,3 @@ -import { isAbsolute, relative } from "node:path"; import { type Component, truncateToWidth, @@ -10,7 +9,6 @@ import { formatAgentMessageParticipant } from "../../../core/agent-messages.js"; import { previewIpythonCode } from "../../../core/tools/code-preview.js"; import { generateDiffString } from "../../../core/tools/edit-diff.js"; import { parseIpythonBashCell } from "../../../core/tools/ipython-cell-code.js"; -import { shortenPath } from "../../../core/tools/render-utils.js"; import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; import { getWorkingPulseFrame, WORKING_ICON_FRAMES, workingIconFrame } from "../theme/working-icon.js"; import { agentMessageBodyLines, agentMessagePreview, agentMessageSummaryLine } from "./agent-message.js"; @@ -286,18 +284,6 @@ function formatDuration(durationMs: number | undefined): string | undefined { return `${(durationMs / 1000).toFixed(1)}s`; } -// Relative to the session cwd when nested under it, else the absolute path. -function displayEditPath(path: string, cwd: string | undefined): string { - if (cwd && isAbsolute(path)) { - const rel = relative(cwd, path); - if (rel && !rel.startsWith("..") && !isAbsolute(rel)) { - return rel; - } - return shortenPath(path); - } - return path; -} - function isImageBlock(block: IPythonCellContentBlock): boolean { return block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string"; } @@ -717,9 +703,8 @@ export class IPythonCellComponent implements Component { } }); - const displayPath = displayEditPath(path, this.state.cwd); const hint = showHint && this.state.showExpandHint !== false ? this.state.editDiffsExpanded === true : undefined; - lines.push(formatFileChangeSummaryLine(displayPath, { added, removed }, hint, width)); + lines.push(formatFileChangeSummaryLine(path, this.state.cwd, { added, removed }, hint, width)); for (const row of rows) { lines.push(row); diff --git a/packages/coding-agent/test/ipython-cell-diff.test.ts b/packages/coding-agent/test/ipython-cell-diff.test.ts index 8f8af7568d..9914266f16 100644 --- a/packages/coding-agent/test/ipython-cell-diff.test.ts +++ b/packages/coding-agent/test/ipython-cell-diff.test.ts @@ -1,3 +1,6 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { visibleWidth } from "@earendil-works/pi-tui"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { renderRichDiff } from "../src/modes/interactive/components/diff.js"; @@ -174,6 +177,33 @@ describe("IPythonCellComponent diff rendering", () => { expect(outside).toContain("/etc/hosts"); }); + it("formats the summary path like the built-in edit tool, resolving symlinked cwds", () => { + const root = mkdtempSync(join(tmpdir(), "ipython-cell-diff-symlink-")); + try { + const realCwd = join(root, "real"); + const linkedCwd = join(root, "linked"); + mkdirSync(realCwd); + writeFileSync(join(realCwd, "same.ts"), "x"); + symlinkSync(realCwd, linkedCwd, "dir"); + const out = renderCell({ + code: "await edit(...)", + cwd: linkedCwd, + details: { + status: "ok", + diffs: [{ path: join(realpathSync(realCwd), "same.ts"), oldStr: "x", newStr: "X", startLine: 1 }], + }, + executionStarted: true, + argsComplete: true, + expanded: false, + editDiffsExpanded: false, + }); + // Same cwd-relative output the built-in edit tool's formatFileChangePath gives. + expect(out).toContain("╰─ same.ts +1 -1"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it("wraps a long diff line across rows without truncating or overflowing the width", () => { const width = 92; const longLine = `const x = ${Array.from({ length: 30 }, (_, i) => `arg${i}`).join(", ")};`; diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index c9221d6df2..29b678055f 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -1,3 +1,6 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Container, resetCapabilitiesCache, setCapabilities, Text, TUI } from "@earendil-works/pi-tui"; import stripAnsi from "strip-ansi"; import { Type } from "typebox"; @@ -467,6 +470,42 @@ describe("ToolExecutionComponent parity", () => { expect(collapsedAgain).toContain("+1 -1"); }); + test("suppresses the built-in edit summary and diff when execution fails", async () => { + const dir = await mkdtemp(join(tmpdir(), "pi-edit-failed-")); + try { + const filePath = join(dir, "sample.txt"); + await writeFile(filePath, "before\n", "utf8"); + const component = new ToolExecutionComponent( + "edit", + "tool-4err", + { path: filePath, edits: [{ oldText: "before", newText: "after" }] }, + {}, + createEditToolDefinition(dir), + createFakeTui(), + dir, + ); + component.setEditDiffsExpanded(true); + const deadline = Date.now() + 2000; + while (Date.now() < deadline && !stripAnsi(component.render(120).join("\n")).includes("+1 -1")) { + component.setArgsComplete(); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(stripAnsi(component.render(120).join("\n"))).toContain("+1 -1"); + + component.updateResult( + { content: [{ type: "text", text: "Could not edit file: sample.txt." }], isError: true }, + false, + ); + const rendered = stripAnsi(component.render(120).join("\n")); + expect(rendered).not.toContain("╰─"); + expect(rendered).not.toContain("+1 -1"); + expect(rendered).not.toContain("-1 before"); + expect(rendered).not.toContain("+1 after"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + test("built-in edit summary truncates a long path to one row and keeps counts and hint", () => { const longPath = "deeply/nested/directory/structure/with/a/really/long/file-name-that-overflows.md"; const component = new ToolExecutionComponent( From c9bb457c9d6b7d85328fa318cbce98f9bf35bf9c Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 14 Aug 2026 19:55:42 +0200 Subject: [PATCH 05/12] fix(coding-agent): color the edit header as error when execution fails after a successful preview --- packages/coding-agent/src/core/tools/edit.ts | 9 +++------ .../coding-agent/test/tool-execution-component.test.ts | 8 +++++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 6ee38b5f17..ef84274108 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -240,15 +240,12 @@ function getEditHeaderBg( settledError: boolean | undefined, theme: typeof import("../../modes/interactive/theme/theme.js").theme, ): (text: string) => string { + if (settledError || (preview && "error" in preview)) { + return (text: string) => theme.bg("toolErrorBg", text); + } if (preview) { - if ("error" in preview) { - return (text: string) => theme.bg("toolErrorBg", text); - } return (text: string) => theme.bg("toolSuccessBg", text); } - if (settledError) { - return (text: string) => theme.bg("toolErrorBg", text); - } return (text: string) => theme.bg("toolPendingBg", text); } diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 29b678055f..1656803ecf 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -11,7 +11,7 @@ import { type BashOperations, createBashTool, createBashToolDefinition } from ". import { createEditToolDefinition } from "../src/core/tools/edit.js"; import { createAgentConnectionToolDefinition } from "../src/modes/agent-connection/tool-definition.js"; import { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js"; -import { initTheme } from "../src/modes/interactive/theme/theme.js"; +import { initTheme, theme } from "../src/modes/interactive/theme/theme.js"; import { getWorkingPulseFrame, workingIconFrame } from "../src/modes/interactive/theme/working-icon.js"; function createBaseToolDefinition(name = "custom_tool"): ToolDefinition { @@ -501,6 +501,12 @@ describe("ToolExecutionComponent parity", () => { expect(rendered).not.toContain("+1 -1"); expect(rendered).not.toContain("-1 before"); expect(rendered).not.toContain("+1 after"); + + // The header must switch to the error background even though the + // async preview itself succeeded before execution failed. + const rawRendered = component.render(120).join("\n"); + expect(rawRendered).toContain(theme.bg("toolErrorBg", "").slice(0, -"\x1b[49m".length)); + expect(rawRendered).not.toContain(theme.bg("toolSuccessBg", "").slice(0, -"\x1b[49m".length)); } finally { await rm(dir, { recursive: true, force: true }); } From 13841120e1f45a8e609d351028e70cd1f0a6321c Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 16 Aug 2026 11:30:22 +0200 Subject: [PATCH 06/12] fix(coding-agent): always show the ctrl+j hint on edit summary rows The edit-diff hint was threaded through showExpandHint, the flag that restricts the ctrl+o hint to the latest tool row. Since the agent almost always runs more tools after an edit, edit rows stopped being "latest" immediately and the ctrl+j hint effectively never appeared. The ctrl+j hint now renders on every edit summary row, matching the always-visible thinking (ctrl+t) and agent-message (ctrl+p) hints. The latest-row gating still applies to the ctrl+o hint on the header line. --- packages/coding-agent/src/core/tools/edit.ts | 16 +++++----------- .../modes/interactive/components/ipython-cell.ts | 4 +++- .../coding-agent/test/ipython-cell-diff.test.ts | 5 +++++ 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index ef84274108..e0eb0502f0 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -283,7 +283,6 @@ function buildEditCallComponent( args: RenderableEditArgs | undefined, theme: typeof import("../../modes/interactive/theme/theme.js").theme, expanded: boolean, - showExpandHint: boolean, cwd: string, ): EditCallRenderComponent { component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme)); @@ -310,7 +309,10 @@ function buildEditCallComponent( rawPath ?? "...", cwd, change, - showExpandHint ? expanded : undefined, + // The ctrl+j hint renders on every edit summary row (unlike the ctrl+o + // hint, which the latest tool row owns), matching thinking and + // agent-message hints. + expanded, expanded ? renderDiff(component.preview.diff).split("\n") : undefined, ), ); @@ -488,14 +490,7 @@ export function createEditToolDefinition( }); } - return buildEditCallComponent( - component, - args, - theme, - context.expanded, - context.showExpandHint !== false, - context.cwd, - ); + return buildEditCallComponent(component, args, theme, context.expanded, context.cwd); }, renderResult(result, _options, theme, context) { const callComponent = context.state.callComponent; @@ -525,7 +520,6 @@ export function createEditToolDefinition( context.args as RenderableEditArgs | undefined, theme, context.expanded, - context.showExpandHint !== false, context.cwd, ); } diff --git a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts index 2fe2e85995..a6423d5a7d 100644 --- a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts +++ b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts @@ -703,7 +703,9 @@ export class IPythonCellComponent implements Component { } }); - const hint = showHint && this.state.showExpandHint !== false ? this.state.editDiffsExpanded === true : undefined; + // Unlike the ctrl+o hint (latest tool row only), the ctrl+j hint renders on + // every edit summary row, matching the thinking and agent-message hints. + const hint = showHint ? this.state.editDiffsExpanded === true : undefined; lines.push(formatFileChangeSummaryLine(path, this.state.cwd, { added, removed }, hint, width)); for (const row of rows) { diff --git a/packages/coding-agent/test/ipython-cell-diff.test.ts b/packages/coding-agent/test/ipython-cell-diff.test.ts index 9914266f16..3e681eb5b2 100644 --- a/packages/coding-agent/test/ipython-cell-diff.test.ts +++ b/packages/coding-agent/test/ipython-cell-diff.test.ts @@ -133,6 +133,11 @@ describe("IPythonCellComponent diff rendering", () => { const hidden = renderCell({ ...state, editDiffsExpanded: false }).split("\n"); const hiddenSummary = hidden.find((l) => l.includes("╰─ a.ts")); expect(hiddenSummary).toMatch(/^ {4}╰─ a\.ts \+1 -1 · .*to expand\)$/); + + // The ctrl+j hint is not owned by the latest tool row: it stays visible + // on rows whose ctrl+o hint is suppressed (showExpandHint=false). + const notLatest = renderCell({ ...state, editDiffsExpanded: false, showExpandHint: false }).split("\n"); + expect(notLatest.find((l) => l.includes("╰─ a.ts"))).toMatch(/to expand\)$/); expect(hidden.some((l) => /1 - .*x/.test(l))).toBe(false); const shown = renderCell({ ...state, editDiffsExpanded: true }).split("\n"); From 13ab4c29c712e826cf53a8e5c29e54dea926dc7a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 16 Aug 2026 12:13:03 +0200 Subject: [PATCH 07/12] fix(coding-agent): stop duplicating the ctrl+j hint on collapsed edits Collapsed built-in edits showed the hint twice: on the edit header and on the summary line. The header hint now renders only when the diff is expanded (where no summary line exists); collapsed rows keep the single hint on the summary line. --- packages/coding-agent/src/core/tools/edit.ts | 6 +++++- packages/coding-agent/test/tool-execution-component.test.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index f1e2f24def..281c5b4fdb 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -258,8 +258,12 @@ function buildEditCallComponent( component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme)); component.clear(); const canExpand = component.preview !== undefined && !("error" in component.preview); + // Collapsed rows carry the ctrl+j hint on the `╰─ path +N -M` summary line, + // so the header only hints when expanded (where no summary line renders). const expandHint = - canExpand && showExpandHint ? `${theme.fg("dim", " · ")}${expandCollapseHint("app.edits.expand", expanded)}` : ""; + canExpand && showExpandHint && expanded + ? `${theme.fg("dim", " · ")}${expandCollapseHint("app.edits.expand", expanded)}` + : ""; component.addChild(new Text(`${formatEditCall(args, theme)}${expandHint}`, 0, 0)); const body = diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index be957d85d5..0e58141e22 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -446,8 +446,10 @@ describe("ToolExecutionComponent parity", () => { const collapsed = stripAnsi(component.render(120).join("\n")); expect(collapsed).not.toContain("-1 before"); expect(collapsed).toContain("+1 -1"); - // The collapsed `╰─ path +N -M` summary line carries the ctrl+j hint. + // The collapsed `╰─ path +N -M` summary line carries the ctrl+j hint — + // and it is the only carrier: the header must not duplicate it. expect(collapsed.split("\n").find((line) => line.includes("╰─"))).toContain("to expand"); + expect(collapsed.split("to expand").length - 1).toBe(1); component.setEditDiffsExpanded(true); const withDiffs = stripAnsi(component.render(120).join("\n")); From 71399d78d475192f9a6ca3e8d79ed55ac15c98a2 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 16 Aug 2026 12:14:31 +0200 Subject: [PATCH 08/12] docs(coding-agent): correct the ctrl+j hint comment to match showHint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hint renders on every tool row, but within a row only on the last file's summary line — the comment claimed every summary row. --- .../src/modes/interactive/components/ipython-cell.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts index a6423d5a7d..332cf422fe 100644 --- a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts +++ b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts @@ -704,7 +704,8 @@ export class IPythonCellComponent implements Component { }); // Unlike the ctrl+o hint (latest tool row only), the ctrl+j hint renders on - // every edit summary row, matching the thinking and agent-message hints. + // every tool row, matching the thinking and agent-message hints. Within a + // row it renders once, on the last file's summary line (showHint). const hint = showHint ? this.state.editDiffsExpanded === true : undefined; lines.push(formatFileChangeSummaryLine(path, this.state.cwd, { added, removed }, hint, width)); From 77ea5300d0f48e0d2035e6040f07d876c0110071 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 16 Aug 2026 12:27:34 +0200 Subject: [PATCH 09/12] fix(coding-agent): keep the ctrl+j hint visible while no summary line renders Gating the header hint on expansion assumed the collapsed summary line always carries the cue, but that summary only mounts once a successful result with a countable diff lands. During the preview-only window and on error rows the diff was expandable with no visible hint. The header now keeps the hint whenever the summary line is absent (mirroring its mount condition) and yields it once the summary renders, so exactly one hint is visible in every state. --- packages/coding-agent/src/core/tools/edit.ts | 18 ++++++-- .../test/tool-execution-component.test.ts | 43 ++++++++++++++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 281c5b4fdb..229ea1cb04 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -141,6 +141,7 @@ type EditCallRenderComponent = Box & { previewArgsKey?: string; previewPending?: boolean; settledError?: boolean; + resultSettled?: boolean; }; function createEditCallRenderComponent(): EditCallRenderComponent { @@ -149,6 +150,7 @@ function createEditCallRenderComponent(): EditCallRenderComponent { previewArgsKey: undefined as string | undefined, previewPending: false, settledError: false, + resultSettled: false, }); } @@ -258,10 +260,13 @@ function buildEditCallComponent( component.setBgFn(getEditHeaderBg(component.preview, component.settledError, theme)); component.clear(); const canExpand = component.preview !== undefined && !("error" in component.preview); - // Collapsed rows carry the ctrl+j hint on the `╰─ path +N -M` summary line, - // so the header only hints when expanded (where no summary line renders). + // Collapsed rows normally carry the ctrl+j hint on the `╰─ path +N -M` + // summary line instead of the header — but that summary only mounts after a + // settled successful result. Until then (preview-only) and on error rows the + // header keeps the hint, so an expandable diff always advertises the key. + const hasSummaryLine = component.resultSettled === true; const expandHint = - canExpand && showExpandHint && expanded + canExpand && showExpandHint && (expanded || !hasSummaryLine) ? `${theme.fg("dim", " · ")}${expandCollapseHint("app.edits.expand", expanded)}` : ""; component.addChild(new Text(`${formatEditCall(args, theme)}${expandHint}`, 0, 0)); @@ -475,6 +480,13 @@ export function createEditToolDefinition( callComponent.settledError = context.isError; changed = true; } + // Mirrors the FileChangeSummaryComponent mount condition: any result + // with a countable diff and no error mounts the summary line. + const summaryMounts = !context.isError && typeof resultDiff === "string"; + if (callComponent.resultSettled !== summaryMounts) { + callComponent.resultSettled = summaryMounts; + changed = true; + } if (changed) { buildEditCallComponent( callComponent, diff --git a/packages/coding-agent/test/tool-execution-component.test.ts b/packages/coding-agent/test/tool-execution-component.test.ts index 0e58141e22..57688465dd 100644 --- a/packages/coding-agent/test/tool-execution-component.test.ts +++ b/packages/coding-agent/test/tool-execution-component.test.ts @@ -1,7 +1,10 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { Container, resetCapabilitiesCache, setCapabilities, Text, TUI } from "@earendil-works/pi-tui"; import stripAnsi from "strip-ansi"; import { Type } from "typebox"; -import { beforeAll, describe, expect, test } from "vitest"; +import { beforeAll, describe, expect, test, vi } from "vitest"; import { VirtualTerminal } from "../../tui/test/virtual-terminal.js"; import type { ToolDefinition } from "../src/core/extensions/types.js"; import { type BashOperations, createBashTool, createBashToolDefinition } from "../src/core/tools/bash.js"; @@ -463,6 +466,44 @@ describe("ToolExecutionComponent parity", () => { expect(collapsedAgain).toContain("+1 -1"); }); + test("keeps the ctrl+j hint on the header while no summary line renders", async () => { + const dir = mkdtempSync(join(tmpdir(), "edit-hint-")); + const filePath = join(dir, "sample.txt"); + writeFileSync(filePath, "before\n"); + try { + const component = new ToolExecutionComponent( + "edit", + "tool-4h", + { path: filePath, oldText: "before", newText: "after" }, + {}, + createEditToolDefinition(dir), + createFakeTui(), + dir, + ); + component.setArgsComplete(); + component.render(120); + // The preview computes asynchronously; poll until it lands. + await vi.waitFor(() => { + expect(stripAnsi(component.render(120).join("\n"))).toContain("to expand"); + }); + // Pre-result: no summary line exists yet, so the header carries the hint. + const preResult = stripAnsi(component.render(120).join("\n")); + expect(preResult).not.toContain("╰─"); + expect(preResult.split("to expand").length - 1).toBe(1); + + // Once a successful result lands, the summary line takes over the hint. + component.updateResult( + { content: [], details: { diff: "-1 before\n+1 after", firstChangedLine: 1 }, isError: false }, + false, + ); + const settled = stripAnsi(component.render(120).join("\n")); + expect(settled.split("\n").find((line) => line.includes("╰─"))).toContain("to expand"); + expect(settled.split("to expand").length - 1).toBe(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("uses the generic result fallback for legacy-named custom tools", () => { const overrideDefinition: ToolDefinition = { ...createBaseToolDefinition("bash"), From bd57b5bcfc1c5145eb9373e0eefb96f3bdc2a30e Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 16 Aug 2026 22:14:05 +0200 Subject: [PATCH 10/12] fix(coding-agent): advertise the collapse key on expanded ipython diff headers --- .../interactive/components/ipython-cell.ts | 11 +++++++-- .../test/ipython-cell-diff.test.ts | 24 ++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts index 7ef091830b..60ba2b9a72 100644 --- a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts +++ b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts @@ -672,9 +672,12 @@ export class IPythonCellComponent implements Component { if (existing) existing.push(diff); else diffsByPath.set(diff.path, [diff]); } + let index = 0; for (const [path, edits] of diffsByPath) { + index += 1; this.addPlain(lines, ""); - this.renderFileDiff(lines, width, path, edits, marker); + const showHint = index === diffsByPath.size && this.state.showExpandHint !== false; + this.renderFileDiff(lines, width, path, edits, marker, showHint); } } @@ -684,6 +687,7 @@ export class IPythonCellComponent implements Component { path: string, edits: readonly DiffDisplay[], marker: string, + showHint: boolean, ): void { const language = getLanguageFromPath(path); let added = 0; @@ -704,7 +708,10 @@ export class IPythonCellComponent implements Component { } }); - const counts = `${theme.fg("toolDiffAdded", `+${added}`)} ${theme.fg("toolDiffRemoved", `-${removed}`)}`; + // The expanded diff replaces the summary line that normally carries the + // ctrl+j hint, so the last file's diff header advertises the collapse key. + const hint = showHint ? `${theme.fg("dim", " · ")}${expandCollapseHint("app.edits.expand", true)}` : ""; + const counts = `${theme.fg("toolDiffAdded", `+${added}`)} ${theme.fg("toolDiffRemoved", `-${removed}`)}${hint}`; const displayPath = displayEditPath(path, this.state.cwd); // Truncate the path (not the counts) so it can't push the header past width. const fixed = visibleWidth(marker) + 1 + 2 + visibleWidth(counts); diff --git a/packages/coding-agent/test/ipython-cell-diff.test.ts b/packages/coding-agent/test/ipython-cell-diff.test.ts index c79715c721..9cd3c08059 100644 --- a/packages/coding-agent/test/ipython-cell-diff.test.ts +++ b/packages/coding-agent/test/ipython-cell-diff.test.ts @@ -206,7 +206,29 @@ describe("IPythonCellComponent diff rendering", () => { const header = lines.map(stripAnsi).find((line) => line.includes("…")); expect(header).toBeDefined(); // The +/- counts survive truncation; only the path is shortened. - expect(header).toMatch(/\+1 -1\s*$/); + expect(header).toMatch(/\+1 -1/); + }); + + it("advertises the collapse key on the last diff header when diffs are expanded", () => { + const lines = new IPythonCellComponent({ + code: "await edit(...)", + details: { + status: "ok", + diffs: [ + { path: "a.ts", oldStr: "x", newStr: "X", startLine: 1 }, + { path: "b.ts", oldStr: "y", newStr: "Y", startLine: 1 }, + ], + }, + executionStarted: true, + argsComplete: true, + expanded: true, + editDiffsExpanded: true, + }).render(120); + const plain = lines.map(stripAnsi); + const hinted = plain.filter((line) => line.includes("to collapse") && /[+]\d+ -\d+/.test(line)); + // Exactly one diff header (the last file's) carries the ctrl+j cue. + expect(hinted).toHaveLength(1); + expect(hinted[0]).toContain("b.ts"); }); it("renders a large diff without spreading the row array (no RangeError)", () => { From fd0b3985451d1f79c24505242dabe7516f2a5ee4 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 16 Aug 2026 22:18:32 +0200 Subject: [PATCH 11/12] fix(coding-agent): move the expanded-diff collapse hint to the truncated cell header --- .../interactive/components/ipython-cell.ts | 16 ++++----- .../test/ipython-cell-diff.test.ts | 33 ++++++++++++------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts index 60ba2b9a72..98986ab16e 100644 --- a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts +++ b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts @@ -429,6 +429,11 @@ export class IPythonCellComponent implements Component { if (this.state.showExpandHint !== false) { parts.push(expandCollapseHint("app.tools.expand", this.state.expanded === true)); + // Expanded diffs replace the summary line that normally carries the + // ctrl+j cue, so the header advertises the collapse key instead. + if (this.state.editDiffsExpanded && (details.diffs?.length ?? 0) > 0) { + parts.push(expandCollapseHint("app.edits.expand", true)); + } } return parts.join(theme.fg("dim", " · ")); } @@ -672,12 +677,9 @@ export class IPythonCellComponent implements Component { if (existing) existing.push(diff); else diffsByPath.set(diff.path, [diff]); } - let index = 0; for (const [path, edits] of diffsByPath) { - index += 1; this.addPlain(lines, ""); - const showHint = index === diffsByPath.size && this.state.showExpandHint !== false; - this.renderFileDiff(lines, width, path, edits, marker, showHint); + this.renderFileDiff(lines, width, path, edits, marker); } } @@ -687,7 +689,6 @@ export class IPythonCellComponent implements Component { path: string, edits: readonly DiffDisplay[], marker: string, - showHint: boolean, ): void { const language = getLanguageFromPath(path); let added = 0; @@ -708,10 +709,7 @@ export class IPythonCellComponent implements Component { } }); - // The expanded diff replaces the summary line that normally carries the - // ctrl+j hint, so the last file's diff header advertises the collapse key. - const hint = showHint ? `${theme.fg("dim", " · ")}${expandCollapseHint("app.edits.expand", true)}` : ""; - const counts = `${theme.fg("toolDiffAdded", `+${added}`)} ${theme.fg("toolDiffRemoved", `-${removed}`)}${hint}`; + const counts = `${theme.fg("toolDiffAdded", `+${added}`)} ${theme.fg("toolDiffRemoved", `-${removed}`)}`; const displayPath = displayEditPath(path, this.state.cwd); // Truncate the path (not the counts) so it can't push the header past width. const fixed = visibleWidth(marker) + 1 + 2 + visibleWidth(counts); diff --git a/packages/coding-agent/test/ipython-cell-diff.test.ts b/packages/coding-agent/test/ipython-cell-diff.test.ts index 9cd3c08059..de525987d7 100644 --- a/packages/coding-agent/test/ipython-cell-diff.test.ts +++ b/packages/coding-agent/test/ipython-cell-diff.test.ts @@ -209,26 +209,37 @@ describe("IPythonCellComponent diff rendering", () => { expect(header).toMatch(/\+1 -1/); }); - it("advertises the collapse key on the last diff header when diffs are expanded", () => { + it("advertises the collapse key on the cell header when diffs are expanded", () => { + const render = (editDiffsExpanded: boolean) => + new IPythonCellComponent({ + code: "await edit(...)", + details: { status: "ok", diffs: [{ path: "a.ts", oldStr: "x", newStr: "X", startLine: 1 }] }, + executionStarted: true, + argsComplete: true, + expanded: false, + editDiffsExpanded, + }).render(120); + const header = (lines: string[]) => stripAnsi(lines[0] ?? ""); + // Expanded diffs: the header carries the ctrl+j cue (the summary line is gone). + expect(header(render(true)).match(/to collapse/g)).toHaveLength(1); + // Collapsed diffs: no ctrl+j cue on the header (the summary line carries it). + expect(header(render(false))).not.toContain("to collapse"); + }); + + it("never overflows a narrow pane when expanded diffs add the header hint", () => { + const width = 24; const lines = new IPythonCellComponent({ code: "await edit(...)", details: { status: "ok", - diffs: [ - { path: "a.ts", oldStr: "x", newStr: "X", startLine: 1 }, - { path: "b.ts", oldStr: "y", newStr: "Y", startLine: 1 }, - ], + diffs: [{ path: "src/some/dir/file.ts", oldStr: "x", newStr: "X", startLine: 1 }], }, executionStarted: true, argsComplete: true, expanded: true, editDiffsExpanded: true, - }).render(120); - const plain = lines.map(stripAnsi); - const hinted = plain.filter((line) => line.includes("to collapse") && /[+]\d+ -\d+/.test(line)); - // Exactly one diff header (the last file's) carries the ctrl+j cue. - expect(hinted).toHaveLength(1); - expect(hinted[0]).toContain("b.ts"); + }).render(width); + expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true); }); it("renders a large diff without spreading the row array (no RangeError)", () => { From b2d9c92e779cfc8a4804908a84d90f6f8168e080 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 17 Aug 2026 10:33:21 +0200 Subject: [PATCH 12/12] fix(coding-agent): stabilize summary-line truncation across the ctrl+j toggle and reuse countChangedLines --- .../interactive/components/edit-summary.ts | 9 +++++++-- .../interactive/components/ipython-cell.ts | 9 ++++----- packages/coding-agent/test/edit-summary.test.ts | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/components/edit-summary.ts b/packages/coding-agent/src/modes/interactive/components/edit-summary.ts index c298184a56..dacf64e261 100644 --- a/packages/coding-agent/src/modes/interactive/components/edit-summary.ts +++ b/packages/coding-agent/src/modes/interactive/components/edit-summary.ts @@ -112,9 +112,14 @@ export function formatFileChangeSummaryLine( diffsExpanded === undefined ? "" : `${theme.fg("dim", " · ")}${expandCollapseHint("app.edits.expand", diffsExpanded)}`; - const suffix = `${theme.fg("dim", " ")}${formatChangeCounts(change)}${hint}`; + // Size the path against the wider hint variant ("to collapse") so toggling + // ctrl+j never re-truncates it — the summary line is a stable anchor. + const widestHint = + diffsExpanded === undefined ? "" : `${theme.fg("dim", " · ")}${expandCollapseHint("app.edits.expand", true)}`; + const counts = `${theme.fg("dim", " ")}${formatChangeCounts(change)}`; + const suffix = `${counts}${hint}`; const safeWidth = Math.max(1, width); - const available = Math.max(1, safeWidth - visibleWidth(prefix) - visibleWidth(suffix)); + const available = Math.max(1, safeWidth - visibleWidth(prefix) - visibleWidth(counts) - visibleWidth(widestHint)); const displayPath = cwd === undefined ? rawPath : formatFileChangePath(rawPath, cwd); const path = truncateToWidth(displayPath, available, "…"); return truncateToWidth(`${prefix}${theme.fg("muted", path)}${suffix}`, safeWidth, ""); diff --git a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts index 332cf422fe..68a8e50396 100644 --- a/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts +++ b/packages/coding-agent/src/modes/interactive/components/ipython-cell.ts @@ -14,7 +14,7 @@ import { getWorkingPulseFrame, WORKING_ICON_FRAMES, workingIconFrame } from "../ import { agentMessageBodyLines, agentMessagePreview, agentMessageSummaryLine } from "./agent-message.js"; import { normalizeErrorDetails, summarizeErrorDetails } from "./collapsible-error.js"; import { renderDiffSeparator, renderRichDiff } from "./diff.js"; -import { FILE_CHANGE_DIFF_INDENT, formatFileChangeSummaryLine } from "./edit-summary.js"; +import { countChangedLines, FILE_CHANGE_DIFF_INDENT, formatFileChangeSummaryLine } from "./edit-summary.js"; import { expandCollapseHint } from "./keybinding-hints.js"; export interface IPythonCellContentBlock { @@ -687,10 +687,9 @@ export class IPythonCellComponent implements Component { const rows: string[] = []; edits.forEach((edit, index) => { const { diff: diffText } = generateDiffString(edit.oldStr, edit.newStr, 4, edit.startLine ?? 1); - for (const row of diffText.split("\n")) { - if (row.startsWith("+")) added++; - else if (row.startsWith("-")) removed++; - } + const counts = countChangedLines(diffText); + added += counts.added; + removed += counts.removed; if (!this.state.editDiffsExpanded) { return; } diff --git a/packages/coding-agent/test/edit-summary.test.ts b/packages/coding-agent/test/edit-summary.test.ts index a5fb9ac27e..0f2aab8537 100644 --- a/packages/coding-agent/test/edit-summary.test.ts +++ b/packages/coding-agent/test/edit-summary.test.ts @@ -5,6 +5,7 @@ import type { AssistantMessage, ToolResultMessage, Usage } from "@earendil-works import stripAnsi from "strip-ansi"; import { beforeAll, describe, expect, test } from "vitest"; import { + formatFileChangeSummaryLine, formatTotalChangeSummary, getToolFileChanges, mergeTurnFileChanges, @@ -136,3 +137,19 @@ describe("edit summaries", () => { } }); }); + +describe("formatFileChangeSummaryLine", () => { + beforeAll(() => initTheme("dark")); + + test("keeps the truncated path stable when the ctrl+j hint flips", () => { + const change = { added: 3, removed: 1 }; + const path = "src/some/deeply/nested/directory/with-a-long-file-name.ts"; + const width = 44; + const pathPart = (line: string) => stripAnsi(line).replace(/\s*\+\d+ -\d+.*$/, ""); + const expanded = formatFileChangeSummaryLine(path, undefined, change, true, width); + const collapsed = formatFileChangeSummaryLine(path, undefined, change, false, width); + expect(stripAnsi(expanded)).toContain("…"); + // "to expand" vs "to collapse" differ in width; the path must not re-truncate. + expect(pathPart(expanded)).toBe(pathPart(collapsed)); + }); +});