Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4027731
feat(coding-agent): add ctrl+j toggle to expand edit diffs independen…
snimu Aug 14, 2026
551c530
feat(coding-agent): make ctrl+j sole owner of edit-diff visibility an…
snimu Aug 14, 2026
07fdec5
feat(coding-agent): always show the edit summary line and render the …
snimu Aug 14, 2026
50ad136
fix(coding-agent): suppress edit summary on failed edits, unify summa…
snimu Aug 14, 2026
c9bb457
fix(coding-agent): color the edit header as error when execution fail…
snimu Aug 14, 2026
1384112
fix(coding-agent): always show the ctrl+j hint on edit summary rows
snimu Aug 16, 2026
7a0c6c1
Merge remote-tracking branch 'origin/main' into feat/edit-diff-toggle
snimu Aug 16, 2026
f29dbb4
Merge remote-tracking branch 'origin/feat/edit-diff-toggle' into feat…
snimu Aug 16, 2026
13ab4c2
fix(coding-agent): stop duplicating the ctrl+j hint on collapsed edits
snimu Aug 16, 2026
45a18ff
Merge remote-tracking branch 'origin/feat/edit-diff-toggle' into feat…
snimu Aug 16, 2026
71399d7
docs(coding-agent): correct the ctrl+j hint comment to match showHint
snimu Aug 16, 2026
77ea530
fix(coding-agent): keep the ctrl+j hint visible while no summary line…
snimu Aug 16, 2026
1fd5b5e
Merge remote-tracking branch 'origin/feat/edit-diff-toggle' into feat…
snimu Aug 16, 2026
72fba0f
Merge main into feat/edit-diff-toggle
snimu Aug 16, 2026
e051fd6
Merge feat/edit-diff-toggle into feat/edit-diff-inline-rendering
snimu Aug 16, 2026
bd57b5b
fix(coding-agent): advertise the collapse key on expanded ipython dif…
snimu Aug 16, 2026
48ec2e0
Merge feat/edit-diff-toggle into feat/edit-diff-inline-rendering
snimu Aug 16, 2026
fd0b398
fix(coding-agent): move the expanded-diff collapse hint to the trunca…
snimu Aug 16, 2026
f1e4270
Merge feat/edit-diff-toggle into feat/edit-diff-inline-rendering
snimu Aug 16, 2026
98b4a73
Merge main into feat/edit-diff-inline-rendering
snimu Aug 17, 2026
b2d9c92
fix(coding-agent): stabilize summary-line truncation across the ctrl+…
snimu Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `╰─ <path> +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)).
Expand Down
109 changes: 68 additions & 41 deletions packages/coding-agent/src/core/tools/edit.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
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,
formatFileChangeSummaryLine,
} from "../../modes/interactive/components/edit-summary.js";
import type { ToolDefinition } from "../extensions/types.js";
import {
applyEditsToNormalizedContent,
Expand Down Expand Up @@ -141,7 +145,6 @@ type EditCallRenderComponent = Box & {
previewArgsKey?: string;
previewPending?: boolean;
settledError?: boolean;
resultSettled?: boolean;
};

function createEditCallRenderComponent(): EditCallRenderComponent {
Expand All @@ -150,7 +153,6 @@ function createEditCallRenderComponent(): EditCallRenderComponent {
previewArgsKey: undefined as string | undefined,
previewPending: false,
settledError: false,
resultSettled: false,
});
}

Expand Down Expand Up @@ -238,50 +240,82 @@ 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);
}

// Width-aware `╰─ <path> +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 rawPath: string,
private readonly cwd: 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.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);
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);
// 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 || !hasSummaryLine)
? `${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;
}
// A failed execution must not present the predicted diff as applied changes.
if (!component.preview || component.settledError) {
return component;
}

// The `╰─ <path> +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 change = countChangedLines(component.preview.diff);
component.addChild(new Spacer(1));
component.addChild(
new EditChangeSummaryComponent(
rawPath ?? "...",
cwd,
change,
// 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,
),
);
return component;
}

Expand Down Expand Up @@ -456,7 +490,7 @@ export function createEditToolDefinition(
});
}

return buildEditCallComponent(component, args, theme, context.expanded, context.showExpandHint !== false);
return buildEditCallComponent(component, args, theme, context.expanded, context.cwd);
},
renderResult(result, _options, theme, context) {
const callComponent = context.state.callComponent;
Expand All @@ -480,20 +514,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,
context.args as RenderableEditArgs | undefined,
theme,
context.expanded,
context.showExpandHint !== false,
context.cwd,
);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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")) {
Expand Down Expand Up @@ -80,7 +80,12 @@ export function mergeTurnFileChanges(
}
}

function counts(change: Pick<FileChangeSummary, "added" | "removed">): string {
/** Dim gutter that anchors every per-file change summary line. */
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));

function formatChangeCounts(change: Pick<FileChangeSummary, "added" | "removed">): string {
return `${theme.fg("toolDiffAdded", `+${change.added}`)} ${theme.fg("toolDiffRemoved", `-${change.removed}`)}`;
}

Expand All @@ -91,29 +96,33 @@ function formatFileChangePath(path: string, cwd: string): string {
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 ` ╰─ <path> +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(
rawPath: string,
cwd: string | undefined,
change: Pick<FileChangeSummary, "added" | "removed">,
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)}`;
// 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(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, "");
Comment thread
cursor[bot] marked this conversation as resolved.
}

export function formatTotalChangeSummary(changes: readonly FileChangeSummary[]): string {
Expand All @@ -122,5 +131,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)}`;
}
Loading