pi-claude-code-ui 1.0.92 → 1.0.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs";
2
2
  import { readFile as readFileAsync } from "node:fs/promises";
3
3
  import { basename, dirname, extname, relative, resolve } from "node:path";
4
4
 
@@ -12,6 +12,7 @@ import type {
12
12
  } from "@earendil-works/pi-coding-agent";
13
13
  import {
14
14
  AssistantMessageComponent,
15
+ CustomEditor,
15
16
  CustomMessageComponent,
16
17
  ToolExecutionComponent,
17
18
  UserMessageComponent,
@@ -68,8 +69,8 @@ import {
68
69
  buildBashPreview,
69
70
  describeBashSource,
70
71
  formatBashDuration,
71
- getLastBashOutputLine,
72
72
  } from "./bash-command";
73
+ import { detectBashFileEdit, type BashFileEdit } from "./bash-file-edit";
73
74
  import { getUserSettingsPath } from "./settings-path";
74
75
  import {
75
76
  blendForContrast,
@@ -105,6 +106,7 @@ const ANSI_PRESENT_RE = /\x1b\[[0-9;]*m/;
105
106
  const PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-container-render");
106
107
  const TOOL_RENDER_CACHE = Symbol.for("pi-claude-style-tools:tool-render-cache");
107
108
  const COMPONENT_PARENT = Symbol.for("pi-claude-style-tools:component-parent");
109
+ const BASH_FILE_EDIT_PATCH_FLAG = Symbol.for("pi-claude-style-tools:bash-file-edit-grouping");
108
110
  const PARENT_TRACKING_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-parent-tracking");
109
111
  const TOOL_UPDATE_DISPLAY_ACTIVE = Symbol("pi-claude-style-tools:update-display-active");
110
112
  const TOOL_UPDATE_DISPLAY_RERUN = Symbol("pi-claude-style-tools:update-display-rerun");
@@ -704,15 +706,49 @@ function groupStatusLight(status: ToolStatus, options?: { agentBreathe?: boolean
704
706
  return paintStatusDot(color);
705
707
  }
706
708
 
707
- function formatToolNameList(tools: any[]): string {
709
+ // Plain-sentence summaries for grouped tool rows ("Read 2 files, ran 1 shell command").
710
+ // Verbs: [done, running, one, many].
711
+ const GROUP_VERBS: Record<string, [string, string, string, string]> = {
712
+ read: ["read", "reading", "file", "files"],
713
+ bash: ["ran", "running", "shell command", "shell commands"],
714
+ grep: ["searched", "searching", "pattern", "patterns"],
715
+ find: ["searched", "searching", "pattern", "patterns"],
716
+ ls: ["listed", "listing", "directory", "directories"],
717
+ Agent: ["launched", "launching", "agent", "agents"],
718
+ };
719
+ // grep and find both count as searches.
720
+ const GROUP_VERB_KEY: Record<string, string> = { find: "grep" };
721
+
722
+ /** "Read 2 files, ran 1 shell command, called Ctx Reduce" (or the running form). */
723
+ function groupSentence(tools: any[], running: boolean): string {
708
724
  const counts = new Map<string, number>();
725
+ const called = new Map<string, number>();
709
726
  for (const tool of tools) {
710
727
  const name = getToolName(tool);
711
- counts.set(name, (counts.get(name) ?? 0) + 1);
728
+ if (GROUP_VERBS[name]) {
729
+ const key = GROUP_VERB_KEY[name] ?? name;
730
+ counts.set(key, (counts.get(key) ?? 0) + 1);
731
+ } else {
732
+ if (!counts.has("call")) counts.set("call", 0);
733
+ const label = toolDisplayName(name);
734
+ called.set(label, (called.get(label) ?? 0) + 1);
735
+ }
712
736
  }
713
- return [...counts.entries()]
714
- .map(([name, count]) => `${toolDisplayName(name)}${count > 1 ? `×${count}` : ""}`)
715
- .join(", ");
737
+ const parts = [...counts].map(([key, count]) => {
738
+ if (key === "call") {
739
+ const names = [...called].map(([label, n]) => (n > 1 ? `${label} ×${n}` : label)).join(", ");
740
+ return `${running ? "calling" : "called"} ${names}`;
741
+ }
742
+ const [done, active, one, many] = GROUP_VERBS[key];
743
+ return `${running ? active : done} ${count} ${count === 1 ? one : many}`;
744
+ });
745
+ const text = parts.join(", ");
746
+ return text.charAt(0).toUpperCase() + text.slice(1);
747
+ }
748
+
749
+ function formatElapsedSeconds(ms: number): string {
750
+ const secs = Math.max(0, Math.round(ms / 1000));
751
+ return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
716
752
  }
717
753
 
718
754
  function getRepeatedToolSubject(tools: any[], groupedName: string | undefined): string {
@@ -759,6 +795,17 @@ function stripLeadingToolStatus(line: string): string {
759
795
  );
760
796
  }
761
797
 
798
+ /** Remove up to `count` leading spaces, keeping any escape codes in front of them. */
799
+ function trimAnsiLeftBy(text: string, count: number): string {
800
+ let current = text;
801
+ for (let i = 0; i < count; i++) {
802
+ const next = current.replace(/^((?:\x1b\[[0-9;]*m)*) /, "$1");
803
+ if (next === current) break;
804
+ current = next;
805
+ }
806
+ return current;
807
+ }
808
+
762
809
  function trimAnsiLeft(text: string): string {
763
810
  let current = text;
764
811
  while (true) {
@@ -792,7 +839,17 @@ function getToolArgSummary(tool: any): string {
792
839
  if (name === "find") return `"${summarizeText(args.pattern ?? "", 40)}"${args.path ? ` in ${args.path}` : ""}`;
793
840
  if (name === "ls") return shortPath(process.cwd(), args.path ?? ".");
794
841
  if (parseNamespacedMcpToolName(name)) return summarizeDirectMcpArgs(args);
795
- return summarizeText(getStringArg(args, "path", "file_path", "url", "query", "name", "subject", "tool", "description", "prompt") || name, 72);
842
+ return summarizeUnknownToolArgs(args);
843
+ }
844
+
845
+ /**
846
+ * Summary for a tool without its own case: its most telling string argument, or the
847
+ * first one it has. Empty when there is none; never the tool's name, which the row
848
+ * already shows as its label ("Ctx Reduce Ctx Reduce").
849
+ */
850
+ function summarizeUnknownToolArgs(args: any): string {
851
+ const known = getStringArg(args, "path", "file_path", "url", "query", "name", "subject", "tool", "description", "prompt");
852
+ return known ? summarizeText(known, 72) : summarizeDirectMcpArgs(args);
796
853
  }
797
854
 
798
855
  function getToolCallLine(tool: any): string {
@@ -832,21 +889,6 @@ interface CollapsedToolEntry {
832
889
  subject: string;
833
890
  }
834
891
 
835
- function collapseRepeatedToolEntries(tools: any[]): CollapsedToolEntry[] {
836
- const entries: CollapsedToolEntry[] = [];
837
- for (const tool of tools) {
838
- const name = getToolName(tool);
839
- const subject = getRepeatedToolSubject([tool], name);
840
- const previous = entries[entries.length - 1];
841
- if (subject && previous?.name === name && previous.subject === subject) {
842
- previous.tools.push(tool);
843
- } else {
844
- entries.push({ tools: [tool], name, subject });
845
- }
846
- }
847
- return entries;
848
- }
849
-
850
892
  function stripReadRangeFromToolLine(line: string): string {
851
893
  return line.replace(
852
894
  /\s+(?:\x1b\[[0-9;]*m)*\((?:offset|limit)=\d+(?:,\s*(?:offset|limit)=\d+)*\)(?=(?:\x1b\[[0-9;]*m)*$)/,
@@ -866,19 +908,15 @@ function getCollapsedToolEntryLine(entry: CollapsedToolEntry, width: number, gro
866
908
  return clampLineWidth(`${sharedLine}${suffix}`, width);
867
909
  }
868
910
 
869
- function getCollapsedToolEntryLines(entry: CollapsedToolEntry, width: number, groupedLabel?: string): string[] {
870
- const lines = [getCollapsedToolEntryLine(entry, width, groupedLabel)];
871
- if (entry.name !== "bash") return lines;
872
- const running = [...entry.tools].reverse().find((tool) => getToolStatusForGroup(tool) === "pending");
873
- const latestOutput = running ? getLastBashOutputLine(getTextContent(running.result)) : undefined;
874
- if (latestOutput) lines.push(`${FG_DIM}${latestOutput}${TRANSPARENT_RESET}`);
875
- return lines;
876
- }
877
-
878
911
  function getExpandedToolGroupLines(tool: any, width: number, groupedLabel?: string): string[] {
879
- const lines = stripToolChrome(tool.render(Math.max(1, width)))
880
- .map((line) => removeGroupedToolPrefix(line, groupedLabel))
881
- .map((line) => tintGroupedToolLine(line, groupedLabel));
912
+ const rendered = stripToolChrome(tool.render(Math.max(1, width)));
913
+ // The child row's own padding is its first line's leading spaces. The header loses its
914
+ // marker and padding; the lines below lose only the padding, so the child's └ and the
915
+ // output under it keep their indent.
916
+ const childPad = /^ */.exec(stripAnsi(rendered[0] ?? ""))?.[0].length ?? 0;
917
+ const lines = rendered.map((line, index) =>
918
+ index === 0 ? tintGroupedToolLine(removeGroupedToolPrefix(line, groupedLabel), groupedLabel) : trimAnsiLeftBy(line, childPad),
919
+ );
882
920
  return lines.length > 0 ? lines : [`${FG_DIM}${String(tool?.toolName ?? "tool")}${TRANSPARENT_RESET}`];
883
921
  }
884
922
 
@@ -886,13 +924,13 @@ function branchPrefix(index: number, total: number, theme?: Theme): string {
886
924
  // Bare tee/corner only — no horizontal ─ arm.
887
925
  const branch = index === total - 1 ? "└" : "├";
888
926
  const rule = currentToolBranchAnsi(theme);
889
- return ` ${rule}${branch}${TRANSPARENT_RESET} `;
927
+ return ` ${BRANCH_INDENT}${rule}${branch}${TRANSPARENT_RESET} `;
890
928
  }
891
929
 
892
930
  function branchContinuation(index: number, total: number, theme?: Theme): string {
893
931
  const rule = currentToolBranchAnsi(theme);
894
932
  // Match lead width of ` X ` (3 cols of structure + spaces handled outside).
895
- return index === total - 1 ? " " : ` ${rule}│${TRANSPARENT_RESET} `;
933
+ return index === total - 1 ? ` ${BRANCH_INDENT}` : ` ${BRANCH_INDENT}${rule}│${TRANSPARENT_RESET} `;
896
934
  }
897
935
 
898
936
  function formatBranchedToolLines(
@@ -916,8 +954,8 @@ function formatBranchedToolLines(
916
954
  // Always strip any leftover status marker from the child call line before
917
955
  // re-prefixing. Agent breathe used · which the old stripper missed, so the
918
956
  // title walked sideways as size changed inside groups.
919
- const body = lineIndex === 0 ? removeGroupedToolPrefix(line) : trimAnsiLeft(line);
920
- const prefix = lineIndex === 0 ? `${branchPrefix(index, total)}${light} ` : `${branchContinuation(index, total)} `;
957
+ const body = lineIndex === 0 ? removeGroupedToolPrefix(line) : line;
958
+ const prefix = lineIndex === 0 ? `${branchPrefix(index, total)}${light} ` : `${branchContinuation(index, total)}`;
921
959
  output.push(clampLineWidth(`${prefix}${body}`, width));
922
960
  }
923
961
  return output;
@@ -927,7 +965,65 @@ const NON_GROUPABLE_TOOL_NAMES = new Set(["edit", "write", "apply_patch"]);
927
965
  const ACTIVE_TOOL_GROUPS = new Set<any>();
928
966
 
929
967
  function isGroupableTool(value: unknown): value is InstanceType<typeof ToolExecutionComponent> {
930
- return value instanceof ToolExecutionComponent && !NON_GROUPABLE_TOOL_NAMES.has(getToolName(value));
968
+ return value instanceof ToolExecutionComponent && !NON_GROUPABLE_TOOL_NAMES.has(getToolName(value)) && !isBashFileEditTool(value);
969
+ }
970
+
971
+ /** A bash row that only changes files (sed -i, heredoc, ...): drawn like edit/write, never grouped. */
972
+ function isBashFileEditTool(tool: any): boolean {
973
+ if (getToolName(tool) !== "bash") return false;
974
+ const command = tool?.args?.command;
975
+ return typeof command === "string" && !!detectBashFileEdit(command);
976
+ }
977
+
978
+ /**
979
+ * Live rows join a group as soon as they appear, before their arguments have streamed in.
980
+ * When a bash row turns out to be a file edit, take it back out: the calls before it stay
981
+ * grouped, the edit gets its own row, and the calls after it form a new group.
982
+ */
983
+ function ejectToolFromGroup(tool: any): void {
984
+ const group = tool?.[COMPONENT_PARENT];
985
+ if (!isToolGroupComponent(group)) return;
986
+ const parent = (group as any)[COMPONENT_PARENT];
987
+ const children = parent?.children;
988
+ if (!Array.isArray(children)) return;
989
+ const groupIndex = children.indexOf(group);
990
+ if (groupIndex === -1) return;
991
+ const expanded = (group as any).expanded === true;
992
+ const tools = (group as any).releaseTools() as any[];
993
+ const at = tools.indexOf(tool);
994
+ if (at === -1) return;
995
+ const piece = (members: any[]): any[] => {
996
+ if (members.length === 0) return [];
997
+ if (members.length === 1) {
998
+ members[0][COMPONENT_PARENT] = parent;
999
+ return members;
1000
+ }
1001
+ const next = new ToolGroupComponent();
1002
+ next.expanded = expanded;
1003
+ for (const member of members) next.addTool(member);
1004
+ (next as any)[COMPONENT_PARENT] = parent;
1005
+ return [next];
1006
+ };
1007
+ tool[COMPONENT_PARENT] = parent;
1008
+ children.splice(groupIndex, 1, ...piece(tools.slice(0, at)), tool, ...piece(tools.slice(at + 1)));
1009
+ try {
1010
+ tool.ui?.requestRender?.();
1011
+ } catch { /* noop */ }
1012
+ }
1013
+
1014
+ function patchBashFileEditGrouping(): void {
1015
+ const proto = ToolExecutionComponent.prototype as any;
1016
+ if (proto[BASH_FILE_EDIT_PATCH_FLAG]) return;
1017
+ for (const method of ["setArgsComplete", "markExecutionStarted"]) {
1018
+ const original = proto[method];
1019
+ if (typeof original !== "function") continue;
1020
+ proto[method] = function patchedBashFileEditGrouping(this: any, ...args: any[]) {
1021
+ const out = original.apply(this, args);
1022
+ if (isBashFileEditTool(this)) ejectToolFromGroup(this);
1023
+ return out;
1024
+ };
1025
+ }
1026
+ proto[BASH_FILE_EDIT_PATCH_FLAG] = true;
931
1027
  }
932
1028
 
933
1029
  class ToolGroupComponent extends Container {
@@ -947,6 +1043,8 @@ class ToolGroupComponent extends Container {
947
1043
  private cachedToolCount?: number;
948
1044
  private cachedLines?: string[];
949
1045
  private paletteFingerprint?: string;
1046
+ /** When the group's current run started, for the "· 3s" on a running header. */
1047
+ private runStartedAt?: number;
950
1048
 
951
1049
  private clearRenderCache(): void {
952
1050
  this.dirty = true;
@@ -1072,7 +1170,6 @@ class ToolGroupComponent extends Container {
1072
1170
 
1073
1171
  const groupedName = getGroupedToolName(this.tools);
1074
1172
  const label = getToolGroupLabel(this.tools);
1075
- const names = groupedName ? "" : formatToolNameList(this.tools);
1076
1173
  const overall: ToolStatus = status.error > 0 ? "error" : status.pending > 0 ? "pending" : "success";
1077
1174
  const mouseChildren: MouseLayoutEntry[] = [];
1078
1175
  let mouseY = 0;
@@ -1085,12 +1182,9 @@ class ToolGroupComponent extends Container {
1085
1182
  const pendingTools = this.tools.filter((tool) => getToolStatusForGroup(tool) === "pending");
1086
1183
  const headerBreathe = pendingTools.length > 0 && pendingTools.every((tool) => isAgentFamilyToolName(getToolName(tool)));
1087
1184
  const light = groupStatusLight(overall, { agentBreathe: headerBreathe });
1088
- const summaryLabel = `${label}:`;
1089
- const countParts: string[] = [];
1090
- if (status.pending) countParts.push(statusText("pending", status.pending));
1091
- if (status.success) countParts.push(statusText("success", status.success));
1092
- if (status.error) countParts.push(statusText("error", status.error));
1093
- const countsText = countParts.join(`${TRANSPARENT_RESET} • `);
1185
+ const running = status.pending > 0;
1186
+ if (running) this.runStartedAt ??= Date.now();
1187
+ else this.runStartedAt = undefined;
1094
1188
  const total = this.tools.length;
1095
1189
  const lines: string[] = [];
1096
1190
  const subject = this.expanded ? "" : getRepeatedToolSubject(this.tools, groupedName);
@@ -1105,11 +1199,30 @@ class ToolGroupComponent extends Container {
1105
1199
  recordLines(row, undefined);
1106
1200
  lines.push(...row);
1107
1201
  } else {
1108
- const summary = ` ${light} ${summaryLabel} ${countsText}${names ? ` ${TRANSPARENT_RESET}• ${names}` : ""}${toolOutputDetailHint(undefined as any, this.expanded, true)}`;
1202
+ // One plain sentence instead of a list of every call: "Read 2 files, ran 1 shell
1203
+ // command". While running: "Reading 2 files, running 1 shell command… · 3s", with
1204
+ // the call in progress underneath. ctrl+o (or a click) shows every call.
1205
+ const sentence = groupSentence(this.tools, running);
1206
+ const elapsed = running && this.runStartedAt !== undefined
1207
+ ? `${FG_DIM} · ${formatElapsedSeconds(Date.now() - this.runStartedAt)}${TRANSPARENT_RESET}`
1208
+ : "";
1209
+ const failed = status.error > 0 ? ` ${TOOL_STATUS_ERROR}· ${status.error} failed${TRANSPARENT_RESET}` : "";
1210
+ const summary = ` ${light} ${sentence}${running ? "…" : ""}${elapsed}${failed}${toolOutputDetailHint(undefined as any, this.expanded, true)}`;
1109
1211
  const header = [" ".repeat(safeWidth), clampLineWidth(summary, safeWidth)];
1212
+ if (running && !this.expanded) {
1213
+ const active = pendingTools[pendingTools.length - 1];
1214
+ if (active) {
1215
+ // The call in progress, without the live "(N lines)" count.
1216
+ const activeLine = stripAnsi(getCompactToolLine(active, Math.max(1, safeWidth - 3), undefined, false))
1217
+ .trim()
1218
+ .replace(/\s+\(\d+ lines?\)$/, "");
1219
+ const dim = safeFgAnsi(getGlobalPiTheme(), "dim") ?? FG_DIM;
1220
+ header.push(clampLineWidth(` ${BRANCH_INDENT}${currentToolBranchAnsi()}└${TRANSPARENT_RESET} ${dim}${activeLine}${TRANSPARENT_RESET}`, safeWidth));
1221
+ }
1222
+ }
1110
1223
  recordLines(header, undefined);
1111
1224
  lines.push(...header);
1112
- const childWidth = Math.max(1, safeWidth - 6);
1225
+ const childWidth = Math.max(1, safeWidth - 6 - BRANCH_INDENT.length);
1113
1226
  if (this.expanded) {
1114
1227
  for (let index = 0; index < total; index++) {
1115
1228
  const tool = this.tools[index];
@@ -1125,22 +1238,6 @@ class ToolGroupComponent extends Container {
1125
1238
  recordLines(row, tool);
1126
1239
  lines.push(...row);
1127
1240
  }
1128
- } else {
1129
- const entries = collapseRepeatedToolEntries(this.tools);
1130
- for (let index = 0; index < entries.length; index++) {
1131
- const entry = entries[index];
1132
- const branched = formatBranchedToolLines(
1133
- getCollapsedToolEntryLines(entry, childWidth, groupedName ? label : undefined),
1134
- index,
1135
- entries.length,
1136
- safeWidth,
1137
- getToolGroupOverallStatus(entry.tools),
1138
- { agentBreathe: entry.tools.every((tool) => isAgentFamilyToolName(getToolName(tool))) },
1139
- );
1140
- const row = branched.map((line) => clampLineWidth(line, safeWidth));
1141
- recordLines(row, entry.tools.length === 1 ? entry.tools[0] : entry.tools);
1142
- lines.push(...row);
1143
- }
1144
1241
  }
1145
1242
  }
1146
1243
  rememberMouseLayout(this, safeWidth, mouseChildren);
@@ -1359,7 +1456,7 @@ function formatTodoOverlayLines(lines: string[], width: number): string[] {
1359
1456
  const withoutTodoHash = line.replace(/#(?=[A-Za-z0-9_-]+)/, "");
1360
1457
  const bare = withoutTodoHash.replace(/([├└])─/, "$1");
1361
1458
  const colored = bare.replace(/[├└]/, (branch) => `${currentToolBranchAnsi()}${branch}${TRANSPARENT_RESET}`);
1362
- return clampLineWidth(` ${colored}`, width);
1459
+ return clampLineWidth(` ${BRANCH_INDENT}${colored}`, width);
1363
1460
  });
1364
1461
  }
1365
1462
 
@@ -2512,6 +2609,158 @@ function trimRenderedBlankLines(lines: string[]): string[] {
2512
2609
  return start <= end ? lines.slice(start, end + 1) : [];
2513
2610
  }
2514
2611
 
2612
+ // ---------------------------------------------------------------------------
2613
+ // pi-subagents (@tintinweb/pi-subagents): Agent rows, completion notices and the live
2614
+ // "Agents" widget, drawn with the same dots, connectors and indentation as tool rows.
2615
+ // ---------------------------------------------------------------------------
2616
+
2617
+ function branchGlyph(glyph: "├" | "└" | "│"): string {
2618
+ return `${currentToolBranchAnsi()}${glyph}${TRANSPARENT_RESET}`;
2619
+ }
2620
+
2621
+ function formatAgentTokens(tokens: unknown): string {
2622
+ if (typeof tokens === "string") return tokens.trim();
2623
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens <= 0) return "";
2624
+ return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k tokens` : `${tokens} tokens`;
2625
+ }
2626
+
2627
+ /** "3 turns · 4 tool uses · 28.5k tokens · 5s" from pi-subagents' details. */
2628
+ function agentStatsText(details: any, durationMs?: number): string {
2629
+ const parts: string[] = [];
2630
+ if (typeof details?.modelName === "string" && details.modelName) parts.push(details.modelName);
2631
+ const turns = Number(details?.turnCount);
2632
+ if (Number.isFinite(turns) && turns > 1) parts.push(`${turns} turns`);
2633
+ const toolUses = Number(details?.toolUses);
2634
+ if (Number.isFinite(toolUses) && toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
2635
+ const tokens = formatAgentTokens(details?.tokens ?? details?.totalTokens);
2636
+ if (tokens) parts.push(tokens);
2637
+ const ms = Number(durationMs ?? details?.durationMs);
2638
+ if (Number.isFinite(ms) && ms > 0) parts.push(formatWorkedDuration(ms));
2639
+ return parts.join(" · ");
2640
+ }
2641
+
2642
+ /** Result rows for pi-subagents' Agent tool, or undefined to use the generic rendering. */
2643
+ function renderAgentToolResult(result: any, expanded: boolean, isPartial: boolean, theme: Theme, ctx: any): Text | undefined {
2644
+ const details = result?.details;
2645
+ const status = typeof details?.status === "string" ? details.status : undefined;
2646
+ if (!status || ctx.isError) return undefined;
2647
+ const stats = agentStatsText(details);
2648
+ const statsSuffix = stats ? theme.fg("muted", ` · ${stats}`) : "";
2649
+ if (isPartial || status === "running" || status === "queued") {
2650
+ const activity = typeof details.activity === "string" && details.activity ? details.activity : "thinking…";
2651
+ return makeText(ctx.lastComponent, withBranch(`${theme.fg("dim", activity)}${statsSuffix}`, theme));
2652
+ }
2653
+ clearBlinkTimer(ctx);
2654
+ if (status === "background") {
2655
+ setToolStatus(ctx, "success");
2656
+ const id = typeof details.agentId === "string" ? theme.fg("muted", ` (${details.agentId})`) : "";
2657
+ return makeText(ctx.lastComponent, withBranch(`${theme.fg("dim", "Running in background")}${id}`, theme));
2658
+ }
2659
+ if (status === "completed" || status === "steered") {
2660
+ setToolStatus(ctx, "success");
2661
+ const label = status === "steered" ? theme.fg("warning", "Wrapped up (turn limit)") : theme.fg("success", "Done");
2662
+ const text = getTextContent(result).trim();
2663
+ if (!expanded || !text) {
2664
+ return makeText(ctx.lastComponent, withBranch(`${label}${statsSuffix}${text ? toolOutputDetailHint(theme, expanded) : ""}`, theme));
2665
+ }
2666
+ const lines = text.split("\n");
2667
+ const preview = buildPreviewText(lines, true, theme, previewLimit(), lines.length, (line) => theme.fg("dim", line || " "));
2668
+ return makeText(ctx.lastComponent, withBranch(`${label}${statsSuffix}\n${preview}`, theme));
2669
+ }
2670
+ if (status === "stopped") {
2671
+ setToolStatus(ctx, "success");
2672
+ return makeText(ctx.lastComponent, withBranch(`${theme.fg("muted", "Stopped")}${statsSuffix}`, theme));
2673
+ }
2674
+ setToolStatus(ctx, "error");
2675
+ const reason = status === "aborted" ? "Aborted (max turns exceeded)" : `Error: ${details.error ?? "unknown"}`;
2676
+ return makeText(ctx.lastComponent, withBranch(`${theme.fg("error", reason)}${statsSuffix}`, theme));
2677
+ }
2678
+
2679
+ /** A finished background agent's notice, drawn like a finished tool row. */
2680
+ function renderSubagentNotificationLines(details: any, expanded: boolean, width: number, theme: Theme): string[] {
2681
+ const agents = [details, ...(Array.isArray(details?.others) ? details.others : [])].filter((agent) => agent && typeof agent === "object");
2682
+ const blocks: string[][] = [];
2683
+ for (const agent of agents) {
2684
+ const status = String(agent.status ?? "completed");
2685
+ const failed = status === "error" || status === "stopped" || status === "aborted";
2686
+ const dot = paintStatusDot(failed ? TOOL_STATUS_ERROR : TOOL_STATUS_SUCCESS);
2687
+ const statusLabel = status === "steered" ? "completed (turn limit)" : status;
2688
+ const header = ` ${dot} ${theme.fg("toolTitle", theme.bold("Agent"))} ${theme.fg("accent", String(agent.description ?? "agent"))} ${theme.fg(failed ? "error" : "muted", statusLabel)}`;
2689
+ const body: string[] = [];
2690
+ const stats = agentStatsText(agent);
2691
+ if (stats) body.push(theme.fg("dim", stats));
2692
+ const preview = String(agent.resultPreview ?? "").trim();
2693
+ if (preview) {
2694
+ const previewLines = preview.split("\n");
2695
+ const shown = expanded ? previewLines.slice(0, 30) : [previewLines[0] ?? ""];
2696
+ for (const line of shown) body.push(theme.fg("dim", line || " "));
2697
+ }
2698
+ if (expanded && typeof agent.outputFile === "string" && agent.outputFile) {
2699
+ body.push(theme.fg("muted", `transcript: ${agent.outputFile}`));
2700
+ }
2701
+ const lines = [header];
2702
+ body.forEach((line, index) => lines.push(` ${BRANCH_INDENT}${branchGlyph(index === body.length - 1 ? "└" : "├")} ${line}`));
2703
+ blocks.push(lines.map((line) => clampLineWidth(line, width)));
2704
+ }
2705
+ return blocks.flatMap((block, index) => (index === 0 ? block : ["", ...block]));
2706
+ }
2707
+
2708
+ const AGENT_WIDGET_PATCH_FLAG = Symbol.for("pi-claude-style-tools:agent-widget-restyle");
2709
+ const ANSI_PREFIX = "((?:\\x1b\\[[0-9;]*m)*)";
2710
+ const AGENT_WIDGET_HEADING_RE = new RegExp(`^${ANSI_PREFIX}([●○])${ANSI_PREFIX}\\s+${ANSI_PREFIX}Agents`);
2711
+ const AGENT_WIDGET_ITEM_RE = new RegExp(`^${ANSI_PREFIX}([├└])─${ANSI_PREFIX} (.*)$`);
2712
+ const AGENT_WIDGET_ACTIVITY_RE = new RegExp(`^${ANSI_PREFIX}(│| ) ${ANSI_PREFIX}(?:${ANSI_PREFIX})?\\s*⎿\\s+(.*)$`);
2713
+ const AGENT_WIDGET_SPINNER_RE = new RegExp(`^${ANSI_PREFIX}[\\u2800-\\u28ff]${ANSI_PREFIX}`);
2714
+
2715
+ /** pi-subagents' "Agents" widget lines, re-indented and re-marked to match tool rows. */
2716
+ function restyleAgentWidgetLines(lines: string[], width: number): string[] {
2717
+ if (!Array.isArray(lines)) return lines;
2718
+ return lines.map((line) => {
2719
+ if (typeof line !== "string") return line;
2720
+ const heading = AGENT_WIDGET_HEADING_RE.exec(line);
2721
+ if (heading) {
2722
+ const active = heading[2] === "●";
2723
+ const dot = active ? paintAgentBreatheDot(TOOL_STATUS_SUCCESS) : paintStatusDot(TOOL_STATUS_SUCCESS);
2724
+ return clampLineWidth(` ${dot} Agents`, width);
2725
+ }
2726
+ const item = AGENT_WIDGET_ITEM_RE.exec(line);
2727
+ if (item) {
2728
+ let rest = item[4];
2729
+ // The running spinner becomes the breathing dot running agent rows use.
2730
+ rest = rest.replace(AGENT_WIDGET_SPINNER_RE, () => paintAgentBreatheDot(TOOL_STATUS_SUCCESS));
2731
+ rest = normalizeLeadingCheckGlyph(rest);
2732
+ return clampLineWidth(` ${BRANCH_INDENT}${branchGlyph(item[2] as "├" | "└")} ${rest}`, width);
2733
+ }
2734
+ const activity = AGENT_WIDGET_ACTIVITY_RE.exec(line);
2735
+ if (activity) {
2736
+ const bar = activity[2] === "│" ? branchGlyph("│") : " ";
2737
+ const dim = safeFgAnsi(getGlobalPiTheme(), "dim") ?? FG_DIM;
2738
+ return clampLineWidth(` ${BRANCH_INDENT}${bar} ${dim}${stripAnsi(activity[5])}${TRANSPARENT_RESET}`, width);
2739
+ }
2740
+ return line;
2741
+ });
2742
+ }
2743
+
2744
+ function patchAgentWidget(ui: any): void {
2745
+ if (!ui || ui[AGENT_WIDGET_PATCH_FLAG] || typeof ui.setWidget !== "function") return;
2746
+ const original = ui.setWidget;
2747
+ ui.setWidget = function patchedSetWidget(this: any, key: string, content: any, options?: any) {
2748
+ if (key === "agents" && typeof content === "function") {
2749
+ const factory = content;
2750
+ content = (tui: any, theme: any) => {
2751
+ const component = factory(tui, theme);
2752
+ if (component && typeof component.render === "function") {
2753
+ const render = component.render.bind(component);
2754
+ component.render = (width: number) => restyleAgentWidgetLines(render(width), width);
2755
+ }
2756
+ return component;
2757
+ };
2758
+ }
2759
+ return original.call(this, key, content, options);
2760
+ };
2761
+ ui[AGENT_WIDGET_PATCH_FLAG] = true;
2762
+ }
2763
+
2515
2764
  function isSubagentNotificationMessage(message: unknown): boolean {
2516
2765
  const candidate = message as Record<string, unknown> | undefined;
2517
2766
  return candidate?.customType === "subagent-notification";
@@ -2617,8 +2866,11 @@ function patchCustomMessageRender(): void {
2617
2866
  });
2618
2867
  const lines = originalRender.call(this, width);
2619
2868
  if (!Array.isArray(lines)) return lines;
2869
+ const details = (this?.message as any)?.details;
2620
2870
  const result = isSubagentNotificationMessage(this?.message)
2621
- ? formatSubagentNotification(lines, width)
2871
+ ? details && typeof details === "object" && typeof details.description === "string"
2872
+ ? frameToolLikeLines(renderSubagentNotificationLines(details, this._expanded === true, Math.max(1, width), getGlobalPiTheme() as Theme), width)
2873
+ : formatSubagentNotification(lines, width)
2622
2874
  : lines.map(normalizeLeadingCheckGlyph);
2623
2875
  return storeMessageRenderCache(this, width, result);
2624
2876
  };
@@ -2660,19 +2912,6 @@ function stripBackgroundAnsi(text: string): string {
2660
2912
  });
2661
2913
  }
2662
2914
 
2663
- function roundedUserBorder(width: number, top: boolean): string {
2664
- if (width <= 1) return `${BORDER_COLOR}│${TRANSPARENT_RESET}`;
2665
- const left = top ? "╭" : "╰";
2666
- const right = top ? "╮" : "╯";
2667
- if (!top || width < 10) {
2668
- return `${BORDER_COLOR}${left}${"─".repeat(Math.max(0, width - 2))}${right}${TRANSPARENT_RESET}`;
2669
- }
2670
- const label = `${WORKED_LINE_FG} User ${TRANSPARENT_RESET}`;
2671
- const prefix = "─";
2672
- const suffixWidth = Math.max(0, width - 2 - visibleWidth(prefix) - visibleWidth(label));
2673
- return `${BORDER_COLOR}${left}${prefix}${TRANSPARENT_RESET}${label}${BORDER_COLOR}${"─".repeat(suffixWidth)}${right}${TRANSPARENT_RESET}`;
2674
- }
2675
-
2676
2915
  function trimAnsiRight(text: string): string {
2677
2916
  let trimmed = text;
2678
2917
  while (true) {
@@ -2686,11 +2925,31 @@ function cleanUserMessageLine(line: string): string {
2686
2925
  return `${TRANSPARENT_BG}${trimAnsiRight(stripBackgroundAnsi(stripOsc133Zones(line)))}${TRANSPARENT_BG}`;
2687
2926
  }
2688
2927
 
2689
- function borderedUserMessageLine(line: string, width: number): string {
2690
- const innerWidth = Math.max(1, width - 4);
2691
- const content = clampLineWidth(cleanUserMessageLine(line), innerWidth);
2692
- const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
2693
- return `${BORDER_COLOR}│${TRANSPARENT_RESET} ${content}${padding} ${BORDER_COLOR}│${TRANSPARENT_RESET}`;
2928
+ // Past prompts: one band in the theme's userMessageBg, with ❯ where tool rows put their ●
2929
+ // (column 1) and the text where tool labels start (column 3).
2930
+ const USER_PROMPT_MARK = "❯";
2931
+ const USER_MESSAGE_PAD = 3;
2932
+
2933
+ /** Background escape for the prompt band, or "" when the theme has no distinct userMessageBg. */
2934
+ function userMessageBandAnsi(): string {
2935
+ const theme = getGlobalPiTheme();
2936
+ const band = themeTokenRgb(theme, "userMessageBg");
2937
+ const terminal = terminalDefaultColors(theme)?.background;
2938
+ if (!band || (terminal && rgbToHex(band) === rgbToHex(terminal))) return "";
2939
+ return rgbBgAnsi(theme, band);
2940
+ }
2941
+
2942
+ function markUserPromptLine(line: string): string {
2943
+ const mark = `${chromeAnsi(getGlobalPiTheme(), "outline")}${USER_PROMPT_MARK}\x1b[39m`;
2944
+ return line.replace(/^((?:\x1b\[[0-9;]*m)*) {3}/, (_match, esc: string) => `${esc} ${mark} `);
2945
+ }
2946
+
2947
+ /** Paint a full-width band behind a line, surviving resets inside it. */
2948
+ function withUserBand(line: string, width: number, band: string): string {
2949
+ const content = clampLineWidth(line, width);
2950
+ if (!band) return content;
2951
+ const pad = " ".repeat(Math.max(0, width - visibleWidth(content)));
2952
+ return `${band}${content.replace(/\x1b\[0?m|\x1b\[49m/g, (reset) => reset + band)}${pad}${TRANSPARENT_BG}`;
2694
2953
  }
2695
2954
 
2696
2955
  function visitMarkdownDescendants(root: unknown, visit: (md: InstanceType<typeof Markdown>) => void): void {
@@ -2707,9 +2966,19 @@ function patchUserMessageRender(): void {
2707
2966
  if (proto[USER_MESSAGE_PATCH_FLAG]) return;
2708
2967
  const originalRender = proto.render;
2709
2968
  if (typeof originalRender !== "function") return;
2969
+ const originalRebuild = proto.rebuild;
2970
+ if (typeof originalRebuild === "function") {
2971
+ proto.rebuild = function patchedUserMessageRebuild(this: any, ...args: any[]) {
2972
+ // Room for the ❯ marker: text starts in column 3, like tool labels.
2973
+ this.outputPad = Math.max(this.outputPad ?? 1, USER_MESSAGE_PAD);
2974
+ clearMessageRenderCache(this);
2975
+ return originalRebuild.apply(this, args);
2976
+ };
2977
+ }
2710
2978
  proto.render = function patchedUserMessageRender(width: number) {
2711
2979
  const cached = messageRenderCacheHit(this, width);
2712
2980
  if (cached) return cached;
2981
+ if ((this.outputPad ?? 1) < USER_MESSAGE_PAD) this.rebuild?.();
2713
2982
  visitMarkdownDescendants(this, (child) => {
2714
2983
  const markdownAny = child as any;
2715
2984
  if (typeof markdownAny.text === "string") {
@@ -2720,26 +2989,58 @@ function patchUserMessageRender(): void {
2720
2989
  }
2721
2990
  }
2722
2991
  makeMarkdownLinksCopySafe(child);
2992
+ // The band is painted below, from the theme's concrete color; pi's own
2993
+ // background would be the transparent override from applyToolBackgroundMode.
2723
2994
  if (markdownAny.defaultTextStyle?.bgColor) {
2724
2995
  markdownAny.defaultTextStyle.bgColor = undefined;
2725
2996
  child.invalidate?.();
2726
2997
  }
2727
2998
  });
2728
- const borderWidth = Math.max(1, width);
2729
- const contentWidth = Math.max(1, borderWidth - 4);
2730
- const lines = originalRender.call(this, contentWidth);
2999
+ const safeWidth = Math.max(1, width);
3000
+ // Pi before 1.4 draws prompts in a Box with one column of padding and no outputPad:
3001
+ // render two columns narrower and shift right instead.
3002
+ const padded = typeof this.rebuild === "function" && "outputPad" in this;
3003
+ const lines = originalRender.call(this, padded ? safeWidth : Math.max(1, safeWidth - 2));
2731
3004
  if (!Array.isArray(lines) || lines.length === 0) return lines;
2732
- const rendered = [
2733
- roundedUserBorder(borderWidth, true),
2734
- ...lines.slice(1, -1).map((line: string) => borderedUserMessageLine(line, borderWidth)),
2735
- roundedUserBorder(borderWidth, false),
2736
- ];
2737
- const clamped = rendered.map((line) => clampLineWidth(line, borderWidth));
2738
- return storeMessageRenderCache(this, width, applyTerminalCopyZones(clamped));
3005
+ // Drop the top and bottom padding rows: one compact band.
3006
+ let body = (lines.length > 2 ? lines.slice(1, -1) : lines).map(cleanUserMessageLine);
3007
+ if (!padded) body = body.map((line) => line.replace(/^((?:\x1b\[[0-9;]*m)*)/, "$1 "));
3008
+ body[0] = markUserPromptLine(body[0]);
3009
+ const band = userMessageBandAnsi();
3010
+ const rendered = body.map((line) => withUserBand(line, safeWidth, band));
3011
+ return storeMessageRenderCache(this, width, applyTerminalCopyZones(rendered));
2739
3012
  };
2740
3013
  proto[USER_MESSAGE_PATCH_FLAG] = true;
2741
3014
  }
2742
3015
 
3016
+ // The input line gets the same ❯ as past prompts. Wraps whatever editor is active
3017
+ // (pi's default, or one set by another extension) instead of replacing it.
3018
+ const PROMPT_EDITOR_FLAG = Symbol.for("pi-claude-style-tools:prompt-marker-editor");
3019
+
3020
+ function installPromptMarkerEditor(ctx: any): void {
3021
+ const ui = ctx?.ui;
3022
+ if (typeof ui?.setEditorComponent !== "function" || typeof ui?.getEditorComponent !== "function") return;
3023
+ const previous = ui.getEditorComponent();
3024
+ if (previous?.[PROMPT_EDITOR_FLAG]) return;
3025
+ const factory: any = (tui: any, theme: any, keybindings: any) => {
3026
+ const editor: any = previous?.(tui, theme, keybindings) ?? new CustomEditor(tui, theme, keybindings);
3027
+ if (typeof editor?.render !== "function") return editor;
3028
+ const render = editor.render.bind(editor);
3029
+ editor.render = (width: number) => {
3030
+ // Every render: pi re-applies the editorPaddingX setting after creation.
3031
+ if ((editor.paddingX ?? 0) < USER_MESSAGE_PAD) editor.paddingX = USER_MESSAGE_PAD;
3032
+ const lines: string[] = render(width);
3033
+ if (Array.isArray(lines) && lines.length > 1) lines[1] = markUserPromptLine(lines[1]);
3034
+ return lines;
3035
+ };
3036
+ return editor;
3037
+ };
3038
+ factory[PROMPT_EDITOR_FLAG] = true;
3039
+ try {
3040
+ ui.setEditorComponent(factory);
3041
+ } catch { /* not interactive */ }
3042
+ }
3043
+
2743
3044
  function patchAssistantMessages(): void {
2744
3045
  const proto = AssistantMessageComponent.prototype as any;
2745
3046
  if (proto[ASSISTANT_PATCH_FLAG]) return;
@@ -3527,49 +3828,81 @@ function toolStatusDot(ctx: any, theme: Theme): string {
3527
3828
  // Branch connector — visual tree from header to output
3528
3829
  // ---------------------------------------------------------------------------
3529
3830
 
3831
+ // Connectors hang under the first letter of the tool's label, two columns in from its ●
3832
+ // (`● Edit stats.py` / ` └ +1 -1 at line 4`), like Claude Code and pi-polish.
3833
+ const BRANCH_INDENT = " ";
3834
+
3530
3835
  function branchIndent(text: string, continued = false, theme?: Theme): string {
3531
3836
  const rule = currentToolBranchAnsi(theme);
3532
3837
  // Align under bare `├ `/`└ ` (│ + one space, or two spaces when closed).
3533
3838
  const prefix = continued ? `${rule}│${TRANSPARENT_RESET} ` : " ";
3534
- return `${prefix}${WRAP_MARK}${text}`;
3839
+ return `${BRANCH_INDENT}${prefix}${WRAP_MARK}${text}`;
3535
3840
  }
3536
3841
 
3537
3842
  function branchLead(text: string, continued = false, theme?: Theme): string {
3538
3843
  const rule = currentToolBranchAnsi(theme);
3539
3844
  // Bare tee/corner only — no horizontal ─ arm.
3540
- return `${rule}${continued ? "├" : "└"}${TRANSPARENT_RESET} ${WRAP_MARK}${text}`;
3845
+ return `${BRANCH_INDENT}${rule}${continued ? "├" : "└"}${TRANSPARENT_RESET} ${WRAP_MARK}${text}`;
3541
3846
  }
3542
3847
 
3543
- function withBranch(content: string, theme: Theme, _isError = false, continued = false): string {
3848
+ // SGR parameters that set a foreground color: 30–37, 39, 90–97, 38;5;n, 38;2;r;g;b.
3849
+ function stripForegroundAnsi(text: string): string {
3850
+ return text.replace(/\x1b\[([0-9;]*)m/g, (match, paramsText: string) => {
3851
+ if (paramsText === "") return match;
3852
+ const params = paramsText.split(";");
3853
+ const kept: string[] = [];
3854
+ for (let i = 0; i < params.length; i++) {
3855
+ const code = Number(params[i] || "0");
3856
+ if (code === 38) {
3857
+ const mode = Number(params[i + 1] || "0");
3858
+ i += mode === 2 ? 4 : mode === 5 ? 2 : 1;
3859
+ continue;
3860
+ }
3861
+ if ((code >= 30 && code <= 37) || code === 39 || (code >= 90 && code <= 97)) continue;
3862
+ kept.push(params[i]);
3863
+ }
3864
+ return kept.length === 0 ? "" : `\x1b[${kept.join(";")}m`;
3865
+ });
3866
+ }
3867
+
3868
+ /**
3869
+ * The status line under a tool's label ("Done (3 lines)", "2 matches", "+1 -1 at line 4")
3870
+ * in the theme's dim color, as pi-polish draws it. Errors keep their red.
3871
+ */
3872
+ function toneStatusLine(text: string, theme?: Theme): string {
3873
+ const t = (theme ?? getGlobalPiTheme()) as any;
3874
+ const dim = safeFgAnsi(t, "dim");
3875
+ if (!dim || !text.trim()) return text;
3876
+ const error = safeFgAnsi(t, "error");
3877
+ if ((error && text.includes(error)) || text.includes(TOOL_STATUS_ERROR)) return text;
3878
+ const body = stripForegroundAnsi(text).replace(/\x1b\[0?m/g, (reset) => `${reset}${dim}`);
3879
+ return `${dim}${body}\x1b[39m`;
3880
+ }
3881
+
3882
+ /** `tone`: the first line is a status line (dim); pass false when it is content (a command, a path). */
3883
+ function withBranch(content: string, theme: Theme, _isError = false, continued = false, tone = true): string {
3544
3884
  if (!content || !content.trim()) return "";
3545
3885
  const lines = content.split("\n");
3546
- const first = lines[0] ?? "";
3886
+ const first = tone ? toneStatusLine(lines[0] ?? "", theme) : lines[0] ?? "";
3547
3887
  if (lines.length === 1) return branchLead(first, continued, theme);
3548
3888
  const rest = lines.slice(1).map((line) => branchIndent(line, continued, theme));
3549
3889
  return `${branchLead(first, continued, theme)}\n${rest.join("\n")}`;
3550
3890
  }
3551
3891
 
3552
- function withClippedBranch(content: string, theme: Theme, continued = false): string {
3553
- return withBranch(content, theme, false, continued).replaceAll(WRAP_MARK, CLIP_MARK);
3892
+ function withClippedBranch(content: string, theme: Theme, continued = false, tone = true): string {
3893
+ return withBranch(content, theme, false, continued, tone).replaceAll(WRAP_MARK, CLIP_MARK);
3554
3894
  }
3555
3895
 
3556
- function withFinalBranchBlock(content: string, theme: Theme, isError = false): string {
3896
+ function withFinalBranchBlock(content: string, theme: Theme, isError = false, tone = true): string {
3557
3897
  if (!content || !content.trim()) return "";
3558
3898
  const lines = content.split("\n");
3559
- const first = lines[0] ?? "";
3899
+ const first = tone ? toneStatusLine(lines[0] ?? "", theme) : lines[0] ?? "";
3560
3900
  if (lines.length === 1) return branchLead(first, false, theme);
3561
3901
  const middle = lines.slice(1, -1).map((line) => branchIndent(line, true, theme));
3562
3902
  const last = lines[lines.length - 1] ?? "";
3563
3903
  return [branchLead(first, true, theme), ...middle, branchLead(last, false, theme)].join("\n");
3564
3904
  }
3565
3905
 
3566
- function indentBranchBlock(block: string): string {
3567
- return block
3568
- .split("\n")
3569
- .map((line) => (line ? ` ${line}` : line))
3570
- .join("\n");
3571
- }
3572
-
3573
3906
  // ---------------------------------------------------------------------------
3574
3907
  // Blink timer for partial (running) states
3575
3908
  // ---------------------------------------------------------------------------
@@ -3963,9 +4296,11 @@ function bashOutputMode(): "opencode" | "summary" | "preview" {
3963
4296
  return getMode(readSettings().bashOutputMode, ["opencode", "summary", "preview"] as const, "opencode");
3964
4297
  }
3965
4298
 
4299
+ // Off by default: the header already names the command, and repeating a long script under
4300
+ // it while it runs made the transcript busy. Ctrl+O still shows the whole command.
3966
4301
  function bashCommandPreviewLimit(): number {
3967
4302
  const value = readSettings().bashCommandPreviewLines;
3968
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 8;
4303
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
3969
4304
  }
3970
4305
 
3971
4306
  function renderBashCommandBlock(
@@ -3983,7 +4318,8 @@ function renderBashCommandBlock(
3983
4318
  lines.push(`... ${presentation.sourceLines.length - sourceLimit} more command lines`);
3984
4319
  }
3985
4320
  const body = lines.map((line) => theme.fg("accent", line || " ")).join("\n");
3986
- return expanded ? withBranch(body, theme, false, true) : withClippedBranch(body, theme, true);
4321
+ // The command itself, not a status line: keep its color.
4322
+ return expanded ? withBranch(body, theme, false, true, false) : withClippedBranch(body, theme, true, false);
3987
4323
  }
3988
4324
 
3989
4325
  function liveToolPreviewEnabled(): boolean {
@@ -4467,11 +4803,11 @@ function liveBranchDisplay(state: Record<string, unknown> | undefined, theme: Th
4467
4803
  if (!state || typeof state !== "object") return undefined;
4468
4804
  const body = state._ptBody;
4469
4805
  if (typeof body === "string" && body.trim() && !body.includes("(rendering")) {
4470
- return indentBranchBlock(withBranch(body, theme, false, true));
4806
+ return withBranch(body, theme, false, true);
4471
4807
  }
4472
4808
  const display = state._ptDisplay;
4473
4809
  if (typeof display === "string" && display.trim()) {
4474
- return indentBranchBlock(withBranch(stripBranchMarkupBlock(display), theme, false, true));
4810
+ return withBranch(stripBranchMarkupBlock(display), theme, false, true);
4475
4811
  }
4476
4812
  return undefined;
4477
4813
  }
@@ -4480,13 +4816,13 @@ function refreshToolBranchDisplaysInState(state: Record<string, unknown> | undef
4480
4816
  if (!state || typeof state !== "object") return;
4481
4817
  const body = state._ptBody;
4482
4818
  if (typeof body === "string" && body.trim() && !body.includes("(rendering")) {
4483
- state._ptDisplay = indentBranchBlock(withBranch(body, theme, false, true));
4819
+ state._ptDisplay = withBranch(body, theme, false, true);
4484
4820
  return;
4485
4821
  }
4486
4822
  const display = state._ptDisplay;
4487
4823
  if (typeof display === "string" && display.trim()) {
4488
4824
  const stripped = stripBranchMarkupBlock(display);
4489
- state._ptDisplay = indentBranchBlock(withBranch(stripped, theme, false, true));
4825
+ state._ptDisplay = withBranch(stripped, theme, false, true);
4490
4826
  }
4491
4827
  }
4492
4828
 
@@ -4944,7 +5280,7 @@ function termW(): number {
4944
5280
  return Math.max(40, Math.min(raw - 4, MAX_TERM_WIDTH));
4945
5281
  }
4946
5282
 
4947
- function branchDiffWidth(componentWidth?: number, chromeWidth = 2): number {
5283
+ function branchDiffWidth(componentWidth?: number, chromeWidth = 2 + BRANCH_INDENT.length): number {
4948
5284
  const width = typeof componentWidth === "number" && Number.isFinite(componentWidth)
4949
5285
  ? Math.floor(componentWidth)
4950
5286
  : termW();
@@ -4954,7 +5290,7 @@ function branchDiffWidth(componentWidth?: number, chromeWidth = 2): number {
4954
5290
  // Diff preview cache keys also carry _toolBranchVisualEpoch, which applyThemePaletteIfNeeded
4955
5291
  // bumps when the theme palette changes: otherwise rows rendered before a /theme or light/dark
4956
5292
  // switch kept the old diff backgrounds and stat colors while new rows used the new ones.
4957
- function contextDiffWidth(ctx: any, chromeWidth = 2): number {
5293
+ function contextDiffWidth(ctx: any, chromeWidth = 2 + BRANCH_INDENT.length): number {
4958
5294
  return branchDiffWidth(ctx.state?._diffComponentWidth, chromeWidth);
4959
5295
  }
4960
5296
 
@@ -5114,26 +5450,12 @@ function stripes(width: number): string {
5114
5450
  return BG_BASE + FG_STRIPE + "╱".repeat(width) + D_RST;
5115
5451
  }
5116
5452
 
5117
- function renderDiffStatBar(added: number, removed: number, width = termW()): string {
5118
- const total = added + removed;
5119
- if (total === 0 || width < 20) return "";
5120
- const slots = Math.max(8, Math.min(20, Math.floor(width / 14)));
5121
- let addSlots = Math.max(0, Math.min(slots, Math.round((added / total) * slots)));
5122
- if (added > 0 && addSlots === 0) addSlots = 1;
5123
- if (removed > 0 && addSlots >= slots) addSlots = slots - 1;
5124
- const removeSlots = Math.max(0, slots - addSlots);
5125
- const addBar = addSlots > 0 ? `${FG_ADD}${"━".repeat(addSlots)}${D_RST}` : "";
5126
- const removeBar = removeSlots > 0 ? `${FG_DEL}${"━".repeat(removeSlots)}${D_RST}` : "";
5127
- return `${FG_DIM}[${D_RST}${addBar}${removeBar}${FG_DIM}]${D_RST}`;
5128
- }
5129
-
5130
5453
  function summarizeDiff(added: number, removed: number): string {
5131
5454
  const parts: string[] = [];
5132
5455
  if (added > 0) parts.push(`${FG_ADD}+${added}${D_RST}`);
5133
5456
  if (removed > 0) parts.push(`${FG_DEL}-${removed}${D_RST}`);
5134
5457
  if (!parts.length) return `${FG_DIM}no changes${D_RST}`;
5135
- const bar = renderDiffStatBar(added, removed);
5136
- return bar ? `${parts.join(" ")} ${bar}` : parts.join(" ");
5458
+ return parts.join(" ");
5137
5459
  }
5138
5460
 
5139
5461
  function diffSummaryWithMeta(added: number, removed: number, hunks: number, mode: string): string {
@@ -5223,6 +5545,7 @@ const EXT_LANG: Record<string, BundledLanguage> = {
5223
5545
  cs: "csharp",
5224
5546
  swift: "swift",
5225
5547
  kt: "kotlin",
5548
+ kts: "kotlin",
5226
5549
  html: "html",
5227
5550
  css: "css",
5228
5551
  scss: "scss",
@@ -5905,7 +6228,7 @@ function renderEditPreviewBody(
5905
6228
  summary: string,
5906
6229
  ): void {
5907
6230
  const dc = resolveDiffColors(theme);
5908
- const branchWidth = contextDiffWidth(ctx, 3);
6231
+ const branchWidth = contextDiffWidth(ctx);
5909
6232
  if (operations.length === 1) {
5910
6233
  const [diff] = diffs;
5911
6234
  const line = lines[0] ?? getFirstChangedNewLine(diff);
@@ -5913,13 +6236,13 @@ function renderEditPreviewBody(
5913
6236
  .then((rendered) => {
5914
6237
  if (ctx.state._pk !== key) return;
5915
6238
  ctx.state._ptBody = `${summarizeDiff(diff.added, diff.removed)}${formatLineMeta(line, theme)}\n${rendered}`;
5916
- ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
6239
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
5917
6240
  safeInvalidate(ctx);
5918
6241
  })
5919
6242
  .catch(() => {
5920
6243
  if (ctx.state._pk !== key) return;
5921
6244
  ctx.state._ptBody = `${summarizeDiff(diff.added, diff.removed)}${formatLineMeta(line, theme)}`;
5922
- ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
6245
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
5923
6246
  safeInvalidate(ctx);
5924
6247
  });
5925
6248
  return;
@@ -5941,13 +6264,13 @@ function renderEditPreviewBody(
5941
6264
  ? `\n${theme.fg("muted", `… ${remainder} more edit blocks${toolOutputDetailHint(theme, ctx.expanded, true)}`)}`
5942
6265
  : "";
5943
6266
  ctx.state._ptBody = `${operations.length} edits ${summary}\n\n${sections.join("\n\n")}${suffix}`;
5944
- ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
6267
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
5945
6268
  safeInvalidate(ctx);
5946
6269
  })
5947
6270
  .catch(() => {
5948
6271
  if (ctx.state._pk !== key) return;
5949
6272
  ctx.state._ptBody = `${operations.length} edits ${summary}`;
5950
- ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
6273
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
5951
6274
  safeInvalidate(ctx);
5952
6275
  });
5953
6276
  }
@@ -6975,8 +7298,11 @@ function summarizeOpenAiToolCall(name: string, args: any, theme: Theme, sp: (pat
6975
7298
  return theme.fg("muted", "enable read-only planning");
6976
7299
  case "ExitPlanMode":
6977
7300
  return theme.fg("muted", "present plan");
6978
- case "Agent":
6979
- return summarizeText(getStringArg(args, "description", "prompt") || "launch agent", 72);
7301
+ case "Agent": {
7302
+ const description = summarizeText(getStringArg(args, "description", "prompt") || "launch agent", 72);
7303
+ const type = getStringArg(args, "subagent_type");
7304
+ return type ? `${description} ${theme.fg("muted", `(${type})`)}` : description;
7305
+ }
6980
7306
  case "get_subagent_result":
6981
7307
  return getStringArg(args, "agent_id") || theme.fg("muted", "agent result");
6982
7308
  case "steer_subagent":
@@ -6997,10 +7323,7 @@ function summarizeOpenAiToolCall(name: string, args: any, theme: Theme, sp: (pat
6997
7323
  return taskIds.length === 1 ? taskIds[0] : `${taskIds[0]} ${theme.fg("muted", `(+${taskIds.length - 1} tasks)`)}`;
6998
7324
  }
6999
7325
  default:
7000
- return summarizeText(
7001
- getStringArg(args, "path", "file_path", "url", "query", "name", "subject", "tool", "description", "prompt") || humanizeToolName(name),
7002
- 72,
7003
- );
7326
+ return summarizeUnknownToolArgs(args);
7004
7327
  }
7005
7328
  }
7006
7329
 
@@ -7136,6 +7459,10 @@ function renderReadImageResult(result: any, expanded: boolean, theme: Theme, ctx
7136
7459
  }
7137
7460
 
7138
7461
  function renderOpenAiToolResult(name: string, result: any, expanded: boolean, isPartial: boolean, theme: Theme, ctx: any): Text {
7462
+ if (name === "Agent") {
7463
+ const agentRow = renderAgentToolResult(result, expanded, isPartial, theme, ctx);
7464
+ if (agentRow) return agentRow;
7465
+ }
7139
7466
  if (isPartial) {
7140
7467
  return makeText(ctx.lastComponent, runningPreviewBlock(result, theme.fg("dim", `${humanizeToolName(name)}...`), expanded, theme, ctx));
7141
7468
  }
@@ -7182,6 +7509,117 @@ function renderOpenAiToolResult(name: string, result: any, expanded: boolean, is
7182
7509
  return makeText(ctx.lastComponent, withBranch(`${statusText}\n${preview}`, theme));
7183
7510
  }
7184
7511
 
7512
+ // ===========================================================================
7513
+ // Bash commands that only change files (sed -i, heredocs, ...), drawn as edit/write rows
7514
+ // ===========================================================================
7515
+
7516
+ interface BashFileEditRecord {
7517
+ /** Path as written in the command. */
7518
+ path: string;
7519
+ created: boolean;
7520
+ diff: ParsedDiff;
7521
+ language?: BundledLanguage;
7522
+ }
7523
+
7524
+ const MAX_BASH_SNAPSHOT_BYTES = 1_000_000;
7525
+
7526
+ function resolveBashEditPath(cwd: string, edit: BashFileEdit, path: string): string {
7527
+ const home = process.env.HOME ?? "";
7528
+ const expand = (value: string) => (home && (value === "~" || value.startsWith("~/")) ? `${home}${value.slice(1)}` : value);
7529
+ return resolve(edit.cd ? resolve(cwd, expand(edit.cd)) : cwd, expand(path));
7530
+ }
7531
+
7532
+ /** File text; null when the file does not exist; undefined when it can't be compared (too big, binary, unreadable). */
7533
+ function readBashEditSnapshot(path: string): string | null | undefined {
7534
+ try {
7535
+ if (!existsSync(path)) return null;
7536
+ const stat = statSync(path);
7537
+ if (!stat.isFile() || stat.size > MAX_BASH_SNAPSHOT_BYTES) return undefined;
7538
+ const text = readFileSync(path, "utf8");
7539
+ return text.includes("\0") ? undefined : text;
7540
+ } catch {
7541
+ return undefined;
7542
+ }
7543
+ }
7544
+
7545
+ function snapshotBashFileEdit(cwd: string, edit: BashFileEdit): Map<string, string | null | undefined> {
7546
+ const snapshot = new Map<string, string | null | undefined>();
7547
+ for (const path of edit.paths) snapshot.set(path, readBashEditSnapshot(resolveBashEditPath(cwd, edit, path)));
7548
+ return snapshot;
7549
+ }
7550
+
7551
+ function collectBashFileEdits(cwd: string, edit: BashFileEdit, before: Map<string, string | null | undefined>): BashFileEditRecord[] {
7552
+ const records: BashFileEditRecord[] = [];
7553
+ for (const path of edit.paths) {
7554
+ const full = resolveBashEditPath(cwd, edit, path);
7555
+ const previous = before.get(path);
7556
+ const current = readBashEditSnapshot(full);
7557
+ if (previous === undefined || typeof current !== "string" || previous === current) continue;
7558
+ records.push({ path, created: previous === null, diff: parseDiff(previous ?? "", current), language: lang(full) });
7559
+ }
7560
+ return records;
7561
+ }
7562
+
7563
+ function bashFileEditHeader(edit: BashFileEdit, records: BashFileEditRecord[] | undefined, theme: Theme, cwd: string, sp: (path: string) => string, prefix: string, trailing: string): string {
7564
+ applyThemePaletteIfNeeded(theme);
7565
+ const created = edit.kind === "write" && !!records?.length && records.every((record) => record.created);
7566
+ const label = edit.kind === "edit" ? "Edit" : created ? "Create" : "Write";
7567
+ const first = sp(resolveBashEditPath(cwd, edit, edit.paths[0]));
7568
+ const more = edit.paths.length > 1 ? ` ${theme.fg("muted", `(+${edit.paths.length - 1} files)`)}` : "";
7569
+ return `${prefix}${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("muted", `(${edit.via})`)} ${WRAP_MARK}${theme.fg("accent", first)}${more}${trailing}`;
7570
+ }
7571
+
7572
+ /** The diff block for one changed file, rendered asynchronously like write's; "" until known. */
7573
+ function bashFileEditDiffBlock(record: BashFileEditRecord, index: number, theme: Theme, ctx: any): string {
7574
+ const diff = record.diff;
7575
+ const previewLines = ctx.expanded ? MAX_RENDER_LINES : diffCollapsedLimit();
7576
+ const hunks = diff.lines.filter((line) => line.type === "sep").length + (diff.lines.length ? 1 : 0);
7577
+ const width = contextDiffWidth(ctx);
7578
+ const mode = record.created ? "new file" : shouldUseSplit(diff, width, previewLines) ? "split" : "unified";
7579
+ const summary = diffSummaryWithMeta(diff.added, diff.removed, hunks, mode);
7580
+ const key = `bfe:${index}:${record.path}:${width}:${diff.added}:${diff.removed}:${diff.lines.length}:${ctx.expanded ? 1 : 0}:${_toolBranchVisualEpoch}`;
7581
+ const keys = (ctx.state._bfeKeys ??= []) as string[];
7582
+ const blocks = (ctx.state._bfeBlocks ??= []) as string[];
7583
+ if (keys[index] !== key) {
7584
+ keys[index] = key;
7585
+ blocks[index] = `${summary}\n${theme.fg("muted", "rendering diff…")}`;
7586
+ const dc = resolveDiffColors(theme);
7587
+ const render = mode === "split" ? renderSplit(diff, record.language, previewLines, dc, width) : renderUnified(diff, record.language, previewLines, dc, width);
7588
+ render
7589
+ .then((rendered) => {
7590
+ if (keys[index] !== key) return;
7591
+ blocks[index] = `${summary}\n${rendered}`;
7592
+ safeInvalidate(ctx);
7593
+ })
7594
+ .catch(() => {
7595
+ if (keys[index] !== key) return;
7596
+ blocks[index] = summary;
7597
+ safeInvalidate(ctx);
7598
+ });
7599
+ }
7600
+ return blocks[index];
7601
+ }
7602
+
7603
+ /** Result body for a bash file edit, or undefined to fall back to the ordinary bash rendering. */
7604
+ function renderBashFileEditResult(edit: BashFileEdit, records: BashFileEditRecord[] | undefined, theme: Theme, ctx: any, sp: (path: string) => string): Text | undefined {
7605
+ if (records === undefined) {
7606
+ // Rows from before diffs were recorded: a heredoc or echo still says what it wrote.
7607
+ if (edit.content === undefined || edit.kind !== "write") return undefined;
7608
+ const diff = getCachedParsedDiff(ctx, `bfe-content:${hashText(edit.content)}`, "", edit.content);
7609
+ records = [{ path: edit.paths[0], created: true, diff, language: lang(edit.paths[0]) }];
7610
+ }
7611
+ if (records.length === 0) return makeText(ctx.lastComponent, withBranch(theme.fg("muted", "✓ no changes"), theme));
7612
+ if (!diffWidthReady(ctx)) {
7613
+ return makeResponsiveDiffText(ctx, ctx.lastComponent, withFinalBranchBlock(theme.fg("muted", "rendering diff…"), theme));
7614
+ }
7615
+ const blocks = records.map((record, index) => {
7616
+ const block = bashFileEditDiffBlock(record, index, theme, ctx);
7617
+ return records!.length > 1 ? `${theme.fg("accent", sp(resolveBashEditPath(ctx.cwd ?? process.cwd(), edit, record.path)))}\n${block}` : block;
7618
+ });
7619
+ // With several files the first line is a path, not a status line.
7620
+ return makeResponsiveDiffText(ctx, ctx.lastComponent, withFinalBranchBlock(blocks.join("\n"), theme, false, records.length === 1));
7621
+ }
7622
+
7185
7623
  // ===========================================================================
7186
7624
  // Extension
7187
7625
  // ===========================================================================
@@ -7200,6 +7638,7 @@ export default function (pi: ExtensionAPI) {
7200
7638
  patchAssistantMessages();
7201
7639
  patchAssistantFullscreenMouse();
7202
7640
  patchToolExecutionRenderers();
7641
+ patchBashFileEditGrouping();
7203
7642
  applyDiffPalette();
7204
7643
  registerThinkingLabels(pi);
7205
7644
  syncExtraToolDetailMode();
@@ -7591,6 +8030,8 @@ export default function (pi: ExtensionAPI) {
7591
8030
  clearRtkRewriteState();
7592
8031
  if (!ctx.hasUI) return;
7593
8032
  patchUiNotifications(ctx.ui);
8033
+ patchAgentWidget(ctx.ui);
8034
+ installPromptMarkerEditor(ctx);
7594
8035
  // terminalTheme (on by default): switch to, and stay on, the theme built from the
7595
8036
  // terminal's palette. `pi --use-theme <name>` still wins for that run.
7596
8037
  if (terminalThemeEnabled() && !(themeChosenOnCommandLine() && activeThemeName(ctx.ui) !== SYSTEM_THEME_NAME)) {
@@ -7612,6 +8053,7 @@ export default function (pi: ExtensionAPI) {
7612
8053
  pi.on("turn_start", async (_event, ctx) => {
7613
8054
  if (!ctx.hasUI) return;
7614
8055
  patchUiNotifications(ctx.ui);
8056
+ patchAgentWidget(ctx.ui);
7615
8057
  applyToolBackgroundMode(ctx.ui.theme);
7616
8058
  applyThemePaletteIfNeeded(ctx.ui.theme);
7617
8059
  });
@@ -7680,6 +8122,11 @@ export default function (pi: ExtensionAPI) {
7680
8122
  const details = result.details as ReadToolDetails | undefined;
7681
8123
  const content = result.content.find((block: any) => block?.type === "text");
7682
8124
  if (content?.type !== "text") return makeText(ctx.lastComponent, withBranch(theme.fg("error", "No text content"), theme));
8125
+ // A failed read (missing file, permission) said "1 lines loaded"; show the error instead.
8126
+ if (ctx.isError) {
8127
+ const message = content.text.trim().split("\n");
8128
+ return makeText(ctx.lastComponent, withBranch(theme.fg("error", expanded ? message.join("\n") : message[0] ?? "Error"), theme));
8129
+ }
7683
8130
  const lines = content.text.split("\n");
7684
8131
  let text = theme.fg("muted", `${lines.length} lines loaded`);
7685
8132
  if (details?.truncation?.truncated) text += theme.fg("warning", " (truncated)");
@@ -7698,13 +8145,31 @@ export default function (pi: ExtensionAPI) {
7698
8145
  promptGuidelines: (bashTool as any).promptGuidelines,
7699
8146
  parameters: bashTool.parameters,
7700
8147
  async execute(toolCallId, params, signal, onUpdate) {
7701
- return bashTool.execute(toolCallId, params, signal, onUpdate);
8148
+ // A command that only changes files: look at them before and after, so the row
8149
+ // can show a diff like an edit/write row. The command itself runs unchanged.
8150
+ const fileEdit = detectBashFileEdit(typeof params.command === "string" ? params.command : "");
8151
+ const before = fileEdit ? snapshotBashFileEdit(cwd, fileEdit) : undefined;
8152
+ const result = await bashTool.execute(toolCallId, params, signal, onUpdate);
8153
+ if (fileEdit && before) {
8154
+ const details = result.details && typeof result.details === "object" ? result.details : {};
8155
+ (result as any).details = { ...details, fileEdits: collectBashFileEdits(cwd, fileEdit, before) };
8156
+ }
8157
+ return result;
7702
8158
  },
7703
8159
  renderCall(args, theme, ctx) {
7704
8160
  syncToolCallStatus(ctx);
7705
8161
  syncBashDuration(ctx);
7706
8162
  const rewrite = ensureRtkRewriteForContext(ctx, args);
7707
8163
  const command = typeof args.command === "string" ? args.command : "";
8164
+ const fileEdit = ctx.argsComplete === true || ctx.executionStarted === true ? detectBashFileEdit(command) : undefined;
8165
+ if (fileEdit) {
8166
+ const status = ctx?.state?._toolStatus;
8167
+ const showCommand = status === "error" || ctx.expanded === true;
8168
+ const commandBlock = showCommand ? renderBashCommandBlock(command, ctx.expanded === true, theme) : "";
8169
+ const rowCwd = ctx.cwd ?? cwd;
8170
+ const header = bashFileEditHeader(fileEdit, ctx.state?._bashFileEdits, theme, rowCwd, (path) => shortPath(rowCwd, path), toolStatusDot(ctx, theme), bashHeaderTrailing(ctx, theme));
8171
+ return makeText(ctx.lastComponent, commandBlock ? `${header}\n${commandBlock}` : header);
8172
+ }
7708
8173
  const presentation = buildBashCommandPresentation(command);
7709
8174
  const summary = stableCallSummary(ctx, "_bashHeadline", () => presentation.headline);
7710
8175
  const rtkBadge = rewrite ? theme.fg("muted", " (RTK)") : "";
@@ -7726,6 +8191,22 @@ export default function (pi: ExtensionAPI) {
7726
8191
  const details = result.details as BashToolDetails | undefined;
7727
8192
  const rewrite = ensureRtkRewriteForContext(ctx, ctx.args);
7728
8193
  const output = result.content[0]?.type === "text" ? result.content[0].text : "";
8194
+ const fileEdit = !isPartial && !ctx.isError ? detectBashFileEdit(typeof ctx.args?.command === "string" ? ctx.args.command : "") : undefined;
8195
+ if (fileEdit && !/exit code: [1-9]/.test(output)) {
8196
+ const records = Array.isArray((details as any)?.fileEdits) ? (details as any).fileEdits as BashFileEditRecord[] : undefined;
8197
+ if (ctx.state && ctx.state._bashFileEdits !== records) {
8198
+ ctx.state._bashFileEdits = records;
8199
+ // The header says Create or Write from these; draw it again (not from inside this render).
8200
+ unrefTimer(setTimeout(() => safeInvalidate(ctx), 0));
8201
+ }
8202
+ const rowCwd = ctx.cwd ?? cwd;
8203
+ const body = renderBashFileEditResult(fileEdit, records, theme, ctx, (path) => shortPath(rowCwd, path));
8204
+ if (body) {
8205
+ clearBlinkTimer(ctx);
8206
+ setToolStatus(ctx, "success");
8207
+ return body;
8208
+ }
8209
+ }
7729
8210
  if (isPartial) {
7730
8211
  const preview = collectNonEmptyLines(output, expanded ? undefined : liveToolPreviewLimit());
7731
8212
  const running = runningPreviewBlock(result, "", expanded, theme, ctx, {
@@ -8056,6 +8537,10 @@ export default function (pi: ExtensionAPI) {
8056
8537
  pi.registerTool({
8057
8538
  name: "edit",
8058
8539
  label: "edit",
8540
+ // Pi's built-in edit draws its own shell (no padding); ours uses the standard one, like
8541
+ // every other tool. Without this, older pi falls back to the built-in "self" shell
8542
+ // while pi 1.4+ uses "default", so the row shifted by a column between versions.
8543
+ renderShell: "default",
8059
8544
  description: editTool.description,
8060
8545
  promptSnippet: (editTool as any).promptSnippet,
8061
8546
  promptGuidelines: (editTool as any).promptGuidelines,
@@ -8106,21 +8591,21 @@ export default function (pi: ExtensionAPI) {
8106
8591
  const revealSummary = shouldRevealCallArgs(ctx) || (!!fp && hasOwnArg(args, "edits"));
8107
8592
  const summary = stableCallSummary(ctx, "_callSummary", () => shouldRevealCallArgs(ctx) && operations.length > 1 ? `${sp(fp)} ${theme.fg("muted", `(${operations.length} edits)`)}` : sp(fp), revealSummary);
8108
8593
  syncToolCallStatus(ctx);
8109
- const hdr = toolHeader("Edit", summary, theme, ` ${toolStatusDot(ctx, theme)}`, liveLineCountTrailing(ctx, theme));
8594
+ const hdr = toolHeader("Edit", summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme));
8110
8595
  // History replay never sets argsComplete; a finished edit is drawn from its result instead.
8111
8596
  const finished = ctx.state._editResult as Record<string, unknown> | undefined;
8112
8597
  if (!((ctx.argsComplete || finished) && operations.length > 0)) return makeText(ctx.lastComponent, hdr);
8113
8598
  if (!diffWidthReady(ctx)) {
8114
- const pending = liveBranchDisplay(ctx.state, theme) ?? indentBranchBlock(withBranch(theme.fg("muted", "(rendering…)"), theme, false, true));
8599
+ const pending = liveBranchDisplay(ctx.state, theme) ?? withBranch(theme.fg("muted", "(rendering…)"), theme, false, true);
8115
8600
  return makeResponsiveDiffText(ctx, ctx.lastComponent, `${hdr}\n${pending}`);
8116
8601
  }
8117
- const diffWidth = contextDiffWidth(ctx, 3);
8602
+ const diffWidth = contextDiffWidth(ctx);
8118
8603
  const key = `edit:${fp}:${hashText(operations.map((edit) => `${edit.oldText}\u0000${edit.newText}`).join("\u0001"))}:${diffWidth}:${ctx.expanded ? 1 : 0}:${_toolBranchVisualEpoch}`;
8119
8604
  const { diffs: fallbackDiffs, summary: editSummary } = getCachedEditOperationSummary(ctx, key, operations);
8120
8605
  if (ctx.state._pk !== key) {
8121
8606
  ctx.state._pk = key;
8122
8607
  ctx.state._ptBody = theme.fg("muted", "(rendering…)");
8123
- ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
8608
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
8124
8609
  const lg = lang(fp);
8125
8610
  // Located once per row: expanding or resizing must not look in the file again, it no longer holds oldText.
8126
8611
  ctx.state._editLocalized ??= !finished
@@ -8145,7 +8630,7 @@ export default function (pi: ExtensionAPI) {
8145
8630
  },
8146
8631
  renderResult(result, { expanded, isPartial }, theme, ctx) {
8147
8632
  if (isPartial) {
8148
- return makeText(ctx.lastComponent, indentBranchBlock(runningPreviewBlock(result, theme.fg("dim", "Editing..."), expanded, theme, ctx)));
8633
+ return makeText(ctx.lastComponent, runningPreviewBlock(result, theme.fg("dim", "Editing..."), expanded, theme, ctx));
8149
8634
  }
8150
8635
  clearBlinkTimer(ctx);
8151
8636
  setToolStatus(ctx, ctx.isError ? "error" : "success");
@@ -8155,20 +8640,20 @@ export default function (pi: ExtensionAPI) {
8155
8640
  ?.filter((c: any) => c.type === "text")
8156
8641
  .map((c: any) => c.text || "")
8157
8642
  .join("\n") ?? "Error";
8158
- return makeText(ctx.lastComponent, indentBranchBlock(withBranch(theme.fg("error", e), theme)));
8643
+ return makeText(ctx.lastComponent, withBranch(theme.fg("error", e), theme));
8159
8644
  }
8160
8645
  if ((result as any).details?._type === "editInfo") {
8161
8646
  const { editLine, hunks, added, removed } = (result as any).details;
8162
8647
  const loc = formatLineMeta(editLine ?? 0, theme);
8163
8648
  const summary = diffSummaryWithMeta(added ?? 0, removed ?? 0, hunks ?? 0, "");
8164
- return makeText(ctx.lastComponent, indentBranchBlock(withBranch(`${summary}${loc}`, theme)));
8649
+ return makeText(ctx.lastComponent, withBranch(`${summary}${loc}`, theme));
8165
8650
  }
8166
8651
  if ((result as any).details?._type === "multiEditInfo") {
8167
8652
  const { editCount, diffLineCount, hunks, totalAdded, totalRemoved } = (result as any).details;
8168
8653
  const summary = diffSummaryWithMeta(totalAdded ?? 0, totalRemoved ?? 0, hunks ?? 0, "");
8169
- return makeText(ctx.lastComponent, indentBranchBlock(withBranch(`${editCount} edits ${summary}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`, theme)));
8654
+ return makeText(ctx.lastComponent, withBranch(`${editCount} edits ${summary}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`, theme));
8170
8655
  }
8171
- return makeText(ctx.lastComponent, indentBranchBlock(withBranch(theme.fg("success", "Applied"), theme)));
8656
+ return makeText(ctx.lastComponent, withBranch(theme.fg("success", "Applied"), theme));
8172
8657
  },
8173
8658
  });
8174
8659