pi-claude-code-ui 1.0.92 → 1.0.93

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 {
@@ -792,7 +828,17 @@ function getToolArgSummary(tool: any): string {
792
828
  if (name === "find") return `"${summarizeText(args.pattern ?? "", 40)}"${args.path ? ` in ${args.path}` : ""}`;
793
829
  if (name === "ls") return shortPath(process.cwd(), args.path ?? ".");
794
830
  if (parseNamespacedMcpToolName(name)) return summarizeDirectMcpArgs(args);
795
- return summarizeText(getStringArg(args, "path", "file_path", "url", "query", "name", "subject", "tool", "description", "prompt") || name, 72);
831
+ return summarizeUnknownToolArgs(args);
832
+ }
833
+
834
+ /**
835
+ * Summary for a tool without its own case: its most telling string argument, or the
836
+ * first one it has. Empty when there is none; never the tool's name, which the row
837
+ * already shows as its label ("Ctx Reduce Ctx Reduce").
838
+ */
839
+ function summarizeUnknownToolArgs(args: any): string {
840
+ const known = getStringArg(args, "path", "file_path", "url", "query", "name", "subject", "tool", "description", "prompt");
841
+ return known ? summarizeText(known, 72) : summarizeDirectMcpArgs(args);
796
842
  }
797
843
 
798
844
  function getToolCallLine(tool: any): string {
@@ -832,21 +878,6 @@ interface CollapsedToolEntry {
832
878
  subject: string;
833
879
  }
834
880
 
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
881
  function stripReadRangeFromToolLine(line: string): string {
851
882
  return line.replace(
852
883
  /\s+(?:\x1b\[[0-9;]*m)*\((?:offset|limit)=\d+(?:,\s*(?:offset|limit)=\d+)*\)(?=(?:\x1b\[[0-9;]*m)*$)/,
@@ -866,15 +897,6 @@ function getCollapsedToolEntryLine(entry: CollapsedToolEntry, width: number, gro
866
897
  return clampLineWidth(`${sharedLine}${suffix}`, width);
867
898
  }
868
899
 
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
900
  function getExpandedToolGroupLines(tool: any, width: number, groupedLabel?: string): string[] {
879
901
  const lines = stripToolChrome(tool.render(Math.max(1, width)))
880
902
  .map((line) => removeGroupedToolPrefix(line, groupedLabel))
@@ -927,7 +949,65 @@ const NON_GROUPABLE_TOOL_NAMES = new Set(["edit", "write", "apply_patch"]);
927
949
  const ACTIVE_TOOL_GROUPS = new Set<any>();
928
950
 
929
951
  function isGroupableTool(value: unknown): value is InstanceType<typeof ToolExecutionComponent> {
930
- return value instanceof ToolExecutionComponent && !NON_GROUPABLE_TOOL_NAMES.has(getToolName(value));
952
+ return value instanceof ToolExecutionComponent && !NON_GROUPABLE_TOOL_NAMES.has(getToolName(value)) && !isBashFileEditTool(value);
953
+ }
954
+
955
+ /** A bash row that only changes files (sed -i, heredoc, ...): drawn like edit/write, never grouped. */
956
+ function isBashFileEditTool(tool: any): boolean {
957
+ if (getToolName(tool) !== "bash") return false;
958
+ const command = tool?.args?.command;
959
+ return typeof command === "string" && !!detectBashFileEdit(command);
960
+ }
961
+
962
+ /**
963
+ * Live rows join a group as soon as they appear, before their arguments have streamed in.
964
+ * When a bash row turns out to be a file edit, take it back out: the calls before it stay
965
+ * grouped, the edit gets its own row, and the calls after it form a new group.
966
+ */
967
+ function ejectToolFromGroup(tool: any): void {
968
+ const group = tool?.[COMPONENT_PARENT];
969
+ if (!isToolGroupComponent(group)) return;
970
+ const parent = (group as any)[COMPONENT_PARENT];
971
+ const children = parent?.children;
972
+ if (!Array.isArray(children)) return;
973
+ const groupIndex = children.indexOf(group);
974
+ if (groupIndex === -1) return;
975
+ const expanded = (group as any).expanded === true;
976
+ const tools = (group as any).releaseTools() as any[];
977
+ const at = tools.indexOf(tool);
978
+ if (at === -1) return;
979
+ const piece = (members: any[]): any[] => {
980
+ if (members.length === 0) return [];
981
+ if (members.length === 1) {
982
+ members[0][COMPONENT_PARENT] = parent;
983
+ return members;
984
+ }
985
+ const next = new ToolGroupComponent();
986
+ next.expanded = expanded;
987
+ for (const member of members) next.addTool(member);
988
+ (next as any)[COMPONENT_PARENT] = parent;
989
+ return [next];
990
+ };
991
+ tool[COMPONENT_PARENT] = parent;
992
+ children.splice(groupIndex, 1, ...piece(tools.slice(0, at)), tool, ...piece(tools.slice(at + 1)));
993
+ try {
994
+ tool.ui?.requestRender?.();
995
+ } catch { /* noop */ }
996
+ }
997
+
998
+ function patchBashFileEditGrouping(): void {
999
+ const proto = ToolExecutionComponent.prototype as any;
1000
+ if (proto[BASH_FILE_EDIT_PATCH_FLAG]) return;
1001
+ for (const method of ["setArgsComplete", "markExecutionStarted"]) {
1002
+ const original = proto[method];
1003
+ if (typeof original !== "function") continue;
1004
+ proto[method] = function patchedBashFileEditGrouping(this: any, ...args: any[]) {
1005
+ const out = original.apply(this, args);
1006
+ if (isBashFileEditTool(this)) ejectToolFromGroup(this);
1007
+ return out;
1008
+ };
1009
+ }
1010
+ proto[BASH_FILE_EDIT_PATCH_FLAG] = true;
931
1011
  }
932
1012
 
933
1013
  class ToolGroupComponent extends Container {
@@ -947,6 +1027,8 @@ class ToolGroupComponent extends Container {
947
1027
  private cachedToolCount?: number;
948
1028
  private cachedLines?: string[];
949
1029
  private paletteFingerprint?: string;
1030
+ /** When the group's current run started, for the "· 3s" on a running header. */
1031
+ private runStartedAt?: number;
950
1032
 
951
1033
  private clearRenderCache(): void {
952
1034
  this.dirty = true;
@@ -1072,7 +1154,6 @@ class ToolGroupComponent extends Container {
1072
1154
 
1073
1155
  const groupedName = getGroupedToolName(this.tools);
1074
1156
  const label = getToolGroupLabel(this.tools);
1075
- const names = groupedName ? "" : formatToolNameList(this.tools);
1076
1157
  const overall: ToolStatus = status.error > 0 ? "error" : status.pending > 0 ? "pending" : "success";
1077
1158
  const mouseChildren: MouseLayoutEntry[] = [];
1078
1159
  let mouseY = 0;
@@ -1085,12 +1166,9 @@ class ToolGroupComponent extends Container {
1085
1166
  const pendingTools = this.tools.filter((tool) => getToolStatusForGroup(tool) === "pending");
1086
1167
  const headerBreathe = pendingTools.length > 0 && pendingTools.every((tool) => isAgentFamilyToolName(getToolName(tool)));
1087
1168
  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} • `);
1169
+ const running = status.pending > 0;
1170
+ if (running) this.runStartedAt ??= Date.now();
1171
+ else this.runStartedAt = undefined;
1094
1172
  const total = this.tools.length;
1095
1173
  const lines: string[] = [];
1096
1174
  const subject = this.expanded ? "" : getRepeatedToolSubject(this.tools, groupedName);
@@ -1105,8 +1183,26 @@ class ToolGroupComponent extends Container {
1105
1183
  recordLines(row, undefined);
1106
1184
  lines.push(...row);
1107
1185
  } else {
1108
- const summary = ` ${light} ${summaryLabel} ${countsText}${names ? ` ${TRANSPARENT_RESET}• ${names}` : ""}${toolOutputDetailHint(undefined as any, this.expanded, true)}`;
1186
+ // One plain sentence instead of a list of every call: "Read 2 files, ran 1 shell
1187
+ // command". While running: "Reading 2 files, running 1 shell command… · 3s", with
1188
+ // the call in progress underneath. ctrl+o (or a click) shows every call.
1189
+ const sentence = groupSentence(this.tools, running);
1190
+ const elapsed = running && this.runStartedAt !== undefined
1191
+ ? `${FG_DIM} · ${formatElapsedSeconds(Date.now() - this.runStartedAt)}${TRANSPARENT_RESET}`
1192
+ : "";
1193
+ const failed = status.error > 0 ? ` ${TOOL_STATUS_ERROR}· ${status.error} failed${TRANSPARENT_RESET}` : "";
1194
+ const summary = ` ${light} ${sentence}${running ? "…" : ""}${elapsed}${failed}${toolOutputDetailHint(undefined as any, this.expanded, true)}`;
1109
1195
  const header = [" ".repeat(safeWidth), clampLineWidth(summary, safeWidth)];
1196
+ if (running && !this.expanded) {
1197
+ const active = pendingTools[pendingTools.length - 1];
1198
+ if (active) {
1199
+ // The call in progress, without the live "(N lines)" count.
1200
+ const activeLine = stripAnsi(getCompactToolLine(active, Math.max(1, safeWidth - 3), undefined, false))
1201
+ .trim()
1202
+ .replace(/\s+\(\d+ lines?\)$/, "");
1203
+ header.push(clampLineWidth(` ${currentToolBranchAnsi()}└${TRANSPARENT_RESET} ${FG_DIM}${activeLine}${TRANSPARENT_RESET}`, safeWidth));
1204
+ }
1205
+ }
1110
1206
  recordLines(header, undefined);
1111
1207
  lines.push(...header);
1112
1208
  const childWidth = Math.max(1, safeWidth - 6);
@@ -1125,22 +1221,6 @@ class ToolGroupComponent extends Container {
1125
1221
  recordLines(row, tool);
1126
1222
  lines.push(...row);
1127
1223
  }
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
1224
  }
1145
1225
  }
1146
1226
  rememberMouseLayout(this, safeWidth, mouseChildren);
@@ -2512,6 +2592,157 @@ function trimRenderedBlankLines(lines: string[]): string[] {
2512
2592
  return start <= end ? lines.slice(start, end + 1) : [];
2513
2593
  }
2514
2594
 
2595
+ // ---------------------------------------------------------------------------
2596
+ // pi-subagents (@tintinweb/pi-subagents): Agent rows, completion notices and the live
2597
+ // "Agents" widget, drawn with the same dots, connectors and indentation as tool rows.
2598
+ // ---------------------------------------------------------------------------
2599
+
2600
+ function branchGlyph(glyph: "├" | "└" | "│"): string {
2601
+ return `${currentToolBranchAnsi()}${glyph}${TRANSPARENT_RESET}`;
2602
+ }
2603
+
2604
+ function formatAgentTokens(tokens: unknown): string {
2605
+ if (typeof tokens === "string") return tokens.trim();
2606
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens <= 0) return "";
2607
+ return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k tokens` : `${tokens} tokens`;
2608
+ }
2609
+
2610
+ /** "3 turns · 4 tool uses · 28.5k tokens · 5s" from pi-subagents' details. */
2611
+ function agentStatsText(details: any, durationMs?: number): string {
2612
+ const parts: string[] = [];
2613
+ if (typeof details?.modelName === "string" && details.modelName) parts.push(details.modelName);
2614
+ const turns = Number(details?.turnCount);
2615
+ if (Number.isFinite(turns) && turns > 1) parts.push(`${turns} turns`);
2616
+ const toolUses = Number(details?.toolUses);
2617
+ if (Number.isFinite(toolUses) && toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
2618
+ const tokens = formatAgentTokens(details?.tokens ?? details?.totalTokens);
2619
+ if (tokens) parts.push(tokens);
2620
+ const ms = Number(durationMs ?? details?.durationMs);
2621
+ if (Number.isFinite(ms) && ms > 0) parts.push(formatWorkedDuration(ms));
2622
+ return parts.join(" · ");
2623
+ }
2624
+
2625
+ /** Result rows for pi-subagents' Agent tool, or undefined to use the generic rendering. */
2626
+ function renderAgentToolResult(result: any, expanded: boolean, isPartial: boolean, theme: Theme, ctx: any): Text | undefined {
2627
+ const details = result?.details;
2628
+ const status = typeof details?.status === "string" ? details.status : undefined;
2629
+ if (!status || ctx.isError) return undefined;
2630
+ const stats = agentStatsText(details);
2631
+ const statsSuffix = stats ? theme.fg("muted", ` · ${stats}`) : "";
2632
+ if (isPartial || status === "running" || status === "queued") {
2633
+ const activity = typeof details.activity === "string" && details.activity ? details.activity : "thinking…";
2634
+ return makeText(ctx.lastComponent, withBranch(`${theme.fg("dim", activity)}${statsSuffix}`, theme));
2635
+ }
2636
+ clearBlinkTimer(ctx);
2637
+ if (status === "background") {
2638
+ setToolStatus(ctx, "success");
2639
+ const id = typeof details.agentId === "string" ? theme.fg("muted", ` (${details.agentId})`) : "";
2640
+ return makeText(ctx.lastComponent, withBranch(`${theme.fg("dim", "Running in background")}${id}`, theme));
2641
+ }
2642
+ if (status === "completed" || status === "steered") {
2643
+ setToolStatus(ctx, "success");
2644
+ const label = status === "steered" ? theme.fg("warning", "Wrapped up (turn limit)") : theme.fg("success", "Done");
2645
+ const text = getTextContent(result).trim();
2646
+ if (!expanded || !text) {
2647
+ return makeText(ctx.lastComponent, withBranch(`${label}${statsSuffix}${text ? toolOutputDetailHint(theme, expanded) : ""}`, theme));
2648
+ }
2649
+ const lines = text.split("\n");
2650
+ const preview = buildPreviewText(lines, true, theme, previewLimit(), lines.length, (line) => theme.fg("dim", line || " "));
2651
+ return makeText(ctx.lastComponent, withBranch(`${label}${statsSuffix}\n${preview}`, theme));
2652
+ }
2653
+ if (status === "stopped") {
2654
+ setToolStatus(ctx, "success");
2655
+ return makeText(ctx.lastComponent, withBranch(`${theme.fg("muted", "Stopped")}${statsSuffix}`, theme));
2656
+ }
2657
+ setToolStatus(ctx, "error");
2658
+ const reason = status === "aborted" ? "Aborted (max turns exceeded)" : `Error: ${details.error ?? "unknown"}`;
2659
+ return makeText(ctx.lastComponent, withBranch(`${theme.fg("error", reason)}${statsSuffix}`, theme));
2660
+ }
2661
+
2662
+ /** A finished background agent's notice, drawn like a finished tool row. */
2663
+ function renderSubagentNotificationLines(details: any, expanded: boolean, width: number, theme: Theme): string[] {
2664
+ const agents = [details, ...(Array.isArray(details?.others) ? details.others : [])].filter((agent) => agent && typeof agent === "object");
2665
+ const blocks: string[][] = [];
2666
+ for (const agent of agents) {
2667
+ const status = String(agent.status ?? "completed");
2668
+ const failed = status === "error" || status === "stopped" || status === "aborted";
2669
+ const dot = paintStatusDot(failed ? TOOL_STATUS_ERROR : TOOL_STATUS_SUCCESS);
2670
+ const statusLabel = status === "steered" ? "completed (turn limit)" : status;
2671
+ const header = ` ${dot} ${theme.fg("toolTitle", theme.bold("Agent"))} ${theme.fg("accent", String(agent.description ?? "agent"))} ${theme.fg(failed ? "error" : "muted", statusLabel)}`;
2672
+ const body: string[] = [];
2673
+ const stats = agentStatsText(agent);
2674
+ if (stats) body.push(theme.fg("muted", stats));
2675
+ const preview = String(agent.resultPreview ?? "").trim();
2676
+ if (preview) {
2677
+ const previewLines = preview.split("\n");
2678
+ const shown = expanded ? previewLines.slice(0, 30) : [previewLines[0] ?? ""];
2679
+ for (const line of shown) body.push(theme.fg("dim", line || " "));
2680
+ }
2681
+ if (expanded && typeof agent.outputFile === "string" && agent.outputFile) {
2682
+ body.push(theme.fg("muted", `transcript: ${agent.outputFile}`));
2683
+ }
2684
+ const lines = [header];
2685
+ body.forEach((line, index) => lines.push(` ${branchGlyph(index === body.length - 1 ? "└" : "├")} ${line}`));
2686
+ blocks.push(lines.map((line) => clampLineWidth(line, width)));
2687
+ }
2688
+ return blocks.flatMap((block, index) => (index === 0 ? block : ["", ...block]));
2689
+ }
2690
+
2691
+ const AGENT_WIDGET_PATCH_FLAG = Symbol.for("pi-claude-style-tools:agent-widget-restyle");
2692
+ const ANSI_PREFIX = "((?:\\x1b\\[[0-9;]*m)*)";
2693
+ const AGENT_WIDGET_HEADING_RE = new RegExp(`^${ANSI_PREFIX}([●○])${ANSI_PREFIX}\\s+${ANSI_PREFIX}Agents`);
2694
+ const AGENT_WIDGET_ITEM_RE = new RegExp(`^${ANSI_PREFIX}([├└])─${ANSI_PREFIX} (.*)$`);
2695
+ const AGENT_WIDGET_ACTIVITY_RE = new RegExp(`^${ANSI_PREFIX}(│| ) ${ANSI_PREFIX}(?:${ANSI_PREFIX})?\\s*⎿\\s+(.*)$`);
2696
+ const AGENT_WIDGET_SPINNER_RE = new RegExp(`^${ANSI_PREFIX}[\\u2800-\\u28ff]${ANSI_PREFIX}`);
2697
+
2698
+ /** pi-subagents' "Agents" widget lines, re-indented and re-marked to match tool rows. */
2699
+ function restyleAgentWidgetLines(lines: string[], width: number): string[] {
2700
+ if (!Array.isArray(lines)) return lines;
2701
+ return lines.map((line) => {
2702
+ if (typeof line !== "string") return line;
2703
+ const heading = AGENT_WIDGET_HEADING_RE.exec(line);
2704
+ if (heading) {
2705
+ const active = heading[2] === "●";
2706
+ const dot = active ? paintAgentBreatheDot(TOOL_STATUS_SUCCESS) : paintStatusDot(TOOL_STATUS_SUCCESS);
2707
+ return clampLineWidth(` ${dot} Agents`, width);
2708
+ }
2709
+ const item = AGENT_WIDGET_ITEM_RE.exec(line);
2710
+ if (item) {
2711
+ let rest = item[4];
2712
+ // The running spinner becomes the breathing dot running agent rows use.
2713
+ rest = rest.replace(AGENT_WIDGET_SPINNER_RE, () => paintAgentBreatheDot(TOOL_STATUS_SUCCESS));
2714
+ rest = normalizeLeadingCheckGlyph(rest);
2715
+ return clampLineWidth(` ${branchGlyph(item[2] as "├" | "└")} ${rest}`, width);
2716
+ }
2717
+ const activity = AGENT_WIDGET_ACTIVITY_RE.exec(line);
2718
+ if (activity) {
2719
+ const bar = activity[2] === "│" ? branchGlyph("│") : " ";
2720
+ return clampLineWidth(` ${bar} ${FG_DIM}${stripAnsi(activity[5])}${TRANSPARENT_RESET}`, width);
2721
+ }
2722
+ return line;
2723
+ });
2724
+ }
2725
+
2726
+ function patchAgentWidget(ui: any): void {
2727
+ if (!ui || ui[AGENT_WIDGET_PATCH_FLAG] || typeof ui.setWidget !== "function") return;
2728
+ const original = ui.setWidget;
2729
+ ui.setWidget = function patchedSetWidget(this: any, key: string, content: any, options?: any) {
2730
+ if (key === "agents" && typeof content === "function") {
2731
+ const factory = content;
2732
+ content = (tui: any, theme: any) => {
2733
+ const component = factory(tui, theme);
2734
+ if (component && typeof component.render === "function") {
2735
+ const render = component.render.bind(component);
2736
+ component.render = (width: number) => restyleAgentWidgetLines(render(width), width);
2737
+ }
2738
+ return component;
2739
+ };
2740
+ }
2741
+ return original.call(this, key, content, options);
2742
+ };
2743
+ ui[AGENT_WIDGET_PATCH_FLAG] = true;
2744
+ }
2745
+
2515
2746
  function isSubagentNotificationMessage(message: unknown): boolean {
2516
2747
  const candidate = message as Record<string, unknown> | undefined;
2517
2748
  return candidate?.customType === "subagent-notification";
@@ -2617,8 +2848,11 @@ function patchCustomMessageRender(): void {
2617
2848
  });
2618
2849
  const lines = originalRender.call(this, width);
2619
2850
  if (!Array.isArray(lines)) return lines;
2851
+ const details = (this?.message as any)?.details;
2620
2852
  const result = isSubagentNotificationMessage(this?.message)
2621
- ? formatSubagentNotification(lines, width)
2853
+ ? details && typeof details === "object" && typeof details.description === "string"
2854
+ ? frameToolLikeLines(renderSubagentNotificationLines(details, this._expanded === true, Math.max(1, width), getGlobalPiTheme() as Theme), width)
2855
+ : formatSubagentNotification(lines, width)
2622
2856
  : lines.map(normalizeLeadingCheckGlyph);
2623
2857
  return storeMessageRenderCache(this, width, result);
2624
2858
  };
@@ -2660,19 +2894,6 @@ function stripBackgroundAnsi(text: string): string {
2660
2894
  });
2661
2895
  }
2662
2896
 
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
2897
  function trimAnsiRight(text: string): string {
2677
2898
  let trimmed = text;
2678
2899
  while (true) {
@@ -2686,11 +2907,31 @@ function cleanUserMessageLine(line: string): string {
2686
2907
  return `${TRANSPARENT_BG}${trimAnsiRight(stripBackgroundAnsi(stripOsc133Zones(line)))}${TRANSPARENT_BG}`;
2687
2908
  }
2688
2909
 
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}`;
2910
+ // Past prompts: one band in the theme's userMessageBg, with ❯ where tool rows put their ●
2911
+ // (column 1) and the text where tool labels start (column 3).
2912
+ const USER_PROMPT_MARK = "❯";
2913
+ const USER_MESSAGE_PAD = 3;
2914
+
2915
+ /** Background escape for the prompt band, or "" when the theme has no distinct userMessageBg. */
2916
+ function userMessageBandAnsi(): string {
2917
+ const theme = getGlobalPiTheme();
2918
+ const band = themeTokenRgb(theme, "userMessageBg");
2919
+ const terminal = terminalDefaultColors(theme)?.background;
2920
+ if (!band || (terminal && rgbToHex(band) === rgbToHex(terminal))) return "";
2921
+ return rgbBgAnsi(theme, band);
2922
+ }
2923
+
2924
+ function markUserPromptLine(line: string): string {
2925
+ const mark = `${chromeAnsi(getGlobalPiTheme(), "outline")}${USER_PROMPT_MARK}\x1b[39m`;
2926
+ return line.replace(/^((?:\x1b\[[0-9;]*m)*) {3}/, (_match, esc: string) => `${esc} ${mark} `);
2927
+ }
2928
+
2929
+ /** Paint a full-width band behind a line, surviving resets inside it. */
2930
+ function withUserBand(line: string, width: number, band: string): string {
2931
+ const content = clampLineWidth(line, width);
2932
+ if (!band) return content;
2933
+ const pad = " ".repeat(Math.max(0, width - visibleWidth(content)));
2934
+ return `${band}${content.replace(/\x1b\[0?m|\x1b\[49m/g, (reset) => reset + band)}${pad}${TRANSPARENT_BG}`;
2694
2935
  }
2695
2936
 
2696
2937
  function visitMarkdownDescendants(root: unknown, visit: (md: InstanceType<typeof Markdown>) => void): void {
@@ -2707,9 +2948,19 @@ function patchUserMessageRender(): void {
2707
2948
  if (proto[USER_MESSAGE_PATCH_FLAG]) return;
2708
2949
  const originalRender = proto.render;
2709
2950
  if (typeof originalRender !== "function") return;
2951
+ const originalRebuild = proto.rebuild;
2952
+ if (typeof originalRebuild === "function") {
2953
+ proto.rebuild = function patchedUserMessageRebuild(this: any, ...args: any[]) {
2954
+ // Room for the ❯ marker: text starts in column 3, like tool labels.
2955
+ this.outputPad = Math.max(this.outputPad ?? 1, USER_MESSAGE_PAD);
2956
+ clearMessageRenderCache(this);
2957
+ return originalRebuild.apply(this, args);
2958
+ };
2959
+ }
2710
2960
  proto.render = function patchedUserMessageRender(width: number) {
2711
2961
  const cached = messageRenderCacheHit(this, width);
2712
2962
  if (cached) return cached;
2963
+ if ((this.outputPad ?? 1) < USER_MESSAGE_PAD) this.rebuild?.();
2713
2964
  visitMarkdownDescendants(this, (child) => {
2714
2965
  const markdownAny = child as any;
2715
2966
  if (typeof markdownAny.text === "string") {
@@ -2720,26 +2971,58 @@ function patchUserMessageRender(): void {
2720
2971
  }
2721
2972
  }
2722
2973
  makeMarkdownLinksCopySafe(child);
2974
+ // The band is painted below, from the theme's concrete color; pi's own
2975
+ // background would be the transparent override from applyToolBackgroundMode.
2723
2976
  if (markdownAny.defaultTextStyle?.bgColor) {
2724
2977
  markdownAny.defaultTextStyle.bgColor = undefined;
2725
2978
  child.invalidate?.();
2726
2979
  }
2727
2980
  });
2728
- const borderWidth = Math.max(1, width);
2729
- const contentWidth = Math.max(1, borderWidth - 4);
2730
- const lines = originalRender.call(this, contentWidth);
2981
+ const safeWidth = Math.max(1, width);
2982
+ // Pi before 1.4 draws prompts in a Box with one column of padding and no outputPad:
2983
+ // render two columns narrower and shift right instead.
2984
+ const padded = typeof this.rebuild === "function" && "outputPad" in this;
2985
+ const lines = originalRender.call(this, padded ? safeWidth : Math.max(1, safeWidth - 2));
2731
2986
  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));
2987
+ // Drop the top and bottom padding rows: one compact band.
2988
+ let body = (lines.length > 2 ? lines.slice(1, -1) : lines).map(cleanUserMessageLine);
2989
+ if (!padded) body = body.map((line) => line.replace(/^((?:\x1b\[[0-9;]*m)*)/, "$1 "));
2990
+ body[0] = markUserPromptLine(body[0]);
2991
+ const band = userMessageBandAnsi();
2992
+ const rendered = body.map((line) => withUserBand(line, safeWidth, band));
2993
+ return storeMessageRenderCache(this, width, applyTerminalCopyZones(rendered));
2739
2994
  };
2740
2995
  proto[USER_MESSAGE_PATCH_FLAG] = true;
2741
2996
  }
2742
2997
 
2998
+ // The input line gets the same ❯ as past prompts. Wraps whatever editor is active
2999
+ // (pi's default, or one set by another extension) instead of replacing it.
3000
+ const PROMPT_EDITOR_FLAG = Symbol.for("pi-claude-style-tools:prompt-marker-editor");
3001
+
3002
+ function installPromptMarkerEditor(ctx: any): void {
3003
+ const ui = ctx?.ui;
3004
+ if (typeof ui?.setEditorComponent !== "function" || typeof ui?.getEditorComponent !== "function") return;
3005
+ const previous = ui.getEditorComponent();
3006
+ if (previous?.[PROMPT_EDITOR_FLAG]) return;
3007
+ const factory: any = (tui: any, theme: any, keybindings: any) => {
3008
+ const editor: any = previous?.(tui, theme, keybindings) ?? new CustomEditor(tui, theme, keybindings);
3009
+ if (typeof editor?.render !== "function") return editor;
3010
+ const render = editor.render.bind(editor);
3011
+ editor.render = (width: number) => {
3012
+ // Every render: pi re-applies the editorPaddingX setting after creation.
3013
+ if ((editor.paddingX ?? 0) < USER_MESSAGE_PAD) editor.paddingX = USER_MESSAGE_PAD;
3014
+ const lines: string[] = render(width);
3015
+ if (Array.isArray(lines) && lines.length > 1) lines[1] = markUserPromptLine(lines[1]);
3016
+ return lines;
3017
+ };
3018
+ return editor;
3019
+ };
3020
+ factory[PROMPT_EDITOR_FLAG] = true;
3021
+ try {
3022
+ ui.setEditorComponent(factory);
3023
+ } catch { /* not interactive */ }
3024
+ }
3025
+
2743
3026
  function patchAssistantMessages(): void {
2744
3027
  const proto = AssistantMessageComponent.prototype as any;
2745
3028
  if (proto[ASSISTANT_PATCH_FLAG]) return;
@@ -3563,13 +3846,6 @@ function withFinalBranchBlock(content: string, theme: Theme, isError = false): s
3563
3846
  return [branchLead(first, true, theme), ...middle, branchLead(last, false, theme)].join("\n");
3564
3847
  }
3565
3848
 
3566
- function indentBranchBlock(block: string): string {
3567
- return block
3568
- .split("\n")
3569
- .map((line) => (line ? ` ${line}` : line))
3570
- .join("\n");
3571
- }
3572
-
3573
3849
  // ---------------------------------------------------------------------------
3574
3850
  // Blink timer for partial (running) states
3575
3851
  // ---------------------------------------------------------------------------
@@ -4467,11 +4743,11 @@ function liveBranchDisplay(state: Record<string, unknown> | undefined, theme: Th
4467
4743
  if (!state || typeof state !== "object") return undefined;
4468
4744
  const body = state._ptBody;
4469
4745
  if (typeof body === "string" && body.trim() && !body.includes("(rendering")) {
4470
- return indentBranchBlock(withBranch(body, theme, false, true));
4746
+ return withBranch(body, theme, false, true);
4471
4747
  }
4472
4748
  const display = state._ptDisplay;
4473
4749
  if (typeof display === "string" && display.trim()) {
4474
- return indentBranchBlock(withBranch(stripBranchMarkupBlock(display), theme, false, true));
4750
+ return withBranch(stripBranchMarkupBlock(display), theme, false, true);
4475
4751
  }
4476
4752
  return undefined;
4477
4753
  }
@@ -4480,13 +4756,13 @@ function refreshToolBranchDisplaysInState(state: Record<string, unknown> | undef
4480
4756
  if (!state || typeof state !== "object") return;
4481
4757
  const body = state._ptBody;
4482
4758
  if (typeof body === "string" && body.trim() && !body.includes("(rendering")) {
4483
- state._ptDisplay = indentBranchBlock(withBranch(body, theme, false, true));
4759
+ state._ptDisplay = withBranch(body, theme, false, true);
4484
4760
  return;
4485
4761
  }
4486
4762
  const display = state._ptDisplay;
4487
4763
  if (typeof display === "string" && display.trim()) {
4488
4764
  const stripped = stripBranchMarkupBlock(display);
4489
- state._ptDisplay = indentBranchBlock(withBranch(stripped, theme, false, true));
4765
+ state._ptDisplay = withBranch(stripped, theme, false, true);
4490
4766
  }
4491
4767
  }
4492
4768
 
@@ -5223,6 +5499,7 @@ const EXT_LANG: Record<string, BundledLanguage> = {
5223
5499
  cs: "csharp",
5224
5500
  swift: "swift",
5225
5501
  kt: "kotlin",
5502
+ kts: "kotlin",
5226
5503
  html: "html",
5227
5504
  css: "css",
5228
5505
  scss: "scss",
@@ -5905,7 +6182,7 @@ function renderEditPreviewBody(
5905
6182
  summary: string,
5906
6183
  ): void {
5907
6184
  const dc = resolveDiffColors(theme);
5908
- const branchWidth = contextDiffWidth(ctx, 3);
6185
+ const branchWidth = contextDiffWidth(ctx);
5909
6186
  if (operations.length === 1) {
5910
6187
  const [diff] = diffs;
5911
6188
  const line = lines[0] ?? getFirstChangedNewLine(diff);
@@ -5913,13 +6190,13 @@ function renderEditPreviewBody(
5913
6190
  .then((rendered) => {
5914
6191
  if (ctx.state._pk !== key) return;
5915
6192
  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));
6193
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
5917
6194
  safeInvalidate(ctx);
5918
6195
  })
5919
6196
  .catch(() => {
5920
6197
  if (ctx.state._pk !== key) return;
5921
6198
  ctx.state._ptBody = `${summarizeDiff(diff.added, diff.removed)}${formatLineMeta(line, theme)}`;
5922
- ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
6199
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
5923
6200
  safeInvalidate(ctx);
5924
6201
  });
5925
6202
  return;
@@ -5941,13 +6218,13 @@ function renderEditPreviewBody(
5941
6218
  ? `\n${theme.fg("muted", `… ${remainder} more edit blocks${toolOutputDetailHint(theme, ctx.expanded, true)}`)}`
5942
6219
  : "";
5943
6220
  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));
6221
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
5945
6222
  safeInvalidate(ctx);
5946
6223
  })
5947
6224
  .catch(() => {
5948
6225
  if (ctx.state._pk !== key) return;
5949
6226
  ctx.state._ptBody = `${operations.length} edits ${summary}`;
5950
- ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
6227
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
5951
6228
  safeInvalidate(ctx);
5952
6229
  });
5953
6230
  }
@@ -6975,8 +7252,11 @@ function summarizeOpenAiToolCall(name: string, args: any, theme: Theme, sp: (pat
6975
7252
  return theme.fg("muted", "enable read-only planning");
6976
7253
  case "ExitPlanMode":
6977
7254
  return theme.fg("muted", "present plan");
6978
- case "Agent":
6979
- return summarizeText(getStringArg(args, "description", "prompt") || "launch agent", 72);
7255
+ case "Agent": {
7256
+ const description = summarizeText(getStringArg(args, "description", "prompt") || "launch agent", 72);
7257
+ const type = getStringArg(args, "subagent_type");
7258
+ return type ? `${description} ${theme.fg("muted", `(${type})`)}` : description;
7259
+ }
6980
7260
  case "get_subagent_result":
6981
7261
  return getStringArg(args, "agent_id") || theme.fg("muted", "agent result");
6982
7262
  case "steer_subagent":
@@ -6997,10 +7277,7 @@ function summarizeOpenAiToolCall(name: string, args: any, theme: Theme, sp: (pat
6997
7277
  return taskIds.length === 1 ? taskIds[0] : `${taskIds[0]} ${theme.fg("muted", `(+${taskIds.length - 1} tasks)`)}`;
6998
7278
  }
6999
7279
  default:
7000
- return summarizeText(
7001
- getStringArg(args, "path", "file_path", "url", "query", "name", "subject", "tool", "description", "prompt") || humanizeToolName(name),
7002
- 72,
7003
- );
7280
+ return summarizeUnknownToolArgs(args);
7004
7281
  }
7005
7282
  }
7006
7283
 
@@ -7136,6 +7413,10 @@ function renderReadImageResult(result: any, expanded: boolean, theme: Theme, ctx
7136
7413
  }
7137
7414
 
7138
7415
  function renderOpenAiToolResult(name: string, result: any, expanded: boolean, isPartial: boolean, theme: Theme, ctx: any): Text {
7416
+ if (name === "Agent") {
7417
+ const agentRow = renderAgentToolResult(result, expanded, isPartial, theme, ctx);
7418
+ if (agentRow) return agentRow;
7419
+ }
7139
7420
  if (isPartial) {
7140
7421
  return makeText(ctx.lastComponent, runningPreviewBlock(result, theme.fg("dim", `${humanizeToolName(name)}...`), expanded, theme, ctx));
7141
7422
  }
@@ -7182,6 +7463,116 @@ function renderOpenAiToolResult(name: string, result: any, expanded: boolean, is
7182
7463
  return makeText(ctx.lastComponent, withBranch(`${statusText}\n${preview}`, theme));
7183
7464
  }
7184
7465
 
7466
+ // ===========================================================================
7467
+ // Bash commands that only change files (sed -i, heredocs, ...), drawn as edit/write rows
7468
+ // ===========================================================================
7469
+
7470
+ interface BashFileEditRecord {
7471
+ /** Path as written in the command. */
7472
+ path: string;
7473
+ created: boolean;
7474
+ diff: ParsedDiff;
7475
+ language?: BundledLanguage;
7476
+ }
7477
+
7478
+ const MAX_BASH_SNAPSHOT_BYTES = 1_000_000;
7479
+
7480
+ function resolveBashEditPath(cwd: string, edit: BashFileEdit, path: string): string {
7481
+ const home = process.env.HOME ?? "";
7482
+ const expand = (value: string) => (home && (value === "~" || value.startsWith("~/")) ? `${home}${value.slice(1)}` : value);
7483
+ return resolve(edit.cd ? resolve(cwd, expand(edit.cd)) : cwd, expand(path));
7484
+ }
7485
+
7486
+ /** File text; null when the file does not exist; undefined when it can't be compared (too big, binary, unreadable). */
7487
+ function readBashEditSnapshot(path: string): string | null | undefined {
7488
+ try {
7489
+ if (!existsSync(path)) return null;
7490
+ const stat = statSync(path);
7491
+ if (!stat.isFile() || stat.size > MAX_BASH_SNAPSHOT_BYTES) return undefined;
7492
+ const text = readFileSync(path, "utf8");
7493
+ return text.includes("\0") ? undefined : text;
7494
+ } catch {
7495
+ return undefined;
7496
+ }
7497
+ }
7498
+
7499
+ function snapshotBashFileEdit(cwd: string, edit: BashFileEdit): Map<string, string | null | undefined> {
7500
+ const snapshot = new Map<string, string | null | undefined>();
7501
+ for (const path of edit.paths) snapshot.set(path, readBashEditSnapshot(resolveBashEditPath(cwd, edit, path)));
7502
+ return snapshot;
7503
+ }
7504
+
7505
+ function collectBashFileEdits(cwd: string, edit: BashFileEdit, before: Map<string, string | null | undefined>): BashFileEditRecord[] {
7506
+ const records: BashFileEditRecord[] = [];
7507
+ for (const path of edit.paths) {
7508
+ const full = resolveBashEditPath(cwd, edit, path);
7509
+ const previous = before.get(path);
7510
+ const current = readBashEditSnapshot(full);
7511
+ if (previous === undefined || typeof current !== "string" || previous === current) continue;
7512
+ records.push({ path, created: previous === null, diff: parseDiff(previous ?? "", current), language: lang(full) });
7513
+ }
7514
+ return records;
7515
+ }
7516
+
7517
+ function bashFileEditHeader(edit: BashFileEdit, records: BashFileEditRecord[] | undefined, theme: Theme, cwd: string, sp: (path: string) => string, prefix: string, trailing: string): string {
7518
+ applyThemePaletteIfNeeded(theme);
7519
+ const created = edit.kind === "write" && !!records?.length && records.every((record) => record.created);
7520
+ const label = edit.kind === "edit" ? "Edit" : created ? "Create" : "Write";
7521
+ const first = sp(resolveBashEditPath(cwd, edit, edit.paths[0]));
7522
+ const more = edit.paths.length > 1 ? ` ${theme.fg("muted", `(+${edit.paths.length - 1} files)`)}` : "";
7523
+ return `${prefix}${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("muted", `(${edit.via})`)} ${WRAP_MARK}${theme.fg("accent", first)}${more}${trailing}`;
7524
+ }
7525
+
7526
+ /** The diff block for one changed file, rendered asynchronously like write's; "" until known. */
7527
+ function bashFileEditDiffBlock(record: BashFileEditRecord, index: number, theme: Theme, ctx: any): string {
7528
+ const diff = record.diff;
7529
+ const previewLines = ctx.expanded ? MAX_RENDER_LINES : diffCollapsedLimit();
7530
+ const hunks = diff.lines.filter((line) => line.type === "sep").length + (diff.lines.length ? 1 : 0);
7531
+ const width = contextDiffWidth(ctx);
7532
+ const mode = record.created ? "new file" : shouldUseSplit(diff, width, previewLines) ? "split" : "unified";
7533
+ const summary = diffSummaryWithMeta(diff.added, diff.removed, hunks, mode);
7534
+ const key = `bfe:${index}:${record.path}:${width}:${diff.added}:${diff.removed}:${diff.lines.length}:${ctx.expanded ? 1 : 0}:${_toolBranchVisualEpoch}`;
7535
+ const keys = (ctx.state._bfeKeys ??= []) as string[];
7536
+ const blocks = (ctx.state._bfeBlocks ??= []) as string[];
7537
+ if (keys[index] !== key) {
7538
+ keys[index] = key;
7539
+ blocks[index] = `${summary}\n${theme.fg("muted", "rendering diff…")}`;
7540
+ const dc = resolveDiffColors(theme);
7541
+ const render = mode === "split" ? renderSplit(diff, record.language, previewLines, dc, width) : renderUnified(diff, record.language, previewLines, dc, width);
7542
+ render
7543
+ .then((rendered) => {
7544
+ if (keys[index] !== key) return;
7545
+ blocks[index] = `${summary}\n${rendered}`;
7546
+ safeInvalidate(ctx);
7547
+ })
7548
+ .catch(() => {
7549
+ if (keys[index] !== key) return;
7550
+ blocks[index] = summary;
7551
+ safeInvalidate(ctx);
7552
+ });
7553
+ }
7554
+ return blocks[index];
7555
+ }
7556
+
7557
+ /** Result body for a bash file edit, or undefined to fall back to the ordinary bash rendering. */
7558
+ function renderBashFileEditResult(edit: BashFileEdit, records: BashFileEditRecord[] | undefined, theme: Theme, ctx: any, sp: (path: string) => string): Text | undefined {
7559
+ if (records === undefined) {
7560
+ // Rows from before diffs were recorded: a heredoc or echo still says what it wrote.
7561
+ if (edit.content === undefined || edit.kind !== "write") return undefined;
7562
+ const diff = getCachedParsedDiff(ctx, `bfe-content:${hashText(edit.content)}`, "", edit.content);
7563
+ records = [{ path: edit.paths[0], created: true, diff, language: lang(edit.paths[0]) }];
7564
+ }
7565
+ if (records.length === 0) return makeText(ctx.lastComponent, withBranch(theme.fg("muted", "✓ no changes"), theme));
7566
+ if (!diffWidthReady(ctx)) {
7567
+ return makeResponsiveDiffText(ctx, ctx.lastComponent, withFinalBranchBlock(theme.fg("muted", "rendering diff…"), theme));
7568
+ }
7569
+ const blocks = records.map((record, index) => {
7570
+ const block = bashFileEditDiffBlock(record, index, theme, ctx);
7571
+ return records!.length > 1 ? `${theme.fg("accent", sp(resolveBashEditPath(ctx.cwd ?? process.cwd(), edit, record.path)))}\n${block}` : block;
7572
+ });
7573
+ return makeResponsiveDiffText(ctx, ctx.lastComponent, withFinalBranchBlock(blocks.join("\n"), theme));
7574
+ }
7575
+
7185
7576
  // ===========================================================================
7186
7577
  // Extension
7187
7578
  // ===========================================================================
@@ -7200,6 +7591,7 @@ export default function (pi: ExtensionAPI) {
7200
7591
  patchAssistantMessages();
7201
7592
  patchAssistantFullscreenMouse();
7202
7593
  patchToolExecutionRenderers();
7594
+ patchBashFileEditGrouping();
7203
7595
  applyDiffPalette();
7204
7596
  registerThinkingLabels(pi);
7205
7597
  syncExtraToolDetailMode();
@@ -7591,6 +7983,8 @@ export default function (pi: ExtensionAPI) {
7591
7983
  clearRtkRewriteState();
7592
7984
  if (!ctx.hasUI) return;
7593
7985
  patchUiNotifications(ctx.ui);
7986
+ patchAgentWidget(ctx.ui);
7987
+ installPromptMarkerEditor(ctx);
7594
7988
  // terminalTheme (on by default): switch to, and stay on, the theme built from the
7595
7989
  // terminal's palette. `pi --use-theme <name>` still wins for that run.
7596
7990
  if (terminalThemeEnabled() && !(themeChosenOnCommandLine() && activeThemeName(ctx.ui) !== SYSTEM_THEME_NAME)) {
@@ -7612,6 +8006,7 @@ export default function (pi: ExtensionAPI) {
7612
8006
  pi.on("turn_start", async (_event, ctx) => {
7613
8007
  if (!ctx.hasUI) return;
7614
8008
  patchUiNotifications(ctx.ui);
8009
+ patchAgentWidget(ctx.ui);
7615
8010
  applyToolBackgroundMode(ctx.ui.theme);
7616
8011
  applyThemePaletteIfNeeded(ctx.ui.theme);
7617
8012
  });
@@ -7698,13 +8093,31 @@ export default function (pi: ExtensionAPI) {
7698
8093
  promptGuidelines: (bashTool as any).promptGuidelines,
7699
8094
  parameters: bashTool.parameters,
7700
8095
  async execute(toolCallId, params, signal, onUpdate) {
7701
- return bashTool.execute(toolCallId, params, signal, onUpdate);
8096
+ // A command that only changes files: look at them before and after, so the row
8097
+ // can show a diff like an edit/write row. The command itself runs unchanged.
8098
+ const fileEdit = detectBashFileEdit(typeof params.command === "string" ? params.command : "");
8099
+ const before = fileEdit ? snapshotBashFileEdit(cwd, fileEdit) : undefined;
8100
+ const result = await bashTool.execute(toolCallId, params, signal, onUpdate);
8101
+ if (fileEdit && before) {
8102
+ const details = result.details && typeof result.details === "object" ? result.details : {};
8103
+ (result as any).details = { ...details, fileEdits: collectBashFileEdits(cwd, fileEdit, before) };
8104
+ }
8105
+ return result;
7702
8106
  },
7703
8107
  renderCall(args, theme, ctx) {
7704
8108
  syncToolCallStatus(ctx);
7705
8109
  syncBashDuration(ctx);
7706
8110
  const rewrite = ensureRtkRewriteForContext(ctx, args);
7707
8111
  const command = typeof args.command === "string" ? args.command : "";
8112
+ const fileEdit = ctx.argsComplete === true || ctx.executionStarted === true ? detectBashFileEdit(command) : undefined;
8113
+ if (fileEdit) {
8114
+ const status = ctx?.state?._toolStatus;
8115
+ const showCommand = status === "error" || ctx.expanded === true;
8116
+ const commandBlock = showCommand ? renderBashCommandBlock(command, ctx.expanded === true, theme) : "";
8117
+ const rowCwd = ctx.cwd ?? cwd;
8118
+ const header = bashFileEditHeader(fileEdit, ctx.state?._bashFileEdits, theme, rowCwd, (path) => shortPath(rowCwd, path), toolStatusDot(ctx, theme), bashHeaderTrailing(ctx, theme));
8119
+ return makeText(ctx.lastComponent, commandBlock ? `${header}\n${commandBlock}` : header);
8120
+ }
7708
8121
  const presentation = buildBashCommandPresentation(command);
7709
8122
  const summary = stableCallSummary(ctx, "_bashHeadline", () => presentation.headline);
7710
8123
  const rtkBadge = rewrite ? theme.fg("muted", " (RTK)") : "";
@@ -7726,6 +8139,22 @@ export default function (pi: ExtensionAPI) {
7726
8139
  const details = result.details as BashToolDetails | undefined;
7727
8140
  const rewrite = ensureRtkRewriteForContext(ctx, ctx.args);
7728
8141
  const output = result.content[0]?.type === "text" ? result.content[0].text : "";
8142
+ const fileEdit = !isPartial && !ctx.isError ? detectBashFileEdit(typeof ctx.args?.command === "string" ? ctx.args.command : "") : undefined;
8143
+ if (fileEdit && !/exit code: [1-9]/.test(output)) {
8144
+ const records = Array.isArray((details as any)?.fileEdits) ? (details as any).fileEdits as BashFileEditRecord[] : undefined;
8145
+ if (ctx.state && ctx.state._bashFileEdits !== records) {
8146
+ ctx.state._bashFileEdits = records;
8147
+ // The header says Create or Write from these; draw it again (not from inside this render).
8148
+ unrefTimer(setTimeout(() => safeInvalidate(ctx), 0));
8149
+ }
8150
+ const rowCwd = ctx.cwd ?? cwd;
8151
+ const body = renderBashFileEditResult(fileEdit, records, theme, ctx, (path) => shortPath(rowCwd, path));
8152
+ if (body) {
8153
+ clearBlinkTimer(ctx);
8154
+ setToolStatus(ctx, "success");
8155
+ return body;
8156
+ }
8157
+ }
7729
8158
  if (isPartial) {
7730
8159
  const preview = collectNonEmptyLines(output, expanded ? undefined : liveToolPreviewLimit());
7731
8160
  const running = runningPreviewBlock(result, "", expanded, theme, ctx, {
@@ -8056,6 +8485,10 @@ export default function (pi: ExtensionAPI) {
8056
8485
  pi.registerTool({
8057
8486
  name: "edit",
8058
8487
  label: "edit",
8488
+ // Pi's built-in edit draws its own shell (no padding); ours uses the standard one, like
8489
+ // every other tool. Without this, older pi falls back to the built-in "self" shell
8490
+ // while pi 1.4+ uses "default", so the row shifted by a column between versions.
8491
+ renderShell: "default",
8059
8492
  description: editTool.description,
8060
8493
  promptSnippet: (editTool as any).promptSnippet,
8061
8494
  promptGuidelines: (editTool as any).promptGuidelines,
@@ -8106,21 +8539,21 @@ export default function (pi: ExtensionAPI) {
8106
8539
  const revealSummary = shouldRevealCallArgs(ctx) || (!!fp && hasOwnArg(args, "edits"));
8107
8540
  const summary = stableCallSummary(ctx, "_callSummary", () => shouldRevealCallArgs(ctx) && operations.length > 1 ? `${sp(fp)} ${theme.fg("muted", `(${operations.length} edits)`)}` : sp(fp), revealSummary);
8108
8541
  syncToolCallStatus(ctx);
8109
- const hdr = toolHeader("Edit", summary, theme, ` ${toolStatusDot(ctx, theme)}`, liveLineCountTrailing(ctx, theme));
8542
+ const hdr = toolHeader("Edit", summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme));
8110
8543
  // History replay never sets argsComplete; a finished edit is drawn from its result instead.
8111
8544
  const finished = ctx.state._editResult as Record<string, unknown> | undefined;
8112
8545
  if (!((ctx.argsComplete || finished) && operations.length > 0)) return makeText(ctx.lastComponent, hdr);
8113
8546
  if (!diffWidthReady(ctx)) {
8114
- const pending = liveBranchDisplay(ctx.state, theme) ?? indentBranchBlock(withBranch(theme.fg("muted", "(rendering…)"), theme, false, true));
8547
+ const pending = liveBranchDisplay(ctx.state, theme) ?? withBranch(theme.fg("muted", "(rendering…)"), theme, false, true);
8115
8548
  return makeResponsiveDiffText(ctx, ctx.lastComponent, `${hdr}\n${pending}`);
8116
8549
  }
8117
- const diffWidth = contextDiffWidth(ctx, 3);
8550
+ const diffWidth = contextDiffWidth(ctx);
8118
8551
  const key = `edit:${fp}:${hashText(operations.map((edit) => `${edit.oldText}\u0000${edit.newText}`).join("\u0001"))}:${diffWidth}:${ctx.expanded ? 1 : 0}:${_toolBranchVisualEpoch}`;
8119
8552
  const { diffs: fallbackDiffs, summary: editSummary } = getCachedEditOperationSummary(ctx, key, operations);
8120
8553
  if (ctx.state._pk !== key) {
8121
8554
  ctx.state._pk = key;
8122
8555
  ctx.state._ptBody = theme.fg("muted", "(rendering…)");
8123
- ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
8556
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
8124
8557
  const lg = lang(fp);
8125
8558
  // Located once per row: expanding or resizing must not look in the file again, it no longer holds oldText.
8126
8559
  ctx.state._editLocalized ??= !finished
@@ -8145,7 +8578,7 @@ export default function (pi: ExtensionAPI) {
8145
8578
  },
8146
8579
  renderResult(result, { expanded, isPartial }, theme, ctx) {
8147
8580
  if (isPartial) {
8148
- return makeText(ctx.lastComponent, indentBranchBlock(runningPreviewBlock(result, theme.fg("dim", "Editing..."), expanded, theme, ctx)));
8581
+ return makeText(ctx.lastComponent, runningPreviewBlock(result, theme.fg("dim", "Editing..."), expanded, theme, ctx));
8149
8582
  }
8150
8583
  clearBlinkTimer(ctx);
8151
8584
  setToolStatus(ctx, ctx.isError ? "error" : "success");
@@ -8155,20 +8588,20 @@ export default function (pi: ExtensionAPI) {
8155
8588
  ?.filter((c: any) => c.type === "text")
8156
8589
  .map((c: any) => c.text || "")
8157
8590
  .join("\n") ?? "Error";
8158
- return makeText(ctx.lastComponent, indentBranchBlock(withBranch(theme.fg("error", e), theme)));
8591
+ return makeText(ctx.lastComponent, withBranch(theme.fg("error", e), theme));
8159
8592
  }
8160
8593
  if ((result as any).details?._type === "editInfo") {
8161
8594
  const { editLine, hunks, added, removed } = (result as any).details;
8162
8595
  const loc = formatLineMeta(editLine ?? 0, theme);
8163
8596
  const summary = diffSummaryWithMeta(added ?? 0, removed ?? 0, hunks ?? 0, "");
8164
- return makeText(ctx.lastComponent, indentBranchBlock(withBranch(`${summary}${loc}`, theme)));
8597
+ return makeText(ctx.lastComponent, withBranch(`${summary}${loc}`, theme));
8165
8598
  }
8166
8599
  if ((result as any).details?._type === "multiEditInfo") {
8167
8600
  const { editCount, diffLineCount, hunks, totalAdded, totalRemoved } = (result as any).details;
8168
8601
  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)));
8602
+ return makeText(ctx.lastComponent, withBranch(`${editCount} edits ${summary}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`, theme));
8170
8603
  }
8171
- return makeText(ctx.lastComponent, indentBranchBlock(withBranch(theme.fg("success", "Applied"), theme)));
8604
+ return makeText(ctx.lastComponent, withBranch(theme.fg("success", "Applied"), theme));
8172
8605
  },
8173
8606
  });
8174
8607