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.
- package/CHANGELOG.md +23 -0
- package/README.md +16 -11
- package/config/config.example.json +1 -1
- package/extensions/bash-file-edit.ts +301 -0
- package/extensions/index.ts +640 -321
- package/extensions/spinner.ts +79 -2
- package/package.json +2 -1
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
99
|
-
//
|
|
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
|
-
*
|
|
161
|
-
* from the terminal's own palette (Ghostty, Rex, ...), and
|
|
162
|
-
* palette when the terminal regains focus so same-kind theme
|
|
163
|
-
* (dark to dark) are picked up too.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
|
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
|
|
1098
|
-
|
|
1099
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
?
|
|
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
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
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
|
|
2738
|
-
|
|
2739
|
-
|
|
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
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
const
|
|
2747
|
-
return storeMessageRenderCache(this, width, applyTerminalCopyZones(
|
|
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
|
|
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
|
-
/**
|
|
4279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
4362
|
-
//
|
|
4363
|
-
// terminal
|
|
4364
|
-
//
|
|
4365
|
-
//
|
|
4366
|
-
//
|
|
4367
|
-
|
|
4368
|
-
const
|
|
4369
|
-
const
|
|
4370
|
-
|
|
4371
|
-
|
|
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
|
-
|
|
4402
|
-
|
|
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
|
-
/**
|
|
4409
|
-
function
|
|
4410
|
-
const
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
4709
|
+
/** User box, code fences, thinking/thought: the outline chrome color (stronger than connectors). */
|
|
4494
4710
|
function syncOutlineChromeFromBranch(theme?: any): void {
|
|
4495
|
-
const outline =
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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", "
|
|
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",
|
|
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
|
-
|
|
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
|
-
`
|
|
7568
|
-
` (
|
|
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
|
-
|
|
7712
|
-
|
|
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
|
-
|
|
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,
|
|
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) ??
|
|
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
|
|
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 =
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
8604
|
+
return makeText(ctx.lastComponent, withBranch(theme.fg("success", "Applied"), theme));
|
|
8286
8605
|
},
|
|
8287
8606
|
});
|
|
8288
8607
|
|