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