pi-claude-code-ui 1.0.91 → 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,
@@ -95,18 +96,17 @@ const RESET = "\x1b[0m";
95
96
  const TRANSPARENT_BG = "\x1b[49m";
96
97
  const TRANSPARENT_RESET = `${RESET}${TRANSPARENT_BG}`;
97
98
 
98
- // User/code box borders and thinking/thought text: branch color + OUTLINE_CHROME_BRIGHTEN.
99
- // Branch ├└│ stay at `currentToolBranchAnsi` (see syncOutlineChromeFromBranch).
99
+ // User/code box borders and thinking/thought text use the outline chrome color, branch
100
+ // connectors ├└│ the fainter branch color (see chromeAnsi / syncOutlineChromeFromBranch).
100
101
  let BORDER_COLOR = "\x1b[38;5;238m";
101
102
  let CODE_BLOCK_LANG_FG = "\x1b[38;2;95;95;95m";
102
103
  const CHROME_ITALIC = "\x1b[3m";
103
- /** Lift outline chrome above branch connectors so boxes and thought read brighter. */
104
- const OUTLINE_CHROME_BRIGHTEN = 64;
105
104
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
106
105
  const ANSI_PRESENT_RE = /\x1b\[[0-9;]*m/;
107
106
  const PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-container-render");
108
107
  const TOOL_RENDER_CACHE = Symbol.for("pi-claude-style-tools:tool-render-cache");
109
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");
110
110
  const PARENT_TRACKING_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-parent-tracking");
111
111
  const TOOL_UPDATE_DISPLAY_ACTIVE = Symbol("pi-claude-style-tools:update-display-active");
112
112
  const TOOL_UPDATE_DISPLAY_RERUN = Symbol("pi-claude-style-tools:update-display-rerun");
@@ -157,10 +157,11 @@ interface SettingsFile {
157
157
  */
158
158
  themeAdaptive?: boolean;
159
159
  /**
160
- * When true, pi uses its built-in `system` theme, which takes every color
161
- * from the terminal's own palette (Ghostty, Rex, ...), and re-reads that
162
- * palette when the terminal regains focus so same-kind theme switches
163
- * (dark to dark) are picked up too. Toggle with `/cc-theme terminal`.
160
+ * On by default (pi >= 1.4): pi uses its built-in `system` theme, which takes
161
+ * every color from the terminal's own palette (Ghostty, Rex, ...), and
162
+ * re-reads that palette when the terminal regains focus so same-kind theme
163
+ * switches (dark to dark) are picked up too. Set to false, or run
164
+ * `/cc-theme terminal off`, to keep the theme chosen in pi's /settings.
164
165
  */
165
166
  terminalTheme?: boolean;
166
167
  /** Pi theme that was active before `terminalTheme` was turned on; restored when it is turned off. */
@@ -185,14 +186,6 @@ interface SettingsFile {
185
186
  * renders expanded, like stock pi.
186
187
  */
187
188
  thinkingMode?: "live" | "full";
188
- /** Exact gray level 0–255 for ├ └ │ in `fixed` mode. Unset: a fixed-contrast blend of the terminal background and text. */
189
- toolBranchRgbGray?: number;
190
- /**
191
- * `fixed` (default): the terminal background blended toward its text color
192
- * to a fixed 2:1 contrast (rgb 72 on a #161616 terminal), or exactly
193
- * `toolBranchRgbGray` when set. `theme`: the theme's dim → muted → borderMuted color.
194
- */
195
- toolBranchColorMode?: "theme" | "fixed";
196
189
  }
197
190
 
198
191
  let _settingsCache: { value: SettingsFile; timestamp: number } | null = null;
@@ -713,15 +706,49 @@ function groupStatusLight(status: ToolStatus, options?: { agentBreathe?: boolean
713
706
  return paintStatusDot(color);
714
707
  }
715
708
 
716
- 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 {
717
724
  const counts = new Map<string, number>();
725
+ const called = new Map<string, number>();
718
726
  for (const tool of tools) {
719
727
  const name = getToolName(tool);
720
- 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
+ }
721
736
  }
722
- return [...counts.entries()]
723
- .map(([name, count]) => `${toolDisplayName(name)}${count > 1 ? `×${count}` : ""}`)
724
- .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`;
725
752
  }
726
753
 
727
754
  function getRepeatedToolSubject(tools: any[], groupedName: string | undefined): string {
@@ -801,7 +828,17 @@ function getToolArgSummary(tool: any): string {
801
828
  if (name === "find") return `"${summarizeText(args.pattern ?? "", 40)}"${args.path ? ` in ${args.path}` : ""}`;
802
829
  if (name === "ls") return shortPath(process.cwd(), args.path ?? ".");
803
830
  if (parseNamespacedMcpToolName(name)) return summarizeDirectMcpArgs(args);
804
- 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);
805
842
  }
806
843
 
807
844
  function getToolCallLine(tool: any): string {
@@ -841,21 +878,6 @@ interface CollapsedToolEntry {
841
878
  subject: string;
842
879
  }
843
880
 
844
- function collapseRepeatedToolEntries(tools: any[]): CollapsedToolEntry[] {
845
- const entries: CollapsedToolEntry[] = [];
846
- for (const tool of tools) {
847
- const name = getToolName(tool);
848
- const subject = getRepeatedToolSubject([tool], name);
849
- const previous = entries[entries.length - 1];
850
- if (subject && previous?.name === name && previous.subject === subject) {
851
- previous.tools.push(tool);
852
- } else {
853
- entries.push({ tools: [tool], name, subject });
854
- }
855
- }
856
- return entries;
857
- }
858
-
859
881
  function stripReadRangeFromToolLine(line: string): string {
860
882
  return line.replace(
861
883
  /\s+(?:\x1b\[[0-9;]*m)*\((?:offset|limit)=\d+(?:,\s*(?:offset|limit)=\d+)*\)(?=(?:\x1b\[[0-9;]*m)*$)/,
@@ -875,15 +897,6 @@ function getCollapsedToolEntryLine(entry: CollapsedToolEntry, width: number, gro
875
897
  return clampLineWidth(`${sharedLine}${suffix}`, width);
876
898
  }
877
899
 
878
- function getCollapsedToolEntryLines(entry: CollapsedToolEntry, width: number, groupedLabel?: string): string[] {
879
- const lines = [getCollapsedToolEntryLine(entry, width, groupedLabel)];
880
- if (entry.name !== "bash") return lines;
881
- const running = [...entry.tools].reverse().find((tool) => getToolStatusForGroup(tool) === "pending");
882
- const latestOutput = running ? getLastBashOutputLine(getTextContent(running.result)) : undefined;
883
- if (latestOutput) lines.push(`${FG_DIM}${latestOutput}${TRANSPARENT_RESET}`);
884
- return lines;
885
- }
886
-
887
900
  function getExpandedToolGroupLines(tool: any, width: number, groupedLabel?: string): string[] {
888
901
  const lines = stripToolChrome(tool.render(Math.max(1, width)))
889
902
  .map((line) => removeGroupedToolPrefix(line, groupedLabel))
@@ -936,7 +949,65 @@ const NON_GROUPABLE_TOOL_NAMES = new Set(["edit", "write", "apply_patch"]);
936
949
  const ACTIVE_TOOL_GROUPS = new Set<any>();
937
950
 
938
951
  function isGroupableTool(value: unknown): value is InstanceType<typeof ToolExecutionComponent> {
939
- 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;
940
1011
  }
941
1012
 
942
1013
  class ToolGroupComponent extends Container {
@@ -956,6 +1027,8 @@ class ToolGroupComponent extends Container {
956
1027
  private cachedToolCount?: number;
957
1028
  private cachedLines?: string[];
958
1029
  private paletteFingerprint?: string;
1030
+ /** When the group's current run started, for the "· 3s" on a running header. */
1031
+ private runStartedAt?: number;
959
1032
 
960
1033
  private clearRenderCache(): void {
961
1034
  this.dirty = true;
@@ -1081,7 +1154,6 @@ class ToolGroupComponent extends Container {
1081
1154
 
1082
1155
  const groupedName = getGroupedToolName(this.tools);
1083
1156
  const label = getToolGroupLabel(this.tools);
1084
- const names = groupedName ? "" : formatToolNameList(this.tools);
1085
1157
  const overall: ToolStatus = status.error > 0 ? "error" : status.pending > 0 ? "pending" : "success";
1086
1158
  const mouseChildren: MouseLayoutEntry[] = [];
1087
1159
  let mouseY = 0;
@@ -1094,12 +1166,9 @@ class ToolGroupComponent extends Container {
1094
1166
  const pendingTools = this.tools.filter((tool) => getToolStatusForGroup(tool) === "pending");
1095
1167
  const headerBreathe = pendingTools.length > 0 && pendingTools.every((tool) => isAgentFamilyToolName(getToolName(tool)));
1096
1168
  const light = groupStatusLight(overall, { agentBreathe: headerBreathe });
1097
- const summaryLabel = `${label}:`;
1098
- const countParts: string[] = [];
1099
- if (status.pending) countParts.push(statusText("pending", status.pending));
1100
- if (status.success) countParts.push(statusText("success", status.success));
1101
- if (status.error) countParts.push(statusText("error", status.error));
1102
- const countsText = countParts.join(`${TRANSPARENT_RESET} • `);
1169
+ const running = status.pending > 0;
1170
+ if (running) this.runStartedAt ??= Date.now();
1171
+ else this.runStartedAt = undefined;
1103
1172
  const total = this.tools.length;
1104
1173
  const lines: string[] = [];
1105
1174
  const subject = this.expanded ? "" : getRepeatedToolSubject(this.tools, groupedName);
@@ -1114,8 +1183,26 @@ class ToolGroupComponent extends Container {
1114
1183
  recordLines(row, undefined);
1115
1184
  lines.push(...row);
1116
1185
  } else {
1117
- 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)}`;
1118
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
+ }
1119
1206
  recordLines(header, undefined);
1120
1207
  lines.push(...header);
1121
1208
  const childWidth = Math.max(1, safeWidth - 6);
@@ -1134,22 +1221,6 @@ class ToolGroupComponent extends Container {
1134
1221
  recordLines(row, tool);
1135
1222
  lines.push(...row);
1136
1223
  }
1137
- } else {
1138
- const entries = collapseRepeatedToolEntries(this.tools);
1139
- for (let index = 0; index < entries.length; index++) {
1140
- const entry = entries[index];
1141
- const branched = formatBranchedToolLines(
1142
- getCollapsedToolEntryLines(entry, childWidth, groupedName ? label : undefined),
1143
- index,
1144
- entries.length,
1145
- safeWidth,
1146
- getToolGroupOverallStatus(entry.tools),
1147
- { agentBreathe: entry.tools.every((tool) => isAgentFamilyToolName(getToolName(tool))) },
1148
- );
1149
- const row = branched.map((line) => clampLineWidth(line, safeWidth));
1150
- recordLines(row, entry.tools.length === 1 ? entry.tools[0] : entry.tools);
1151
- lines.push(...row);
1152
- }
1153
1224
  }
1154
1225
  }
1155
1226
  rememberMouseLayout(this, safeWidth, mouseChildren);
@@ -1615,7 +1686,7 @@ const TOOL_EXECUTION_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool
1615
1686
 
1616
1687
  // Rendered-output cache for assistant/user/custom message components.
1617
1688
  // Keyed by (width, branch visual epoch, tool background mode). The epoch changes on
1618
- // theme / /cc-tools branch / /cc-theme rebinds; the mode is included because subagent
1689
+ // theme and /cc-theme rebinds; the mode is included because subagent
1619
1690
  // (custom-message) framing follows `toolBackgroundMode` via frameToolLikeLines. This avoids
1620
1691
  // re-running the per-line ANSI stripping (applyTerminalCopyZones, normalizeLeadingCheckGlyph,
1621
1692
  // border boxing) on every scroll/expand re-render — the dominant CPU cost on long chats,
@@ -2521,6 +2592,157 @@ function trimRenderedBlankLines(lines: string[]): string[] {
2521
2592
  return start <= end ? lines.slice(start, end + 1) : [];
2522
2593
  }
2523
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
+
2524
2746
  function isSubagentNotificationMessage(message: unknown): boolean {
2525
2747
  const candidate = message as Record<string, unknown> | undefined;
2526
2748
  return candidate?.customType === "subagent-notification";
@@ -2626,8 +2848,11 @@ function patchCustomMessageRender(): void {
2626
2848
  });
2627
2849
  const lines = originalRender.call(this, width);
2628
2850
  if (!Array.isArray(lines)) return lines;
2851
+ const details = (this?.message as any)?.details;
2629
2852
  const result = isSubagentNotificationMessage(this?.message)
2630
- ? 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)
2631
2856
  : lines.map(normalizeLeadingCheckGlyph);
2632
2857
  return storeMessageRenderCache(this, width, result);
2633
2858
  };
@@ -2669,19 +2894,6 @@ function stripBackgroundAnsi(text: string): string {
2669
2894
  });
2670
2895
  }
2671
2896
 
2672
- function roundedUserBorder(width: number, top: boolean): string {
2673
- if (width <= 1) return `${BORDER_COLOR}│${TRANSPARENT_RESET}`;
2674
- const left = top ? "╭" : "╰";
2675
- const right = top ? "╮" : "╯";
2676
- if (!top || width < 10) {
2677
- return `${BORDER_COLOR}${left}${"─".repeat(Math.max(0, width - 2))}${right}${TRANSPARENT_RESET}`;
2678
- }
2679
- const label = `${WORKED_LINE_FG} User ${TRANSPARENT_RESET}`;
2680
- const prefix = "─";
2681
- const suffixWidth = Math.max(0, width - 2 - visibleWidth(prefix) - visibleWidth(label));
2682
- return `${BORDER_COLOR}${left}${prefix}${TRANSPARENT_RESET}${label}${BORDER_COLOR}${"─".repeat(suffixWidth)}${right}${TRANSPARENT_RESET}`;
2683
- }
2684
-
2685
2897
  function trimAnsiRight(text: string): string {
2686
2898
  let trimmed = text;
2687
2899
  while (true) {
@@ -2695,11 +2907,31 @@ function cleanUserMessageLine(line: string): string {
2695
2907
  return `${TRANSPARENT_BG}${trimAnsiRight(stripBackgroundAnsi(stripOsc133Zones(line)))}${TRANSPARENT_BG}`;
2696
2908
  }
2697
2909
 
2698
- function borderedUserMessageLine(line: string, width: number): string {
2699
- const innerWidth = Math.max(1, width - 4);
2700
- const content = clampLineWidth(cleanUserMessageLine(line), innerWidth);
2701
- const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
2702
- 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}`;
2703
2935
  }
2704
2936
 
2705
2937
  function visitMarkdownDescendants(root: unknown, visit: (md: InstanceType<typeof Markdown>) => void): void {
@@ -2716,9 +2948,19 @@ function patchUserMessageRender(): void {
2716
2948
  if (proto[USER_MESSAGE_PATCH_FLAG]) return;
2717
2949
  const originalRender = proto.render;
2718
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
+ }
2719
2960
  proto.render = function patchedUserMessageRender(width: number) {
2720
2961
  const cached = messageRenderCacheHit(this, width);
2721
2962
  if (cached) return cached;
2963
+ if ((this.outputPad ?? 1) < USER_MESSAGE_PAD) this.rebuild?.();
2722
2964
  visitMarkdownDescendants(this, (child) => {
2723
2965
  const markdownAny = child as any;
2724
2966
  if (typeof markdownAny.text === "string") {
@@ -2729,26 +2971,58 @@ function patchUserMessageRender(): void {
2729
2971
  }
2730
2972
  }
2731
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.
2732
2976
  if (markdownAny.defaultTextStyle?.bgColor) {
2733
2977
  markdownAny.defaultTextStyle.bgColor = undefined;
2734
2978
  child.invalidate?.();
2735
2979
  }
2736
2980
  });
2737
- const borderWidth = Math.max(1, width);
2738
- const contentWidth = Math.max(1, borderWidth - 4);
2739
- 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));
2740
2986
  if (!Array.isArray(lines) || lines.length === 0) return lines;
2741
- const rendered = [
2742
- roundedUserBorder(borderWidth, true),
2743
- ...lines.slice(1, -1).map((line: string) => borderedUserMessageLine(line, borderWidth)),
2744
- roundedUserBorder(borderWidth, false),
2745
- ];
2746
- const clamped = rendered.map((line) => clampLineWidth(line, borderWidth));
2747
- 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));
2748
2994
  };
2749
2995
  proto[USER_MESSAGE_PATCH_FLAG] = true;
2750
2996
  }
2751
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
+
2752
3026
  function patchAssistantMessages(): void {
2753
3027
  const proto = AssistantMessageComponent.prototype as any;
2754
3028
  if (proto[ASSISTANT_PATCH_FLAG]) return;
@@ -3572,13 +3846,6 @@ function withFinalBranchBlock(content: string, theme: Theme, isError = false): s
3572
3846
  return [branchLead(first, true, theme), ...middle, branchLead(last, false, theme)].join("\n");
3573
3847
  }
3574
3848
 
3575
- function indentBranchBlock(block: string): string {
3576
- return block
3577
- .split("\n")
3578
- .map((line) => (line ? ` ${line}` : line))
3579
- .join("\n");
3580
- }
3581
-
3582
3849
  // ---------------------------------------------------------------------------
3583
3850
  // Blink timer for partial (running) states
3584
3851
  // ---------------------------------------------------------------------------
@@ -4260,8 +4527,14 @@ function themeAdaptiveEnabled(): boolean {
4260
4527
  // terminalTheme: pi follows the terminal's own palette (Ghostty, Rex, ...).
4261
4528
  // ---------------------------------------------------------------------------
4262
4529
 
4530
+ /** On by default; `terminalTheme: false` (or `/cc-theme terminal off`) turns it off. */
4263
4531
  function terminalThemeEnabled(): boolean {
4264
- return readSettings().terminalTheme === true;
4532
+ return readSettings().terminalTheme !== false;
4533
+ }
4534
+
4535
+ /** `pi --use-theme <name>` picks the theme for this run. */
4536
+ function themeChosenOnCommandLine(): boolean {
4537
+ return process.argv.some((arg) => arg === "--use-theme" || arg.startsWith("--use-theme="));
4265
4538
  }
4266
4539
 
4267
4540
  /** Pi's saved `theme` setting (may be a "light/dark" pair), from pi's own settings file. */
@@ -4275,11 +4548,16 @@ function readPiThemeSetting(): string | undefined {
4275
4548
  }
4276
4549
  }
4277
4550
 
4278
- /** Switch pi to its terminal-palette theme and keep it in sync. Returns false if unavailable. */
4279
- function applyTerminalTheme(ctx: any, options: { announce: boolean }): boolean {
4551
+ /**
4552
+ * Switch pi to its terminal-palette theme and keep it in sync. Returns false if unavailable.
4553
+ * `reportUnavailable` is off for the default-on startup path, so older pi versions stay quiet.
4554
+ */
4555
+ function applyTerminalTheme(ctx: any, options: { announce: boolean; reportUnavailable: boolean }): boolean {
4280
4556
  if (!ctx?.hasUI) return false;
4281
4557
  if (!systemThemeAvailable(ctx.ui)) {
4282
- ctx.ui.notify("Terminal colors need pi's built-in `system` theme (pi 1.4 or newer).", "error");
4558
+ if (options.reportUnavailable) {
4559
+ ctx.ui.notify("Terminal colors need pi's built-in `system` theme (pi 1.4 or newer).", "error");
4560
+ }
4283
4561
  return false;
4284
4562
  }
4285
4563
  const current = activeThemeName(ctx.ui);
@@ -4293,7 +4571,14 @@ function applyTerminalTheme(ctx: any, options: { announce: boolean }): boolean {
4293
4571
  return false;
4294
4572
  }
4295
4573
  if (options.announce) {
4296
- ctx.ui.notify(`Theme follows the terminal's colors (was "${previous ?? current}"). /cc-theme terminal off to switch back.`, "info");
4574
+ // Pi renders a resumed session's transcript right after session_start; post the
4575
+ // notice on the next tick so it lands below that history instead of above it.
4576
+ const message = `Theme follows the terminal's colors (was "${previous ?? current}"). /cc-theme terminal off switches back and keeps it.`;
4577
+ unrefTimer(setTimeout(() => {
4578
+ try {
4579
+ ctx.ui.notify(message, "info");
4580
+ } catch { /* session replaced in the meantime */ }
4581
+ }, 0));
4297
4582
  }
4298
4583
  }
4299
4584
  startFocusResync(ctx.ui, terminalThemeEnabled);
@@ -4358,88 +4643,53 @@ let FG_DEL = "\x1b[38;2;200;100;100m";
4358
4643
  let FG_DIM = "\x1b[38;2;80;80;80m";
4359
4644
  let FG_LNUM = "\x1b[38;2;100;100;100m";
4360
4645
  let FG_RULE = "\x1b[38;2;50;50;50m";
4361
- // Tool branch connectors (├ └ │). The default ("fixed" mode without an explicit gray) blends the
4362
- // terminal's background toward its text color until it has the contrast rgb 72 has on a #161616
4363
- // terminal (2.0:1), so connectors keep that weight on light and tinted themes and take on the
4364
- // theme's tint. Outline chrome (user box, code fences, thinking rule) does the same at the
4365
- // contrast of rgb 136 (5.1:1). Gray 72 / +64 is the fallback when pi does not expose concrete
4366
- // theme colors (pi < 1.4) or themeAdaptive is off.
4367
- const DEFAULT_TOOL_BRANCH_GRAY = 72;
4368
- const BRANCH_CHROME_CONTRAST = 2.0;
4369
- const OUTLINE_CHROME_CONTRAST = 5.1;
4370
-
4371
- // Branch connectors resolve their color on every rendered row, so keep the solved blends.
4372
- const _chromeBlendCache = new Map<string, string>();
4373
-
4374
- /** Terminal background blended toward its text color to `contrast`, or null without concrete colors. */
4375
- function themeChromeBlend(theme: any, contrast: number): string | null {
4376
- if (!theme || !themeAdaptiveEnabled()) return null;
4377
- const defaults = terminalDefaultColors(theme);
4378
- if (!defaults) return null;
4379
- const { background: bg, foreground: fg } = defaults;
4380
- const mode = typeof theme.getColorMode === "function" ? theme.getColorMode() : "";
4381
- const key = `${contrast}|${bg.r},${bg.g},${bg.b}|${fg.r},${fg.g},${fg.b}|${mode}`;
4382
- let ansi = _chromeBlendCache.get(key);
4383
- if (!ansi) {
4384
- if (_chromeBlendCache.size > 32) _chromeBlendCache.clear();
4385
- ansi = rgbFgAnsi(theme, blendForContrast(bg, fg, contrast));
4386
- _chromeBlendCache.set(key, ansi);
4387
- }
4388
- return ansi;
4389
- }
4390
-
4391
- function hasExplicitToolBranchGray(): boolean {
4392
- const raw = readSettings().toolBranchRgbGray;
4393
- return typeof raw === "number" && Number.isFinite(raw);
4394
- }
4646
+ // Branch connectors (├ └ │) and outline chrome (user box, code fences, thinking rule) are neutral
4647
+ // marks drawn at a fixed contrast against the terminal background: the background blended toward
4648
+ // the terminal's text color until it reaches 2:1 for connectors (rgb 72 on a #161616 terminal) and
4649
+ // 5.1:1 for outlines (rgb ~136). That keeps connectors faint but visible on dark, light, and tinted
4650
+ // themes alike, with nothing to configure. When pi does not report the terminal's colors (pi < 1.4),
4651
+ // the same contrasts are taken against plain black or white, per the theme's appearance.
4652
+ type ChromeKind = "branch" | "outline";
4653
+ const CHROME_CONTRAST: Record<ChromeKind, number> = { branch: 2.0, outline: 5.1 };
4654
+ const DARK_FALLBACK_GRAY: Record<ChromeKind, number> = { branch: 72, outline: 136 };
4655
+ const WHITE_RGB = { r: 255, g: 255, b: 255 };
4656
+ const BLACK_RGB = { r: 0, g: 0, b: 0 };
4395
4657
 
4396
4658
  function toolBranchRgbAnsi(gray: number): string {
4397
4659
  const g = Math.max(0, Math.min(255, Math.round(gray)));
4398
4660
  return `\x1b[38;2;${g};${g};${g}m`;
4399
4661
  }
4400
4662
 
4401
- function ansiRgbBrightenedBy(ansi: string, delta: number): string | null {
4402
- const rgb = parseAnsiRgb(ansi);
4403
- if (!rgb) return null;
4404
- const bump = (c: number) => Math.max(0, Math.min(255, Math.round(c + delta)));
4405
- return `\x1b[38;2;${bump(rgb.r)};${bump(rgb.g)};${bump(rgb.b)}m`;
4406
- }
4663
+ // Connectors resolve their color on every rendered row, so keep the solved blends.
4664
+ const _chromeAnsiCache = new Map<string, string>();
4407
4665
 
4408
- /** Outline chrome always stands out more than the branch; never falls back to identical branch ANSI. */
4409
- function outlineChromeAnsiFromBranch(theme?: any): string {
4410
- const t = theme ?? _toolBranchThemeHint;
4411
- if (toolBranchColorModeFixed() && !hasExplicitToolBranchGray()) {
4412
- const blend = themeChromeBlend(t, OUTLINE_CHROME_CONTRAST);
4413
- if (blend) return blend;
4414
- }
4415
- const branch = currentToolBranchAnsi(t);
4416
- // Toward the text color: brighter on dark themes, darker on light ones.
4417
- const towardText = t && isLightThemeBackground(t) ? -OUTLINE_CHROME_BRIGHTEN : OUTLINE_CHROME_BRIGHTEN;
4418
- const fromBranch = ansiRgbBrightenedBy(branch, towardText);
4419
- if (fromBranch) return fromBranch;
4420
- let gray = DEFAULT_TOOL_BRANCH_GRAY;
4421
- if (toolBranchColorModeFixed()) {
4422
- gray = getConfiguredToolBranchGray();
4423
- } else if (t) {
4424
- const hint = safeFgAnsi(t, "dim") ?? safeFgAnsi(t, "muted") ?? safeFgAnsi(t, "borderMuted");
4425
- const rgb = hint ? parseAnsiRgb(hint) : null;
4426
- if (rgb) gray = Math.round((rgb.r + rgb.g + rgb.b) / 3);
4666
+ /** Chrome color for `kind` on the current terminal background. */
4667
+ function chromeAnsi(theme: any, kind: ChromeKind): string {
4668
+ const defaults = theme ? terminalDefaultColors(theme) : null;
4669
+ const light = !defaults && !!theme && isLightThemeBackground(theme);
4670
+ const mode = typeof theme?.getColorMode === "function" ? theme.getColorMode() : "truecolor";
4671
+ const key = defaults
4672
+ ? `${kind}|${rgbToHex(defaults.background)}|${rgbToHex(defaults.foreground)}|${mode}`
4673
+ : `${kind}|${light ? "light" : "dark"}|${mode}`;
4674
+ let ansi = _chromeAnsiCache.get(key);
4675
+ if (!ansi) {
4676
+ const contrast = CHROME_CONTRAST[kind];
4677
+ const gray = DARK_FALLBACK_GRAY[kind];
4678
+ const rgb = defaults
4679
+ ? blendForContrast(defaults.background, defaults.foreground, contrast)
4680
+ : light
4681
+ ? blendForContrast(WHITE_RGB, BLACK_RGB, contrast)
4682
+ : { r: gray, g: gray, b: gray };
4683
+ if (_chromeAnsiCache.size > 32) _chromeAnsiCache.clear();
4684
+ ansi = rgbFgAnsi(theme, rgb);
4685
+ _chromeAnsiCache.set(key, ansi);
4427
4686
  }
4428
- return toolBranchRgbAnsi(Math.min(255, gray + OUTLINE_CHROME_BRIGHTEN));
4429
- }
4430
-
4431
- function getConfiguredToolBranchGray(): number {
4432
- const raw = readSettings().toolBranchRgbGray;
4433
- return typeof raw === "number" && Number.isFinite(raw) ? Math.max(0, Math.min(255, Math.round(raw))) : DEFAULT_TOOL_BRANCH_GRAY;
4434
- }
4435
-
4436
- function toolBranchColorModeFixed(): boolean {
4437
- return readSettings().toolBranchColorMode !== "theme";
4687
+ return ansi;
4438
4688
  }
4439
4689
 
4440
4690
  function toolBranchRenderCacheKey(): string {
4441
4691
  // The escape itself, not stripAnsi(TOOL_RULE) (which is always ""), so a new color is a new key.
4442
- return `${toolBranchColorModeFixed() ? "fixed" : "theme"}:${TOOL_RULE}`;
4692
+ return TOOL_RULE;
4443
4693
  }
4444
4694
 
4445
4695
  let _toolBranchVisualEpoch = 0;
@@ -4448,51 +4698,17 @@ function bumpToolBranchVisualEpoch(): void {
4448
4698
  _toolBranchVisualEpoch++;
4449
4699
  }
4450
4700
 
4451
- /** On light panels, theme dim/muted can be nearly white — pull chrome toward mid-gray. */
4452
- function attenuateChromeAnsi(ansi: string, theme: any): string {
4453
- const rgb = parseAnsiRgb(ansi);
4454
- if (!rgb) return ansi;
4455
- if (!isLightThemeBackground(theme)) return ansi;
4456
- const lum = 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b;
4457
- // Already quiet enough on light backgrounds.
4458
- if (lum <= 145) return ansi;
4459
- const target = 118;
4460
- const t = Math.min(1, (lum - 130) / 110);
4461
- const mix = (c: number) => Math.round(c + (target - c) * t);
4462
- return `\x1b[38;2;${mix(rgb.r)};${mix(rgb.g)};${mix(rgb.b)}m`;
4463
- }
4464
-
4465
- /** Shared outline chrome: user box, tool rules, code fences, branch connectors. */
4466
- function resolveThemeChromeFg(theme: any): string | null {
4467
- if (!theme || !themeAdaptiveEnabled()) return null;
4468
- const dim = safeFgAnsi(theme, "dim");
4469
- const muted = safeFgAnsi(theme, "muted");
4470
- const borderMuted = safeFgAnsi(theme, "borderMuted");
4471
- const thinking = safeFgAnsi(theme, "thinkingText");
4472
- const raw = dim ?? muted ?? borderMuted ?? thinking;
4473
- return raw ? attenuateChromeAnsi(raw, theme) : null;
4474
- }
4475
-
4476
- /** Resolve ├ └ │ color from settings + theme on every use (not a stale global). */
4701
+ /** Last theme seen, for call sites that draw connectors without a theme at hand. */
4477
4702
  let _toolBranchThemeHint: any;
4478
4703
 
4704
+ /** ├ └ │ color for the current terminal background (resolved on every use, not a stale global). */
4479
4705
  function currentToolBranchAnsi(theme?: any): string {
4480
- const t = theme ?? _toolBranchThemeHint;
4481
- if (toolBranchColorModeFixed()) {
4482
- if (!hasExplicitToolBranchGray()) {
4483
- const blend = themeChromeBlend(t, BRANCH_CHROME_CONTRAST);
4484
- if (blend) return blend;
4485
- }
4486
- return toolBranchRgbAnsi(getConfiguredToolBranchGray());
4487
- }
4488
- const chrome = t ? resolveThemeChromeFg(t) : null;
4489
- if (chrome) return chrome;
4490
- return toolBranchRgbAnsi(getConfiguredToolBranchGray());
4706
+ return chromeAnsi(theme ?? _toolBranchThemeHint, "branch");
4491
4707
  }
4492
4708
 
4493
- /** User box, code fences, thinking/thought: branch + OUTLINE_CHROME_BRIGHTEN (never same as branch). */
4709
+ /** User box, code fences, thinking/thought: the outline chrome color (stronger than connectors). */
4494
4710
  function syncOutlineChromeFromBranch(theme?: any): void {
4495
- const outline = outlineChromeAnsiFromBranch(theme);
4711
+ const outline = chromeAnsi(theme ?? _toolBranchThemeHint, "outline");
4496
4712
  const prevBorder = BORDER_COLOR;
4497
4713
  BORDER_COLOR = outline;
4498
4714
  WORKED_LINE_FG = outline;
@@ -4527,11 +4743,11 @@ function liveBranchDisplay(state: Record<string, unknown> | undefined, theme: Th
4527
4743
  if (!state || typeof state !== "object") return undefined;
4528
4744
  const body = state._ptBody;
4529
4745
  if (typeof body === "string" && body.trim() && !body.includes("(rendering")) {
4530
- return indentBranchBlock(withBranch(body, theme, false, true));
4746
+ return withBranch(body, theme, false, true);
4531
4747
  }
4532
4748
  const display = state._ptDisplay;
4533
4749
  if (typeof display === "string" && display.trim()) {
4534
- return indentBranchBlock(withBranch(stripBranchMarkupBlock(display), theme, false, true));
4750
+ return withBranch(stripBranchMarkupBlock(display), theme, false, true);
4535
4751
  }
4536
4752
  return undefined;
4537
4753
  }
@@ -4540,13 +4756,13 @@ function refreshToolBranchDisplaysInState(state: Record<string, unknown> | undef
4540
4756
  if (!state || typeof state !== "object") return;
4541
4757
  const body = state._ptBody;
4542
4758
  if (typeof body === "string" && body.trim() && !body.includes("(rendering")) {
4543
- state._ptDisplay = indentBranchBlock(withBranch(body, theme, false, true));
4759
+ state._ptDisplay = withBranch(body, theme, false, true);
4544
4760
  return;
4545
4761
  }
4546
4762
  const display = state._ptDisplay;
4547
4763
  if (typeof display === "string" && display.trim()) {
4548
4764
  const stripped = stripBranchMarkupBlock(display);
4549
- state._ptDisplay = indentBranchBlock(withBranch(stripped, theme, false, true));
4765
+ state._ptDisplay = withBranch(stripped, theme, false, true);
4550
4766
  }
4551
4767
  }
4552
4768
 
@@ -4556,7 +4772,7 @@ function refreshAllToolBranchVisuals(ctx: any): void {
4556
4772
  invalidateThemePaletteCache();
4557
4773
  applyToolBackgroundMode(ctx?.ui?.theme);
4558
4774
  applyToolBranchColor(ctx?.ui?.theme);
4559
- bumpToolBranchVisualEpoch(); // always bust ToolText + container caches after /cc-tools branch
4775
+ bumpToolBranchVisualEpoch(); // always bust ToolText + container caches
4560
4776
  // Tool rows recompute branch markup on next render (liveBranchDisplay + cache bust).
4561
4777
  if (ctx?.hasUI) {
4562
4778
  try {
@@ -4597,7 +4813,7 @@ function scheduleDeferredChromeRebind(ctx: any, delayMs = 0): void {
4597
4813
  unrefTimer(timer);
4598
4814
  }
4599
4815
 
4600
- let TOOL_RULE = toolBranchRgbAnsi(DEFAULT_TOOL_BRANCH_GRAY);
4816
+ let TOOL_RULE = toolBranchRgbAnsi(DARK_FALLBACK_GRAY.branch);
4601
4817
  let FG_SAFE_MUTED = "\x1b[38;2;139;148;158m";
4602
4818
  let FG_STRIPE = "\x1b[38;2;40;40;40m";
4603
4819
 
@@ -4781,7 +4997,7 @@ const _claudeStyleDefaults = {
4781
4997
  BORDER_COLOR: "\x1b[38;5;238m",
4782
4998
  WORKED_LINE_FG: "\x1b[38;2;140;140;140m",
4783
4999
  CODE_BLOCK_LANG_FG: "\x1b[38;2;95;95;95m",
4784
- TOOL_RULE: toolBranchRgbAnsi(DEFAULT_TOOL_BRANCH_GRAY),
5000
+ TOOL_RULE: toolBranchRgbAnsi(DARK_FALLBACK_GRAY.branch),
4785
5001
  FG_DIM: "\x1b[38;2;80;80;80m",
4786
5002
  FG_LNUM: "\x1b[38;2;100;100;100m",
4787
5003
  FG_RULE: "\x1b[38;2;50;50;50m",
@@ -5283,6 +5499,7 @@ const EXT_LANG: Record<string, BundledLanguage> = {
5283
5499
  cs: "csharp",
5284
5500
  swift: "swift",
5285
5501
  kt: "kotlin",
5502
+ kts: "kotlin",
5286
5503
  html: "html",
5287
5504
  css: "css",
5288
5505
  scss: "scss",
@@ -5965,7 +6182,7 @@ function renderEditPreviewBody(
5965
6182
  summary: string,
5966
6183
  ): void {
5967
6184
  const dc = resolveDiffColors(theme);
5968
- const branchWidth = contextDiffWidth(ctx, 3);
6185
+ const branchWidth = contextDiffWidth(ctx);
5969
6186
  if (operations.length === 1) {
5970
6187
  const [diff] = diffs;
5971
6188
  const line = lines[0] ?? getFirstChangedNewLine(diff);
@@ -5973,13 +6190,13 @@ function renderEditPreviewBody(
5973
6190
  .then((rendered) => {
5974
6191
  if (ctx.state._pk !== key) return;
5975
6192
  ctx.state._ptBody = `${summarizeDiff(diff.added, diff.removed)}${formatLineMeta(line, theme)}\n${rendered}`;
5976
- ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
6193
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
5977
6194
  safeInvalidate(ctx);
5978
6195
  })
5979
6196
  .catch(() => {
5980
6197
  if (ctx.state._pk !== key) return;
5981
6198
  ctx.state._ptBody = `${summarizeDiff(diff.added, diff.removed)}${formatLineMeta(line, theme)}`;
5982
- ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
6199
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
5983
6200
  safeInvalidate(ctx);
5984
6201
  });
5985
6202
  return;
@@ -6001,13 +6218,13 @@ function renderEditPreviewBody(
6001
6218
  ? `\n${theme.fg("muted", `… ${remainder} more edit blocks${toolOutputDetailHint(theme, ctx.expanded, true)}`)}`
6002
6219
  : "";
6003
6220
  ctx.state._ptBody = `${operations.length} edits ${summary}\n\n${sections.join("\n\n")}${suffix}`;
6004
- ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
6221
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
6005
6222
  safeInvalidate(ctx);
6006
6223
  })
6007
6224
  .catch(() => {
6008
6225
  if (ctx.state._pk !== key) return;
6009
6226
  ctx.state._ptBody = `${operations.length} edits ${summary}`;
6010
- ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
6227
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
6011
6228
  safeInvalidate(ctx);
6012
6229
  });
6013
6230
  }
@@ -7035,8 +7252,11 @@ function summarizeOpenAiToolCall(name: string, args: any, theme: Theme, sp: (pat
7035
7252
  return theme.fg("muted", "enable read-only planning");
7036
7253
  case "ExitPlanMode":
7037
7254
  return theme.fg("muted", "present plan");
7038
- case "Agent":
7039
- 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
+ }
7040
7260
  case "get_subagent_result":
7041
7261
  return getStringArg(args, "agent_id") || theme.fg("muted", "agent result");
7042
7262
  case "steer_subagent":
@@ -7057,10 +7277,7 @@ function summarizeOpenAiToolCall(name: string, args: any, theme: Theme, sp: (pat
7057
7277
  return taskIds.length === 1 ? taskIds[0] : `${taskIds[0]} ${theme.fg("muted", `(+${taskIds.length - 1} tasks)`)}`;
7058
7278
  }
7059
7279
  default:
7060
- return summarizeText(
7061
- getStringArg(args, "path", "file_path", "url", "query", "name", "subject", "tool", "description", "prompt") || humanizeToolName(name),
7062
- 72,
7063
- );
7280
+ return summarizeUnknownToolArgs(args);
7064
7281
  }
7065
7282
  }
7066
7283
 
@@ -7196,6 +7413,10 @@ function renderReadImageResult(result: any, expanded: boolean, theme: Theme, ctx
7196
7413
  }
7197
7414
 
7198
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
+ }
7199
7420
  if (isPartial) {
7200
7421
  return makeText(ctx.lastComponent, runningPreviewBlock(result, theme.fg("dim", `${humanizeToolName(name)}...`), expanded, theme, ctx));
7201
7422
  }
@@ -7242,6 +7463,116 @@ function renderOpenAiToolResult(name: string, result: any, expanded: boolean, is
7242
7463
  return makeText(ctx.lastComponent, withBranch(`${statusText}\n${preview}`, theme));
7243
7464
  }
7244
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
+
7245
7576
  // ===========================================================================
7246
7577
  // Extension
7247
7578
  // ===========================================================================
@@ -7260,6 +7591,7 @@ export default function (pi: ExtensionAPI) {
7260
7591
  patchAssistantMessages();
7261
7592
  patchAssistantFullscreenMouse();
7262
7593
  patchToolExecutionRenderers();
7594
+ patchBashFileEditGrouping();
7263
7595
  applyDiffPalette();
7264
7596
  registerThinkingLabels(pi);
7265
7597
  syncExtraToolDetailMode();
@@ -7278,7 +7610,7 @@ export default function (pi: ExtensionAPI) {
7278
7610
  // /cc-tools command — control tool chrome, grouping, and detail level.
7279
7611
  const TOOL_MODES = ["outlines", "transparent", "default"] as const;
7280
7612
  const TOOL_BOOL_MODES = ["on", "off", "toggle", "status"] as const;
7281
- const TOOL_SUBCOMMANDS = [...TOOL_MODES, "group", "detail", "thinking", "branch", "status"] as const;
7613
+ const TOOL_SUBCOMMANDS = [...TOOL_MODES, "group", "detail", "thinking", "status"] as const;
7282
7614
  const booleanMode = (raw: string | undefined, current: boolean): boolean | "status" | undefined => {
7283
7615
  const mode = raw || "toggle";
7284
7616
  if (mode === "on") return true;
@@ -7289,24 +7621,11 @@ export default function (pi: ExtensionAPI) {
7289
7621
  };
7290
7622
  const notifyToolStatus = (ctx: any): void => {
7291
7623
  if (!ctx.hasUI) return;
7292
- const branchMode = toolBranchColorModeFixed() ? "fixed" : "theme";
7293
- const branchGray = getConfiguredToolBranchGray();
7294
- const theme = ctx.ui?.theme;
7295
- const chromeHint = branchMode === "theme" && theme
7296
- ? (resolveThemeChromeFg(theme) ? " (attenuated on light themes)" : " (fallback gray if theme keys missing)")
7297
- : "";
7298
- const branchLine = branchMode === "theme"
7299
- ? `Branch color: theme${chromeHint}`
7300
- : hasExplicitToolBranchGray() || !themeChromeBlend(theme, BRANCH_CHROME_CONTRAST)
7301
- ? `Branch color: fixed rgb(${branchGray})`
7302
- : "Branch color: fixed 2:1 contrast on the terminal background (rgb 72 on near-black)";
7303
7624
  ctx.ui.notify([
7304
7625
  `Tool style: ${toolBackgroundMode}`,
7305
7626
  `Tool grouping: ${toolGroupingEnabled() ? "on" : "off"}`,
7306
7627
  `Thinking: ${getThinkingMode()}`,
7307
7628
  `Extra detail: ${extraToolOutputExpanded ? "on" : "off"} (${rawKeyHint("ctrl+shift+o", "toggle")})`,
7308
- branchLine,
7309
- ` /cc-tools branch <0-255> | theme | fixed | reset`,
7310
7629
  ].join("\n"), "info");
7311
7630
  };
7312
7631
  pi.registerCommand("cc-tools", {
@@ -7324,20 +7643,12 @@ export default function (pi: ExtensionAPI) {
7324
7643
  m === "group" ? "Toggle grouped adjacent/concurrent tool rows"
7325
7644
  : m === "thinking" ? "Thinking display: live (default) or full"
7326
7645
  : m === "detail" ? "Toggle Ctrl+Shift+O extra-detail mode"
7327
- : m === "branch" ? "├ └ │ gray (0-255), theme, fixed, or reset"
7328
7646
  : m === "status" ? "Show tool UI settings"
7329
7647
  : m === "outlines" ? "Horizontal rules around each tool (default)"
7330
7648
  : m === "transparent" ? "No borders or backgrounds"
7331
7649
  : "Pi built-in tool backgrounds",
7332
7650
  }));
7333
7651
  }
7334
- if (first === "branch") {
7335
- const second = parts[1] ?? "";
7336
- const opts = ["theme", "fixed", "reset", "status"];
7337
- return opts
7338
- .filter((o) => o.startsWith(second))
7339
- .map((o) => ({ value: `branch ${o}`, label: o, description: "Branch connector color" }));
7340
- }
7341
7652
  if (first === "thinking") {
7342
7653
  const second = parts[1] ?? "";
7343
7654
  return ["live", "full", "status"]
@@ -7379,44 +7690,6 @@ export default function (pi: ExtensionAPI) {
7379
7690
  return;
7380
7691
  }
7381
7692
 
7382
- if (sub === "branch") {
7383
- const arg = parts[1] ?? "status";
7384
- if (arg === "status" || !arg) {
7385
- notifyToolStatus(ctx);
7386
- return;
7387
- }
7388
- if (arg === "reset") {
7389
- writeSettingsKey("toolBranchRgbGray", undefined);
7390
- writeSettingsKey("toolBranchColorMode", undefined);
7391
- if (ctx.hasUI) refreshAllToolBranchVisuals(ctx);
7392
- if (ctx.hasUI) ctx.ui.notify("Branch color → default (fixed contrast on the terminal background)", "info");
7393
- return;
7394
- }
7395
- if (arg === "theme") {
7396
- writeSettingsKey("toolBranchColorMode", "theme");
7397
- if (ctx.hasUI) refreshAllToolBranchVisuals(ctx);
7398
- if (ctx.hasUI) ctx.ui.notify("Branch color → follow pi theme (dim/muted)", "info");
7399
- return;
7400
- }
7401
- if (arg === "fixed") {
7402
- writeSettingsKey("toolBranchColorMode", "fixed");
7403
- if (ctx.hasUI) refreshAllToolBranchVisuals(ctx);
7404
- if (ctx.hasUI) ctx.ui.notify(hasExplicitToolBranchGray()
7405
- ? `Branch color → fixed rgb(${getConfiguredToolBranchGray()})`
7406
- : "Branch color → fixed contrast on the terminal background", "info");
7407
- return;
7408
- }
7409
- const gray = Number.parseInt(arg, 10);
7410
- if (!Number.isFinite(gray) || gray < 0 || gray > 255) {
7411
- if (ctx.hasUI) ctx.ui.notify("Usage: /cc-tools branch <0-255> | theme | fixed | reset", "error");
7412
- return;
7413
- }
7414
- writeSettingsKey("toolBranchRgbGray", gray);
7415
- writeSettingsKey("toolBranchColorMode", "fixed");
7416
- if (ctx.hasUI) refreshAllToolBranchVisuals(ctx);
7417
- if (ctx.hasUI) ctx.ui.notify(`Branch color → fixed rgb(${gray})`, "info");
7418
- return;
7419
- }
7420
7693
 
7421
7694
  if (sub === "thinking") {
7422
7695
  const arg = (parts[1] ?? "status").toLowerCase();
@@ -7478,8 +7751,9 @@ export default function (pi: ExtensionAPI) {
7478
7751
  if (!ctx.hasUI) return;
7479
7752
  const name = activeThemeName(ctx.ui) ?? "unknown";
7480
7753
  const previous = readSettings().terminalThemePrevious;
7754
+ const isDefault = readSettings().terminalTheme === undefined;
7481
7755
  ctx.ui.notify([
7482
- `Terminal colors: ${current ? "on" : "off"} (pi theme "${name}")`,
7756
+ `Terminal colors: ${current ? "on" : "off"}${isDefault ? " (default)" : ""} (pi theme "${name}")`,
7483
7757
  current && previous ? ` /cc-theme terminal off restores "${previous}"` : "",
7484
7758
  current && name !== SYSTEM_THEME_NAME ? " pi is not using the system theme right now; /cc-theme terminal on switches it back" : "",
7485
7759
  ].filter(Boolean).join("\n"), "info");
@@ -7494,15 +7768,17 @@ export default function (pi: ExtensionAPI) {
7494
7768
  return;
7495
7769
  }
7496
7770
  if (next) {
7771
+ const prior = readSettings().terminalTheme;
7497
7772
  writeSettingsKey("terminalTheme", true);
7498
- if (!applyTerminalTheme(ctx, { announce: false })) {
7499
- writeSettingsKey("terminalTheme", undefined);
7773
+ if (!applyTerminalTheme(ctx, { announce: false, reportUnavailable: true })) {
7774
+ writeSettingsKey("terminalTheme", prior);
7500
7775
  return;
7501
7776
  }
7502
7777
  requestTerminalColorScheme();
7503
7778
  if (ctx.hasUI) ctx.ui.notify("Terminal colors: on — pi's theme now follows the terminal (Ghostty, Rex, ...)", "info");
7504
7779
  } else {
7505
- writeSettingsKey("terminalTheme", undefined);
7780
+ // Explicit false: unset means on (the default).
7781
+ writeSettingsKey("terminalTheme", false);
7506
7782
  const restored = restorePreviousTheme(ctx);
7507
7783
  if (ctx.hasUI) {
7508
7784
  ctx.ui.notify(restored
@@ -7561,11 +7837,10 @@ export default function (pi: ExtensionAPI) {
7561
7837
  const statusKey = settings.spinnerStatusColor || "muted";
7562
7838
  const verbAnsi = safeFgAnsi(theme, verbKey) ?? safeFgAnsi(theme, "accent");
7563
7839
  const statusAnsi = safeFgAnsi(theme, statusKey) ?? safeFgAnsi(theme, "muted");
7564
- const chromePreview = resolveThemeChromeFg(theme);
7565
7840
  // Print a short preview of what we derived.
7566
7841
  const preview = [
7567
- `chrome : ${chromePreview ? `${chromePreview}─┌ User ├─\x1b[39m` : "(unchanged)"}`,
7568
- ` (user box, tool rules, branches)`,
7842
+ `outlines : ${chromeAnsi(theme, "outline")}╭─ User ──\x1b[39m (user box, code fences)`,
7843
+ `connectors : ${chromeAnsi(theme, "branch")}├ └ │\x1b[39m (faint, fixed contrast on the terminal background)`,
7569
7844
  `muted text : ${safeFgAnsi(theme, "muted") ? `${safeFgAnsi(theme, "muted")}example dim text\x1b[39m` : "(unchanged)"}`,
7570
7845
  `diff add : ${safeFgAnsi(theme, "toolDiffAdded") ? `${safeFgAnsi(theme, "toolDiffAdded")}+ added line\x1b[39m` : "(unchanged)"}`,
7571
7846
  `diff del : ${safeFgAnsi(theme, "toolDiffRemoved") ? `${safeFgAnsi(theme, "toolDiffRemoved")}- removed line\x1b[39m` : "(unchanged)"}`,
@@ -7708,8 +7983,13 @@ export default function (pi: ExtensionAPI) {
7708
7983
  clearRtkRewriteState();
7709
7984
  if (!ctx.hasUI) return;
7710
7985
  patchUiNotifications(ctx.ui);
7711
- // terminalTheme: switch to (and stay on) the theme built from the terminal's palette.
7712
- if (terminalThemeEnabled()) applyTerminalTheme(ctx, { announce: true });
7986
+ patchAgentWidget(ctx.ui);
7987
+ installPromptMarkerEditor(ctx);
7988
+ // terminalTheme (on by default): switch to, and stay on, the theme built from the
7989
+ // terminal's palette. `pi --use-theme <name>` still wins for that run.
7990
+ if (terminalThemeEnabled() && !(themeChosenOnCommandLine() && activeThemeName(ctx.ui) !== SYSTEM_THEME_NAME)) {
7991
+ applyTerminalTheme(ctx, { announce: true, reportUnavailable: readSettings().terminalTheme === true });
7992
+ }
7713
7993
  // Session switch (/resume, /new) can leave tool chrome from the previous
7714
7994
  // theme; rebind from ctx.ui.theme (other extensions may setTheme in the
7715
7995
  // same tick — deferred passes pick up the final theme without coupling).
@@ -7726,6 +8006,7 @@ export default function (pi: ExtensionAPI) {
7726
8006
  pi.on("turn_start", async (_event, ctx) => {
7727
8007
  if (!ctx.hasUI) return;
7728
8008
  patchUiNotifications(ctx.ui);
8009
+ patchAgentWidget(ctx.ui);
7729
8010
  applyToolBackgroundMode(ctx.ui.theme);
7730
8011
  applyThemePaletteIfNeeded(ctx.ui.theme);
7731
8012
  });
@@ -7812,13 +8093,31 @@ export default function (pi: ExtensionAPI) {
7812
8093
  promptGuidelines: (bashTool as any).promptGuidelines,
7813
8094
  parameters: bashTool.parameters,
7814
8095
  async execute(toolCallId, params, signal, onUpdate) {
7815
- 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;
7816
8106
  },
7817
8107
  renderCall(args, theme, ctx) {
7818
8108
  syncToolCallStatus(ctx);
7819
8109
  syncBashDuration(ctx);
7820
8110
  const rewrite = ensureRtkRewriteForContext(ctx, args);
7821
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
+ }
7822
8121
  const presentation = buildBashCommandPresentation(command);
7823
8122
  const summary = stableCallSummary(ctx, "_bashHeadline", () => presentation.headline);
7824
8123
  const rtkBadge = rewrite ? theme.fg("muted", " (RTK)") : "";
@@ -7840,6 +8139,22 @@ export default function (pi: ExtensionAPI) {
7840
8139
  const details = result.details as BashToolDetails | undefined;
7841
8140
  const rewrite = ensureRtkRewriteForContext(ctx, ctx.args);
7842
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
+ }
7843
8158
  if (isPartial) {
7844
8159
  const preview = collectNonEmptyLines(output, expanded ? undefined : liveToolPreviewLimit());
7845
8160
  const running = runningPreviewBlock(result, "", expanded, theme, ctx, {
@@ -8170,6 +8485,10 @@ export default function (pi: ExtensionAPI) {
8170
8485
  pi.registerTool({
8171
8486
  name: "edit",
8172
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",
8173
8492
  description: editTool.description,
8174
8493
  promptSnippet: (editTool as any).promptSnippet,
8175
8494
  promptGuidelines: (editTool as any).promptGuidelines,
@@ -8220,21 +8539,21 @@ export default function (pi: ExtensionAPI) {
8220
8539
  const revealSummary = shouldRevealCallArgs(ctx) || (!!fp && hasOwnArg(args, "edits"));
8221
8540
  const summary = stableCallSummary(ctx, "_callSummary", () => shouldRevealCallArgs(ctx) && operations.length > 1 ? `${sp(fp)} ${theme.fg("muted", `(${operations.length} edits)`)}` : sp(fp), revealSummary);
8222
8541
  syncToolCallStatus(ctx);
8223
- 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));
8224
8543
  // History replay never sets argsComplete; a finished edit is drawn from its result instead.
8225
8544
  const finished = ctx.state._editResult as Record<string, unknown> | undefined;
8226
8545
  if (!((ctx.argsComplete || finished) && operations.length > 0)) return makeText(ctx.lastComponent, hdr);
8227
8546
  if (!diffWidthReady(ctx)) {
8228
- 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);
8229
8548
  return makeResponsiveDiffText(ctx, ctx.lastComponent, `${hdr}\n${pending}`);
8230
8549
  }
8231
- const diffWidth = contextDiffWidth(ctx, 3);
8550
+ const diffWidth = contextDiffWidth(ctx);
8232
8551
  const key = `edit:${fp}:${hashText(operations.map((edit) => `${edit.oldText}\u0000${edit.newText}`).join("\u0001"))}:${diffWidth}:${ctx.expanded ? 1 : 0}:${_toolBranchVisualEpoch}`;
8233
8552
  const { diffs: fallbackDiffs, summary: editSummary } = getCachedEditOperationSummary(ctx, key, operations);
8234
8553
  if (ctx.state._pk !== key) {
8235
8554
  ctx.state._pk = key;
8236
8555
  ctx.state._ptBody = theme.fg("muted", "(rendering…)");
8237
- ctx.state._ptDisplay = indentBranchBlock(withBranch(ctx.state._ptBody, theme, false, true));
8556
+ ctx.state._ptDisplay = withBranch(ctx.state._ptBody, theme, false, true);
8238
8557
  const lg = lang(fp);
8239
8558
  // Located once per row: expanding or resizing must not look in the file again, it no longer holds oldText.
8240
8559
  ctx.state._editLocalized ??= !finished
@@ -8259,7 +8578,7 @@ export default function (pi: ExtensionAPI) {
8259
8578
  },
8260
8579
  renderResult(result, { expanded, isPartial }, theme, ctx) {
8261
8580
  if (isPartial) {
8262
- 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));
8263
8582
  }
8264
8583
  clearBlinkTimer(ctx);
8265
8584
  setToolStatus(ctx, ctx.isError ? "error" : "success");
@@ -8269,20 +8588,20 @@ export default function (pi: ExtensionAPI) {
8269
8588
  ?.filter((c: any) => c.type === "text")
8270
8589
  .map((c: any) => c.text || "")
8271
8590
  .join("\n") ?? "Error";
8272
- return makeText(ctx.lastComponent, indentBranchBlock(withBranch(theme.fg("error", e), theme)));
8591
+ return makeText(ctx.lastComponent, withBranch(theme.fg("error", e), theme));
8273
8592
  }
8274
8593
  if ((result as any).details?._type === "editInfo") {
8275
8594
  const { editLine, hunks, added, removed } = (result as any).details;
8276
8595
  const loc = formatLineMeta(editLine ?? 0, theme);
8277
8596
  const summary = diffSummaryWithMeta(added ?? 0, removed ?? 0, hunks ?? 0, "");
8278
- return makeText(ctx.lastComponent, indentBranchBlock(withBranch(`${summary}${loc}`, theme)));
8597
+ return makeText(ctx.lastComponent, withBranch(`${summary}${loc}`, theme));
8279
8598
  }
8280
8599
  if ((result as any).details?._type === "multiEditInfo") {
8281
8600
  const { editCount, diffLineCount, hunks, totalAdded, totalRemoved } = (result as any).details;
8282
8601
  const summary = diffSummaryWithMeta(totalAdded ?? 0, totalRemoved ?? 0, hunks ?? 0, "");
8283
- 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));
8284
8603
  }
8285
- return makeText(ctx.lastComponent, indentBranchBlock(withBranch(theme.fg("success", "Applied"), theme)));
8604
+ return makeText(ctx.lastComponent, withBranch(theme.fg("success", "Applied"), theme));
8286
8605
  },
8287
8606
  });
8288
8607