@pi-kaush/pi-tool-call-markers 0.2.6 → 0.2.8
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 +1 -1
- package/package.json +1 -1
- package/src/index.ts +104 -75
- package/src/thinking-block-merger.ts +223 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
-
-
|
|
5
|
+
- Render every collapsed tool call as a `% tool: call → outcome` line with the tool name bolded — one marker per call, no bullets, no blank lines between sections; grouped blocks share only their leading blank row (supersedes the nested sub-heading layout).
|
|
6
6
|
- Render subagent calls as an unboxed plan — `% subagent` heading with chain/parallel counts and numbered steps as they execute, agent names in accent with emojis scraped from the native plan component (args fallback) — replacing the accent-rail card; failed subagents follow the full-red failure tone.
|
|
7
7
|
- Strip display sequences and control bytes (notably `\r` from progress writers like git) from collapsed-row text so command output cannot return the cursor to column 0 and overwrite the row.
|
|
8
8
|
- Color the truncation ellipsis to match its row tone (`muted` settled, `error` failed) instead of the terminal default foreground left by pi-tui's truncation reset.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -61,6 +61,7 @@ type PresentationPatchState = {
|
|
|
61
61
|
theme?: ThemeLike;
|
|
62
62
|
collapseParallel: boolean;
|
|
63
63
|
groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
|
|
64
|
+
collapsedCache: WeakMap<ToolExecutionRow, CollapsedRenderCache>;
|
|
64
65
|
rowVersions: WeakMap<ToolExecutionRow, number>;
|
|
65
66
|
rowGroups: WeakMap<ToolExecutionRow, ToolExecutionRow[]>;
|
|
66
67
|
rowSignatures: WeakMap<ToolExecutionRow, string>;
|
|
@@ -85,6 +86,14 @@ type GroupRenderCache = {
|
|
|
85
86
|
width: number;
|
|
86
87
|
};
|
|
87
88
|
|
|
89
|
+
type CollapsedRenderCache = {
|
|
90
|
+
lines: string[];
|
|
91
|
+
signature: string;
|
|
92
|
+
version: number;
|
|
93
|
+
themeSample: string;
|
|
94
|
+
width: number;
|
|
95
|
+
};
|
|
96
|
+
|
|
88
97
|
function envEnabled(name: string, defaultValue: boolean): boolean {
|
|
89
98
|
const value = process.env[name]?.trim().toLowerCase();
|
|
90
99
|
if (!value) return defaultValue;
|
|
@@ -116,6 +125,33 @@ function hasVisibleContent(line: string): boolean {
|
|
|
116
125
|
return stripAnsi(line).trim().length > 0;
|
|
117
126
|
}
|
|
118
127
|
|
|
128
|
+
// State-transition fingerprint used by both the updateDisplay version bump
|
|
129
|
+
// and the settled-row render cache. It deliberately covers only shape
|
|
130
|
+
// transitions (partial/expanded/result/error): while a row is partial its
|
|
131
|
+
// content streams and it is never cached, and once settled the rendered
|
|
132
|
+
// content is fully determined by this fingerprint + width + theme.
|
|
133
|
+
function rowSignatureOf(row: ToolExecutionRow): string {
|
|
134
|
+
return `${row.isPartial}|${row.expanded}|${row.result ? 1 : 0}|${row.result?.isError ? 1 : 0}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Theme samples are five theme.fg calls; computing them per row per frame
|
|
138
|
+
// would reintroduce the per-keystroke cost the render caches remove, so
|
|
139
|
+
// cache by theme object identity (a theme switch swaps the object).
|
|
140
|
+
const themeSamples = new WeakMap<ThemeLike, string>();
|
|
141
|
+
function themeSampleFor(theme: ThemeLike): string {
|
|
142
|
+
let sample = themeSamples.get(theme);
|
|
143
|
+
if (!sample) {
|
|
144
|
+
sample =
|
|
145
|
+
theme.fg("toolTitle", "x") +
|
|
146
|
+
theme.fg("muted", "x") +
|
|
147
|
+
theme.fg("dim", "x") +
|
|
148
|
+
theme.fg("warning", "x") +
|
|
149
|
+
theme.fg("error", "x");
|
|
150
|
+
themeSamples.set(theme, sample);
|
|
151
|
+
}
|
|
152
|
+
return sample;
|
|
153
|
+
}
|
|
154
|
+
|
|
119
155
|
type InsetLayout = {
|
|
120
156
|
contentWidth: number;
|
|
121
157
|
left: number;
|
|
@@ -756,9 +792,10 @@ function styledCallLabel(
|
|
|
756
792
|
const plain = sanitizeInline(stripAnsi(label)).trim();
|
|
757
793
|
const match = /^(\S+)(.*)$/s.exec(plain);
|
|
758
794
|
if (!match) return theme.fg(color, plain);
|
|
795
|
+
const rest = match[2] ?? "";
|
|
759
796
|
return (
|
|
760
797
|
theme.fg(color, theme.bold(match[1] ?? "")) +
|
|
761
|
-
theme.fg(color,
|
|
798
|
+
(rest ? theme.fg(color, `:${rest}`) : "")
|
|
762
799
|
);
|
|
763
800
|
}
|
|
764
801
|
|
|
@@ -805,9 +842,9 @@ function collapsedHeadline(
|
|
|
805
842
|
theme: ThemeLike,
|
|
806
843
|
): string {
|
|
807
844
|
// Failed rows render entirely in error so the row reads as the one that
|
|
808
|
-
// failed, not just its outcome tail.
|
|
845
|
+
// failed, not just its outcome tail. Only the tool name is bold.
|
|
809
846
|
const color = rowHasFailed(row) ? "error" : "muted";
|
|
810
|
-
const marker = `${theme.fg(color,
|
|
847
|
+
const marker = `${theme.fg(color, GROUP_MARKER)} `;
|
|
811
848
|
const budget = Math.max(1, width - visibleWidth(marker));
|
|
812
849
|
const label = collapsedCallLabel(row, budget, theme, color);
|
|
813
850
|
const outcome = collapsedOutcome(row, budget, theme);
|
|
@@ -1069,73 +1106,15 @@ function renderCollapsedToolRow(
|
|
|
1069
1106
|
return ["", ...insetLines(body, width)];
|
|
1070
1107
|
}
|
|
1071
1108
|
|
|
1072
|
-
function compactBulletLine(
|
|
1073
|
-
summary: string,
|
|
1074
|
-
outcome: string | undefined,
|
|
1075
|
-
width: number,
|
|
1076
|
-
theme: ThemeLike,
|
|
1077
|
-
color = "muted",
|
|
1078
|
-
indentSpaces = 2,
|
|
1079
|
-
): string {
|
|
1080
|
-
const prefix = `${" ".repeat(indentSpaces)}${theme.fg(color, "•")} `;
|
|
1081
|
-
const indent = visibleWidth(prefix);
|
|
1082
|
-
if (width <= indent) return truncateToWidth(prefix, width, "", false);
|
|
1083
|
-
const available = width - indent;
|
|
1084
|
-
return (
|
|
1085
|
-
prefix +
|
|
1086
|
-
(outcome
|
|
1087
|
-
? fitSummaryTail(summary, outcome, available, theme.fg(color, "…"))
|
|
1088
|
-
: fitSummary(summary, available, theme.fg(color, "…")))
|
|
1089
|
-
);
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
1109
|
function groupedCallComponent(
|
|
1093
1110
|
rows: ToolExecutionRow[],
|
|
1094
1111
|
theme: ThemeLike,
|
|
1095
1112
|
): ComponentLike {
|
|
1096
1113
|
return {
|
|
1097
1114
|
render(width: number): string[] {
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
|
|
1101
|
-
const lines: string[] = [
|
|
1102
|
-
truncateToWidth(
|
|
1103
|
-
theme.fg("muted", theme.bold(GROUP_MARKER)),
|
|
1104
|
-
width,
|
|
1105
|
-
"",
|
|
1106
|
-
false,
|
|
1107
|
-
),
|
|
1108
|
-
];
|
|
1109
|
-
let previousToolName: string | undefined;
|
|
1110
|
-
for (const row of rows) {
|
|
1111
|
-
if (row.toolName !== previousToolName) {
|
|
1112
|
-
const token =
|
|
1113
|
-
row.toolName === "bash" ? "$" : (row.toolName ?? "tool");
|
|
1114
|
-
lines.push(
|
|
1115
|
-
truncateToWidth(
|
|
1116
|
-
` ${theme.fg("muted", theme.bold(token))}`,
|
|
1117
|
-
width,
|
|
1118
|
-
"",
|
|
1119
|
-
false,
|
|
1120
|
-
),
|
|
1121
|
-
);
|
|
1122
|
-
previousToolName = row.toolName;
|
|
1123
|
-
}
|
|
1124
|
-
const color = rowHasFailed(row) ? "error" : "muted";
|
|
1125
|
-
const call = renderedCallSummary(row, Math.max(1, width - 6), theme);
|
|
1126
|
-
const outcome = renderedGroupedOutcome(row, theme);
|
|
1127
|
-
lines.push(
|
|
1128
|
-
compactBulletLine(
|
|
1129
|
-
theme.fg(color, stripAnsi(call)),
|
|
1130
|
-
outcome,
|
|
1131
|
-
width,
|
|
1132
|
-
theme,
|
|
1133
|
-
color,
|
|
1134
|
-
4,
|
|
1135
|
-
),
|
|
1136
|
-
);
|
|
1137
|
-
}
|
|
1138
|
-
return lines;
|
|
1115
|
+
// Every member renders like a singleton — `% tool: call → outcome` per
|
|
1116
|
+
// line with the tool name bolded — no bullets or internal blanks.
|
|
1117
|
+
return rows.map((row) => collapsedHeadline(row, width, theme));
|
|
1139
1118
|
},
|
|
1140
1119
|
invalidate() {},
|
|
1141
1120
|
};
|
|
@@ -1172,12 +1151,7 @@ function renderGroupedToolRows(
|
|
|
1172
1151
|
): string[] {
|
|
1173
1152
|
const theme = state.theme;
|
|
1174
1153
|
if (!theme) return state.originalRender.call(row, width);
|
|
1175
|
-
const themeSample =
|
|
1176
|
-
theme.fg("toolTitle", "x") +
|
|
1177
|
-
theme.fg("muted", "x") +
|
|
1178
|
-
theme.fg("dim", "x") +
|
|
1179
|
-
theme.fg("warning", "x") +
|
|
1180
|
-
theme.fg("error", "x");
|
|
1154
|
+
const themeSample = themeSampleFor(theme);
|
|
1181
1155
|
const cached = state.groupCache.get(row);
|
|
1182
1156
|
const hasLiveMembers = rows.some(isLiveRow);
|
|
1183
1157
|
if (
|
|
@@ -1387,6 +1361,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1387
1361
|
const state: PresentationPatchState = {
|
|
1388
1362
|
collapseParallel: envEnabled(COLLAPSE_PARALLEL_ENV, true),
|
|
1389
1363
|
groupCache: new WeakMap(),
|
|
1364
|
+
collapsedCache: new WeakMap(),
|
|
1390
1365
|
rowVersions: new WeakMap(),
|
|
1391
1366
|
rowGroups: new WeakMap(),
|
|
1392
1367
|
rowSignatures: new WeakMap(),
|
|
@@ -1399,7 +1374,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1399
1374
|
// Group members always bump so their leader's cache refreshes; other
|
|
1400
1375
|
// rows only bump on state transitions, so bash's per-second invalidate
|
|
1401
1376
|
// ticks and resize invalidations stop busting caches.
|
|
1402
|
-
const signature =
|
|
1377
|
+
const signature = rowSignatureOf(this);
|
|
1403
1378
|
if (
|
|
1404
1379
|
state.rowGroups.has(this) ||
|
|
1405
1380
|
state.rowSignatures.get(this) !== signature
|
|
@@ -1414,18 +1389,72 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1414
1389
|
this: ToolExecutionRow,
|
|
1415
1390
|
width: number,
|
|
1416
1391
|
): string[] {
|
|
1417
|
-
const
|
|
1392
|
+
const theme = state.theme;
|
|
1418
1393
|
if (
|
|
1419
1394
|
typeof this.expanded !== "boolean" ||
|
|
1420
1395
|
typeof this.isPartial !== "boolean" ||
|
|
1421
1396
|
this.expanded ||
|
|
1422
|
-
!
|
|
1423
|
-
(lines.length === 0 && (this.imageComponents?.length ?? 0) === 0)
|
|
1397
|
+
!theme
|
|
1424
1398
|
) {
|
|
1399
|
+
return state.originalRender.call(this, width);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// ================================================================
|
|
1403
|
+
// Settled-row render cache — PLEASE DO NOT REMOVE THIS FAST PATH.
|
|
1404
|
+
// ================================================================
|
|
1405
|
+
// Pi's TUI re-renders the whole transcript on every keystroke, and
|
|
1406
|
+
// the collapsed-row decoration below rebuilds strings for every row
|
|
1407
|
+
// it touches. On a long session (~1,000 messages, hundreds of tool
|
|
1408
|
+
// rows) that measured at ~80 ms per keystroke — visible input lag.
|
|
1409
|
+
//
|
|
1410
|
+
// Settled rows (not partial, not expanded) have fully static render
|
|
1411
|
+
// output, so their collapsed lines are cached keyed by width +
|
|
1412
|
+
// rowSignatureOf + rowVersions + theme sample. updateDisplay bumps
|
|
1413
|
+
// rowVersions on every real state transition, and partial rows
|
|
1414
|
+
// bypass the cache entirely because their content streams. This
|
|
1415
|
+
// mirrors the existing groupCache discipline; keep them in sync.
|
|
1416
|
+
// ================================================================
|
|
1417
|
+
if (!this.isPartial) {
|
|
1418
|
+
const signature = rowSignatureOf(this);
|
|
1419
|
+
const version = state.rowVersions.get(this) ?? 0;
|
|
1420
|
+
const themeSample = themeSampleFor(theme);
|
|
1421
|
+
const cached = state.collapsedCache.get(this);
|
|
1422
|
+
if (
|
|
1423
|
+
cached &&
|
|
1424
|
+
cached.width === width &&
|
|
1425
|
+
cached.signature === signature &&
|
|
1426
|
+
cached.version === version &&
|
|
1427
|
+
cached.themeSample === themeSample
|
|
1428
|
+
) {
|
|
1429
|
+
return cached.lines;
|
|
1430
|
+
}
|
|
1431
|
+
const lines = state.originalRender.call(this, width);
|
|
1432
|
+
if (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0) {
|
|
1433
|
+
return lines;
|
|
1434
|
+
}
|
|
1435
|
+
try {
|
|
1436
|
+
const collapsed = renderCollapsedToolRow(this, width, theme);
|
|
1437
|
+
state.collapsedCache.set(this, {
|
|
1438
|
+
lines: collapsed,
|
|
1439
|
+
signature,
|
|
1440
|
+
version,
|
|
1441
|
+
themeSample,
|
|
1442
|
+
width,
|
|
1443
|
+
});
|
|
1444
|
+
return collapsed;
|
|
1445
|
+
} catch {
|
|
1446
|
+
return lines;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// Partial (streaming) rows keep the collapsed presentation but are
|
|
1451
|
+
// never cached: their content changes with every streamed chunk.
|
|
1452
|
+
const lines = state.originalRender.call(this, width);
|
|
1453
|
+
if (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0) {
|
|
1425
1454
|
return lines;
|
|
1426
1455
|
}
|
|
1427
1456
|
try {
|
|
1428
|
-
return renderCollapsedToolRow(this, width,
|
|
1457
|
+
return renderCollapsedToolRow(this, width, theme);
|
|
1429
1458
|
} catch {
|
|
1430
1459
|
return lines;
|
|
1431
1460
|
}
|
|
@@ -5,6 +5,176 @@ const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v1");
|
|
|
5
5
|
const PI_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
6
6
|
const SPINNER_INTERVAL_MS = 80;
|
|
7
7
|
|
|
8
|
+
// Visual experiment for the settled "+ Thought" label. "inherit" keeps Pi's
|
|
9
|
+
// native styling (italic + thinkingText); "gray" sits halfway between the
|
|
10
|
+
// muted and text theme colors. The env var overrides the default so the
|
|
11
|
+
// variants can be compared without editing code or republishing.
|
|
12
|
+
const THOUGHT_LABEL_COLOR_ENV = "PI_TOOL_CALL_MARKERS_THOUGHT_COLOR";
|
|
13
|
+
type ThoughtLabelColor = "inherit" | "orange" | "gray";
|
|
14
|
+
const DEFAULT_THOUGHT_LABEL_COLOR: ThoughtLabelColor = "orange";
|
|
15
|
+
// cobalt2's orange token (#ffb86c).
|
|
16
|
+
const THOUGHT_LABEL_ORANGE = { r: 255, g: 184, b: 108 };
|
|
17
|
+
|
|
18
|
+
function thoughtLabelColorChoice(): ThoughtLabelColor {
|
|
19
|
+
const raw = process.env[THOUGHT_LABEL_COLOR_ENV];
|
|
20
|
+
return raw === "inherit" || raw === "orange" || raw === "gray"
|
|
21
|
+
? raw
|
|
22
|
+
: DEFAULT_THOUGHT_LABEL_COLOR;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
|
|
26
|
+
const BASIC_ANSI_RGB: Array<[number, string] | undefined> = [
|
|
27
|
+
[0, "#000000"],
|
|
28
|
+
[1, "#800000"],
|
|
29
|
+
[2, "#008000"],
|
|
30
|
+
[3, "#808000"],
|
|
31
|
+
[4, "#000080"],
|
|
32
|
+
[5, "#800080"],
|
|
33
|
+
[6, "#008080"],
|
|
34
|
+
[7, "#c0c0c0"],
|
|
35
|
+
[8, "#808080"],
|
|
36
|
+
[9, "#ff0000"],
|
|
37
|
+
[10, "#00ff00"],
|
|
38
|
+
[11, "#ffff00"],
|
|
39
|
+
[12, "#0000ff"],
|
|
40
|
+
[13, "#ff00ff"],
|
|
41
|
+
[14, "#00ffff"],
|
|
42
|
+
[15, "#ffffff"],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
type Rgb = { r: number; g: number; b: number };
|
|
46
|
+
|
|
47
|
+
type ThemeDetail = {
|
|
48
|
+
fg(color: string, text: string): string;
|
|
49
|
+
getFgAnsi?(color: string): string;
|
|
50
|
+
getColorMode?(): "truecolor" | "256color" | string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
let activeTheme: ThemeDetail | undefined;
|
|
54
|
+
|
|
55
|
+
function hexToRgb(hex: string): Rgb | undefined {
|
|
56
|
+
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
|
57
|
+
if (!match) return undefined;
|
|
58
|
+
const value = parseInt(match[1]!, 16);
|
|
59
|
+
return { r: value >> 16, g: (value >> 8) & 255, b: value & 255 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Mirrors Pi's own mapping so a named color can round-trip through a
|
|
63
|
+
// 256-color terminal (e.g. when COLORFGBG forces 256color mode).
|
|
64
|
+
function ansi256ToRgb(index: number): Rgb | undefined {
|
|
65
|
+
if (index < 16) {
|
|
66
|
+
const hex = BASIC_ANSI_RGB[index]?.[1];
|
|
67
|
+
return hex ? hexToRgb(hex) : undefined;
|
|
68
|
+
}
|
|
69
|
+
if (index < 232) {
|
|
70
|
+
const cube = index - 16;
|
|
71
|
+
const channel = (n: number) => (n === 0 ? 0 : 55 + n * 40);
|
|
72
|
+
return {
|
|
73
|
+
r: channel(Math.floor(cube / 36)),
|
|
74
|
+
g: channel(Math.floor((cube % 36) / 6)),
|
|
75
|
+
b: channel(cube % 6),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const gray = 8 + (index - 232) * 10;
|
|
79
|
+
return { r: gray, g: gray, b: gray };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function rgbToAnsi256({ r, g, b }: Rgb): number {
|
|
83
|
+
const nearest = (value: number) =>
|
|
84
|
+
CUBE_VALUES.reduce(
|
|
85
|
+
(best, candidate) =>
|
|
86
|
+
Math.abs(candidate - value) < Math.abs(best - value) ? candidate : best,
|
|
87
|
+
0,
|
|
88
|
+
);
|
|
89
|
+
const cubeR = nearest(r);
|
|
90
|
+
const cubeG = nearest(g);
|
|
91
|
+
const cubeB = nearest(b);
|
|
92
|
+
const cubeDistance =
|
|
93
|
+
(r - cubeR) ** 2 * 0.299 +
|
|
94
|
+
(g - cubeG) ** 2 * 0.587 +
|
|
95
|
+
(b - cubeB) ** 2 * 0.114;
|
|
96
|
+
const luminance = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
|
|
97
|
+
const grayStep = Math.max(0, Math.min(23, Math.round((luminance - 8) / 10)));
|
|
98
|
+
const grayValue = 8 + grayStep * 10;
|
|
99
|
+
const grayDistance = (luminance - grayValue) ** 2;
|
|
100
|
+
const spread = Math.max(r, g, b) - Math.min(r, g, b);
|
|
101
|
+
if (spread < 10 && grayDistance < cubeDistance) return 232 + grayStep;
|
|
102
|
+
return (
|
|
103
|
+
16 +
|
|
104
|
+
36 * CUBE_VALUES.indexOf(cubeR) +
|
|
105
|
+
6 * CUBE_VALUES.indexOf(cubeG) +
|
|
106
|
+
CUBE_VALUES.indexOf(cubeB)
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseAnsiFgRgb(ansi: string): Rgb | undefined {
|
|
111
|
+
if (/^\x1b\[39m$/.test(ansi)) return undefined;
|
|
112
|
+
const truecolor = /^\x1b\[38;2;(\d+);(\d+);(\d+)m$/.exec(ansi);
|
|
113
|
+
if (truecolor) {
|
|
114
|
+
return {
|
|
115
|
+
r: Number(truecolor[1]),
|
|
116
|
+
g: Number(truecolor[2]),
|
|
117
|
+
b: Number(truecolor[3]),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const indexed = /^\x1b\[38;5;(\d+)m$/.exec(ansi);
|
|
121
|
+
if (indexed) return ansi256ToRgb(Number(indexed[1]));
|
|
122
|
+
const basic = /^\x1b\[(9[0-7]|3[0-7])m$/.exec(ansi);
|
|
123
|
+
if (basic) {
|
|
124
|
+
const code = Number(basic[1]);
|
|
125
|
+
return ansi256ToRgb(code >= 90 ? code - 90 + 8 : code - 30);
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function fgRgbAnsi({ r, g, b }: Rgb): string {
|
|
131
|
+
if (activeTheme?.getColorMode?.() === "256color") {
|
|
132
|
+
return `\x1b[38;5;${rgbToAnsi256({ r, g, b })}m`;
|
|
133
|
+
}
|
|
134
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function midpointRgb(a: Rgb, b: Rgb): Rgb {
|
|
138
|
+
return {
|
|
139
|
+
r: Math.round((a.r + b.r) / 2),
|
|
140
|
+
g: Math.round((a.g + b.g) / 2),
|
|
141
|
+
b: Math.round((a.b + b.b) / 2),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let thoughtLabelPrefix = "";
|
|
146
|
+
let thoughtLabelSuffix = "";
|
|
147
|
+
|
|
148
|
+
function updateThoughtLabelStyle(): void {
|
|
149
|
+
thoughtLabelPrefix = "";
|
|
150
|
+
thoughtLabelSuffix = "";
|
|
151
|
+
const choice = thoughtLabelColorChoice();
|
|
152
|
+
if (choice === "inherit" || !activeTheme) return;
|
|
153
|
+
|
|
154
|
+
let rgb: Rgb | undefined;
|
|
155
|
+
if (choice === "orange") {
|
|
156
|
+
rgb = THOUGHT_LABEL_ORANGE;
|
|
157
|
+
} else if (activeTheme.getFgAnsi) {
|
|
158
|
+
const muted = parseAnsiFgRgb(activeTheme.getFgAnsi("muted"));
|
|
159
|
+
const text = parseAnsiFgRgb(activeTheme.getFgAnsi("text"));
|
|
160
|
+
if (muted && text) rgb = midpointRgb(muted, text);
|
|
161
|
+
}
|
|
162
|
+
if (!rgb) return;
|
|
163
|
+
|
|
164
|
+
// The styled label replaces Pi's italicized Text node wholesale (see
|
|
165
|
+
// restyleHiddenThinkingLabel). The leading italic-off still matters: the
|
|
166
|
+
// TUI's diff renderer can skip bytes shared with the previously drawn
|
|
167
|
+
// italic line, leaving the terminal in italic state otherwise.
|
|
168
|
+
thoughtLabelPrefix = `\x1b[23m${fgRgbAnsi(rgb)}`;
|
|
169
|
+
thoughtLabelSuffix = "\x1b[39m";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function visibleThoughtLabel(label: string): string {
|
|
173
|
+
if (!thoughtLabelPrefix) return label;
|
|
174
|
+
const raw = label.replace(/\x1b\[[0-9;]*m/g, "");
|
|
175
|
+
return `${thoughtLabelPrefix}${raw}${thoughtLabelSuffix}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
8
178
|
type AssistantMessageLike = {
|
|
9
179
|
content?: unknown[];
|
|
10
180
|
};
|
|
@@ -12,9 +182,46 @@ type AssistantMessageLike = {
|
|
|
12
182
|
type AssistantMessageRow = {
|
|
13
183
|
hiddenThinkingLabel?: unknown;
|
|
14
184
|
hideThinkingBlock?: unknown;
|
|
185
|
+
contentContainer?: { children?: unknown[] };
|
|
15
186
|
updateContent(message: AssistantMessageLike, ...args: unknown[]): void;
|
|
16
187
|
};
|
|
17
188
|
|
|
189
|
+
type TextLikeChild = {
|
|
190
|
+
text?: unknown;
|
|
191
|
+
setText?(text: string): void;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Pi renders the hidden-thinking label as an italic Text node built from the
|
|
195
|
+
// plain label field. Embedding style codes in the field itself is not
|
|
196
|
+
// enough: the TUI diff renderer reuses the byte prefix shared with the
|
|
197
|
+
// previous (italic) frame, so an in-line italic reset may never reach the
|
|
198
|
+
// terminal. Swap the node's text for a self-contained styled version after
|
|
199
|
+
// each native render pass instead.
|
|
200
|
+
function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
|
|
201
|
+
if (!thoughtLabelPrefix) return;
|
|
202
|
+
if (
|
|
203
|
+
row.hideThinkingBlock !== true ||
|
|
204
|
+
typeof row.hiddenThinkingLabel !== "string"
|
|
205
|
+
) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const label = row.hiddenThinkingLabel;
|
|
209
|
+
const children = row.contentContainer?.children;
|
|
210
|
+
if (!Array.isArray(children)) return;
|
|
211
|
+
const styled = visibleThoughtLabel(label);
|
|
212
|
+
for (const child of children) {
|
|
213
|
+
const textChild = child as TextLikeChild | undefined;
|
|
214
|
+
if (
|
|
215
|
+
typeof textChild?.text !== "string" ||
|
|
216
|
+
typeof textChild.setText !== "function" ||
|
|
217
|
+
!textChild.text.includes(label)
|
|
218
|
+
) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
textChild.setText(styled);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
18
225
|
type ThinkingTiming = {
|
|
19
226
|
finishedAt?: number;
|
|
20
227
|
startedAt: number;
|
|
@@ -186,6 +393,11 @@ function installThinkingGroupingPatch():
|
|
|
186
393
|
// Preserve Pi's native label if its private row shape changes.
|
|
187
394
|
}
|
|
188
395
|
Reflect.apply(state.originalUpdateContent, this, [combined, ...args]);
|
|
396
|
+
try {
|
|
397
|
+
restyleHiddenThinkingLabel(this);
|
|
398
|
+
} catch {
|
|
399
|
+
// Keep Pi's native label styling if the row shape changes.
|
|
400
|
+
}
|
|
189
401
|
};
|
|
190
402
|
|
|
191
403
|
state.patchedUpdateContent = patchedUpdateContent;
|
|
@@ -224,5 +436,15 @@ function uninstallThinkingGroupingPatch(
|
|
|
224
436
|
|
|
225
437
|
export default function (pi: ExtensionAPI) {
|
|
226
438
|
const patch = installThinkingGroupingPatch();
|
|
227
|
-
pi.on("
|
|
439
|
+
pi.on("session_start", (_event, ctx) => {
|
|
440
|
+
if (ctx.mode !== "tui") return;
|
|
441
|
+
activeTheme = ctx.ui.theme as unknown as ThemeDetail;
|
|
442
|
+
updateThoughtLabelStyle();
|
|
443
|
+
});
|
|
444
|
+
pi.on("session_shutdown", () => {
|
|
445
|
+
activeTheme = undefined;
|
|
446
|
+
thoughtLabelPrefix = "";
|
|
447
|
+
thoughtLabelSuffix = "";
|
|
448
|
+
uninstallThinkingGroupingPatch(patch);
|
|
449
|
+
});
|
|
228
450
|
}
|