@pi-kaush/pi-tool-call-markers 0.2.7 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-tool-call-markers",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Compact and group Pi tool calls, with a bundled extension for adjacent thinking blocks.",
5
5
  "license": "MIT",
6
6
  "author": "Kaushik Gopal",
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;
@@ -1115,12 +1151,7 @@ function renderGroupedToolRows(
1115
1151
  ): string[] {
1116
1152
  const theme = state.theme;
1117
1153
  if (!theme) return state.originalRender.call(row, width);
1118
- const themeSample =
1119
- theme.fg("toolTitle", "x") +
1120
- theme.fg("muted", "x") +
1121
- theme.fg("dim", "x") +
1122
- theme.fg("warning", "x") +
1123
- theme.fg("error", "x");
1154
+ const themeSample = themeSampleFor(theme);
1124
1155
  const cached = state.groupCache.get(row);
1125
1156
  const hasLiveMembers = rows.some(isLiveRow);
1126
1157
  if (
@@ -1330,6 +1361,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1330
1361
  const state: PresentationPatchState = {
1331
1362
  collapseParallel: envEnabled(COLLAPSE_PARALLEL_ENV, true),
1332
1363
  groupCache: new WeakMap(),
1364
+ collapsedCache: new WeakMap(),
1333
1365
  rowVersions: new WeakMap(),
1334
1366
  rowGroups: new WeakMap(),
1335
1367
  rowSignatures: new WeakMap(),
@@ -1342,7 +1374,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1342
1374
  // Group members always bump so their leader's cache refreshes; other
1343
1375
  // rows only bump on state transitions, so bash's per-second invalidate
1344
1376
  // ticks and resize invalidations stop busting caches.
1345
- const signature = `${this.isPartial}|${this.expanded}|${this.result ? 1 : 0}|${this.result?.isError ? 1 : 0}`;
1377
+ const signature = rowSignatureOf(this);
1346
1378
  if (
1347
1379
  state.rowGroups.has(this) ||
1348
1380
  state.rowSignatures.get(this) !== signature
@@ -1357,18 +1389,72 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1357
1389
  this: ToolExecutionRow,
1358
1390
  width: number,
1359
1391
  ): string[] {
1360
- const lines = state.originalRender.call(this, width);
1392
+ const theme = state.theme;
1361
1393
  if (
1362
1394
  typeof this.expanded !== "boolean" ||
1363
1395
  typeof this.isPartial !== "boolean" ||
1364
1396
  this.expanded ||
1365
- !state.theme ||
1366
- (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0)
1397
+ !theme
1367
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) {
1368
1454
  return lines;
1369
1455
  }
1370
1456
  try {
1371
- return renderCollapsedToolRow(this, width, state.theme);
1457
+ return renderCollapsedToolRow(this, width, theme);
1372
1458
  } catch {
1373
1459
  return lines;
1374
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("session_shutdown", () => uninstallThinkingGroupingPatch(patch));
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
  }