pi-claude-code-ui 1.0.84 → 1.0.86

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 CHANGED
@@ -3,6 +3,12 @@
3
3
  > [!IMPORTANT]
4
4
  > **1.0.69 — package rename (permanent).** Canonical npm name is now [`pi-claude-code-ui`](https://www.npmjs.com/package/pi-claude-code-ui). `pi-claude-style-tools` is legacy and will not receive further releases. Install with `pi install npm:pi-claude-code-ui` or `npm i pi-claude-code-ui`.
5
5
 
6
+ ## 1.0.86 — 2026-10-08
7
+
8
+ ### Fixed
9
+
10
+ - **Display-math scanning no longer splits fenced blocks on `$$` and `\[ … \]`** (Perry Trolard, [#33](https://github.com/FammasMaz/pi-cc-tools/issues/33)) - paragraph rebuilding now respects the same Markdown code regions as inline math formatting, so `$$` (shell PID) and `\[ … \]` (grep/ripgrep char-class regexes) inside ```` ``` ```` fences and inline code stay literal instead of cutting the block into a math segment.
11
+
6
12
  ## 1.0.83 — 2026-09-07
7
13
 
8
14
  ### Fixed
@@ -39,6 +45,16 @@
39
45
 
40
46
  - **Hide transient context tags** — hide transient context tags in streaming prose.
41
47
 
48
+ ## 1.0.85 — 2026-09-07
49
+
50
+ ### Fixed
51
+
52
+ - Preserve Markdown code literals while formatting inline mathematics.
53
+ - Keep assistant presentation state out of canonical session messages so compacted sessions can replay safely.
54
+ - Respect `bashOutputMode` for completed Bash output.
55
+ - Preserve native prompt metadata in wrapped core tools.
56
+ - Harden spinner cleanup when Pi replaces a stale UI context.
57
+
42
58
  ## Unreleased
43
59
 
44
60
  ### Fixed
package/README.md CHANGED
@@ -37,7 +37,7 @@ Claude Code inspired tool rendering for Pi — Shiki-powered diffs, status dots,
37
37
 
38
38
  ## Configuration
39
39
 
40
- Set in `.pi/settings.json` or `~/.pi/settings.json`:
40
+ Set in `.pi/settings.json` or the active Pi agent settings file (`$PI_CODING_AGENT_DIR/settings.json`, defaulting to `~/.pi/settings.json`):
41
41
 
42
42
  ```json
43
43
  {
@@ -93,7 +93,7 @@ On `/resume`, `/new`, or `/fork`, tool chrome is rebound from the **current** pi
93
93
  /cc-theme toggle # flip the current value
94
94
  ```
95
95
 
96
- The selection is persisted to `~/.pi/settings.json` and applied to the next rendered tool row. No restart required.
96
+ The selection is persisted to the active Pi agent settings file and applied to the next rendered tool row. No restart required.
97
97
 
98
98
  #### Repaint the spinner with `/cc-spinner`
99
99
 
@@ -106,7 +106,7 @@ The spinner glyph itself is still colored by pi's loader using `accent`, while t
106
106
  /cc-spinner reset # restore defaults (verb=borderAccent, status=muted)
107
107
  ```
108
108
 
109
- The selection is persisted as `spinnerVerbColor` / `spinnerStatusColor` in `~/.pi/settings.json` and applied on the next spinner tick.
109
+ The selection is persisted as `spinnerVerbColor` / `spinnerStatusColor` in the active Pi agent settings file and applied on the next spinner tick.
110
110
 
111
111
  ### Tool background modes
112
112
 
@@ -70,6 +70,7 @@ import {
70
70
  formatBashDuration,
71
71
  getLastBashOutputLine,
72
72
  } from "./bash-command";
73
+ import { getUserSettingsPath } from "./settings-path";
73
74
 
74
75
  const RESET = "\x1b[0m";
75
76
  const TRANSPARENT_BG = "\x1b[49m";
@@ -167,7 +168,7 @@ function readSettings(): SettingsFile {
167
168
  return _settingsCache.value;
168
169
  }
169
170
  const cwdPath = `${process.cwd()}/.pi/settings.json`;
170
- const homePath = `${process.env.HOME ?? ""}/.pi/settings.json`;
171
+ const homePath = getUserSettingsPath();
171
172
  const merged: SettingsFile = {};
172
173
  for (const path of [cwdPath, homePath]) {
173
174
  try {
@@ -195,9 +196,9 @@ function bustSpinnerSettingsCache(): void {
195
196
  function writeSettingsKey(key: string, value: unknown): void {
196
197
  _settingsCache = null; // invalidate cache on write
197
198
  const home = process.env.HOME ?? "";
198
- if (!home) return;
199
- const dir = `${home}/.pi`;
200
- const path = `${dir}/settings.json`;
199
+ if (!home && !process.env.PI_CODING_AGENT_DIR) return;
200
+ const path = getUserSettingsPath();
201
+ const dir = dirname(path);
201
202
  let settings: Record<string, unknown> = {};
202
203
  try {
203
204
  if (existsSync(path)) settings = JSON.parse(readFileSync(path, "utf8")) ?? {};
@@ -526,8 +527,8 @@ function getThinkingMode(): ThinkingMode {
526
527
 
527
528
  function isAssistantThinkingComplete(comp: any, message: any): boolean {
528
529
  if (!message || message.role !== "assistant") return false;
529
- if (typeof message[THINKING_DURATION_KEY] === "number") return true;
530
- if (message[THINKING_ACTIVE_KEY]) return false;
530
+ if (getThinkingDurationMetadata(message) !== undefined) return true;
531
+ if (hasThinkingActiveMetadata(message)) return false;
531
532
  // Providers keep stopReason "pending" for the whole stream ("deferred" while
532
533
  // a deferred call is unresolved); both are in-flight sentinels, never
533
534
  // completion signals. Without this, live-thinking detection would depend on
@@ -554,7 +555,7 @@ function isAssistantThinkingComplete(comp: any, message: any): boolean {
554
555
  function isLiveThinkingMessage(comp: any, message: any): boolean {
555
556
  if (!message || message.role !== "assistant") return false;
556
557
  if (isAssistantThinkingComplete(comp, message)) return false;
557
- if ((message as any)[THINKING_ACTIVE_KEY]) return true;
558
+ if (hasThinkingActiveMetadata(message)) return true;
558
559
  if (Array.isArray(message.content)) {
559
560
  return message.content.some((b: any) => b?.type === "thinking" && typeof b?.thinking === "string" && b.thinking.trim());
560
561
  }
@@ -1199,7 +1200,7 @@ function isThinkingOnlyAssistantComponent(comp: unknown): comp is InstanceType<t
1199
1200
  if (hasText) return false;
1200
1201
  const hasToolCalls = msg.content.some((c: any) => c?.type === "toolCall");
1201
1202
  if (hasToolCalls) return false;
1202
- if ((comp as any).isStreaming === true || msg[THINKING_ACTIVE_KEY]) return false;
1203
+ if ((comp as any).isStreaming === true || hasThinkingActiveMetadata(msg)) return false;
1203
1204
  return true;
1204
1205
  }
1205
1206
 
@@ -1227,10 +1228,13 @@ function maybeMergeConsecutiveThinkingMessages(parent: any): void {
1227
1228
  const mergedDuration = durA + durB;
1228
1229
 
1229
1230
  const nextThinkingBlocks = nextMsg.content.filter((c: any) => c?.type === "thinking");
1230
- curMsg.content.push(...nextThinkingBlocks);
1231
- curMsg[THINKING_DURATION_KEY] = mergedDuration;
1231
+ const mergedMessage = {
1232
+ ...curMsg,
1233
+ content: [...curMsg.content, ...nextThinkingBlocks],
1234
+ [THINKING_DURATION_KEY]: mergedDuration,
1235
+ };
1232
1236
 
1233
- (current as any).updateContent(curMsg);
1237
+ (current as any).updateContent(mergedMessage);
1234
1238
 
1235
1239
  const removeCount = nextIdx - i;
1236
1240
  children.splice(i + 1, removeCount);
@@ -1614,6 +1618,46 @@ const THINKING_DURATION_KEY = "_piClaudeStyleThinkingDurationMs";
1614
1618
  const THINKING_ACTIVE_KEY = "_piClaudeStyleThinkingActive";
1615
1619
  const MAX_REASONABLE_THINKING_DURATION_MS = 24 * 60 * 60 * 1000;
1616
1620
 
1621
+ // Presentation metadata belongs to the renderer, not the canonical session
1622
+ // messages. Weak collections keep live state available to the TUI without
1623
+ // changing the objects Pi persists or projects for compaction replay.
1624
+ const thinkingDurationByMessage = new WeakMap<object, number>();
1625
+ const thinkingActiveMessages = new WeakSet<object>();
1626
+ const workedStartByMessage = new WeakMap<object, number>();
1627
+ const workedDurationByMessage = new WeakMap<object, { duration: number; sessionTotal?: number; turns?: number }>();
1628
+
1629
+ function getThinkingDurationMetadata(message: any): number | undefined {
1630
+ if (!message || typeof message !== "object") return undefined;
1631
+ const stored = thinkingDurationByMessage.get(message) ?? message[THINKING_DURATION_KEY];
1632
+ return typeof stored === "number"
1633
+ && Number.isFinite(stored)
1634
+ && stored > 0
1635
+ && stored <= MAX_REASONABLE_THINKING_DURATION_MS
1636
+ ? stored
1637
+ : undefined;
1638
+ }
1639
+
1640
+ function hasThinkingActiveMetadata(message: any): boolean {
1641
+ return !!message
1642
+ && typeof message === "object"
1643
+ && (thinkingActiveMessages.has(message) || message[THINKING_ACTIVE_KEY] === true);
1644
+ }
1645
+
1646
+ function workedMetadataForMessage(message: any): { duration: number; sessionTotal?: number; turns?: number } | undefined {
1647
+ if (!message || typeof message !== "object") return undefined;
1648
+ const metadata = workedDurationByMessage.get(message);
1649
+ if (metadata) return metadata;
1650
+ const duration = message[WORKED_DURATION_KEY];
1651
+ if (typeof duration !== "number" || !Number.isFinite(duration) || duration < 0) return undefined;
1652
+ const sessionTotal = message[WORKED_SESSION_TOTAL_KEY];
1653
+ const turns = message[WORKED_TURNS_KEY];
1654
+ return {
1655
+ duration,
1656
+ sessionTotal: typeof sessionTotal === "number" ? sessionTotal : undefined,
1657
+ turns: typeof turns === "number" ? turns : undefined,
1658
+ };
1659
+ }
1660
+
1617
1661
  let thinkingBlockStartMs = 0;
1618
1662
  /** True from thinking_start until thinking_end on the current assistant stream. */
1619
1663
  let thinkingBlockInFlight = false;
@@ -1757,9 +1801,8 @@ class HiddenThinkingSummary {
1757
1801
  }
1758
1802
 
1759
1803
  function getMessageThinkingDurationMs(message: any): number {
1760
- const stored = (message as any)?.[THINKING_DURATION_KEY];
1761
- if (typeof stored === "number" && Number.isFinite(stored) && stored > 0 && stored <= MAX_REASONABLE_THINKING_DURATION_MS) return stored;
1762
- if (message && typeof message === "object" && stored !== undefined) delete (message as any)[THINKING_DURATION_KEY];
1804
+ const stored = getThinkingDurationMetadata(message);
1805
+ if (stored !== undefined) return stored;
1763
1806
  let totalChars = 0;
1764
1807
  if (Array.isArray(message?.content)) {
1765
1808
  for (const block of message.content) {
@@ -1780,9 +1823,6 @@ function hiddenThinkingSummaryForMessage(message: any, comp?: any): string {
1780
1823
  return thinkingActiveSummaryText();
1781
1824
  }
1782
1825
  const durationMs = getMessageThinkingDurationMs(message);
1783
- if (message && typeof message === "object") {
1784
- (message as any)[THINKING_DURATION_KEY] = durationMs;
1785
- }
1786
1826
  return thoughtDurationSummaryText(durationMs);
1787
1827
  }
1788
1828
 
@@ -1961,15 +2001,45 @@ function patchTerminalWriteTagScrubber(): void {
1961
2001
  proto[TERMINAL_SCRUB_PATCH_FLAG] = true;
1962
2002
  }
1963
2003
 
2004
+ // Code spans and fenced blocks are literal Markdown source. Math preprocessing
2005
+ // must never touch delimiters inside them: `$$` is the shell PID and `\[ … \]
2006
+ // appears in grep/ripgrep class regexes. Inline formatting splits on this
2007
+ // expression before transforming; display-math block scanning below reuses it
2008
+ // so a math candidate opening inside code is skipped in favor of real prose.
2009
+ const MARKDOWN_CODE_REGION_RE = /(`{3,}[\s\S]*?`{3,}|`[^`\n]*`)/g;
2010
+
2011
+ interface CodeRegion {
2012
+ start: number;
2013
+ end: number;
2014
+ }
2015
+
2016
+ function findMarkdownCodeRegions(text: string): CodeRegion[] {
2017
+ const regions: CodeRegion[] = [];
2018
+ MARKDOWN_CODE_REGION_RE.lastIndex = 0;
2019
+ let match: RegExpExecArray | null;
2020
+ while ((match = MARKDOWN_CODE_REGION_RE.exec(text))) {
2021
+ regions.push({ start: match.index, end: match.index + match[0].length });
2022
+ }
2023
+ return regions;
2024
+ }
2025
+
2026
+ function codeRegionContaining(regions: CodeRegion[], index: number): CodeRegion | undefined {
2027
+ return regions.find((region) => index >= region.start && index < region.end);
2028
+ }
2029
+
1964
2030
  function replaceInlineMath(text: string): string {
1965
2031
  if (!hasInlineMathMarkers(text)) return text;
1966
- const withParens = text.replace(/\\\(([\s\S]*?)\\\)/g, (_match, body: string) => {
1967
- return codeSpan(formatMathForDisplay(body, false));
1968
- });
1969
- return withParens.replace(/(^|[^\\])\$([^\n$]{1,200})\$/g, (match, prefix: string, body: string) => {
1970
- if (!looksLikeInlineMath(body)) return match;
1971
- return `${prefix}${codeSpan(formatMathForDisplay(body, false))}`;
1972
- });
2032
+ const transform = (segment: string): string => {
2033
+ const withParens = segment.replace(/\\\(([\s\S]*?)\\\)/g, (_match, body: string) => {
2034
+ return codeSpan(formatMathForDisplay(body, false));
2035
+ });
2036
+ return withParens.replace(/(^|[^\\])\$([^\n$]{1,200})\$/g, (match, prefix: string, body: string) => {
2037
+ if (!looksLikeInlineMath(body)) return match;
2038
+ return `${prefix}${codeSpan(formatMathForDisplay(body, false))}`;
2039
+ });
2040
+ };
2041
+ const parts = text.split(MARKDOWN_CODE_REGION_RE);
2042
+ return parts.map((part, index) => index % 2 === 1 ? part : transform(part)).join("");
1973
2043
  }
1974
2044
 
1975
2045
  interface MathBlock {
@@ -2058,29 +2128,60 @@ function shouldFormatStandaloneMath(text: string): boolean {
2058
2128
  return !/[.!?]\s/.test(plain) && plain.length <= 240;
2059
2129
  }
2060
2130
 
2061
- function appendMarkdownSegment(segments: ParagraphSegment[], text: string, theme: MarkdownThemeLike): void {
2131
+ // Options Pi hands to the Markdown components it builds, including the markdown
2132
+ // transformers (Pi's own Mermaid renderer, extension transformers, ...). Paragraph
2133
+ // wrappers rebuild those components, so they have to forward the options.
2134
+ type MarkdownOptions = ConstructorParameters<typeof Markdown>[5]
2135
+
2136
+ function appendMarkdownSegment(
2137
+ segments: ParagraphSegment[],
2138
+ text: string,
2139
+ theme: MarkdownThemeLike,
2140
+ options?: MarkdownOptions,
2141
+ ): void {
2062
2142
  if (!text.trim()) return;
2063
2143
  const normalized = shouldFormatStandaloneMath(text) ? formatMathForDisplay(text, false) : replaceInlineMath(text);
2064
- segments.push({ kind: "markdown", md: new Markdown(normalized, 0, 0, theme) });
2144
+ segments.push({ kind: "markdown", md: new Markdown(normalized, 0, 0, theme, undefined, options) });
2065
2145
  }
2066
2146
 
2067
- function buildParagraphSegments(text: string, theme: MarkdownThemeLike): ParagraphSegment[] {
2147
+ function buildParagraphSegments(
2148
+ text: string,
2149
+ theme: MarkdownThemeLike,
2150
+ options?: MarkdownOptions,
2151
+ ): ParagraphSegment[] {
2068
2152
  const segments: ParagraphSegment[] = [];
2069
2153
  if (!hasDisplayMathMarkers(text)) {
2070
- appendMarkdownSegment(segments, text, theme);
2154
+ appendMarkdownSegment(segments, text, theme, options);
2071
2155
  return segments;
2072
2156
  }
2073
2157
  const scanLoose = shouldScanLooseBracketMath(text);
2158
+ // Code regions are computed once for the whole paragraph: a display-math
2159
+ // candidate must be rejected before its fence gets cut into separate
2160
+ // Markdown segments (issue #33, sibling of the inline fix in 2ccd158).
2161
+ const codeRegions = findMarkdownCodeRegions(text);
2162
+ // `cursor` marks the start of prose not yet emitted; `scanFrom` only advances
2163
+ // past rejected code regions so skipped candidates never erase prose.
2074
2164
  let cursor = 0;
2075
- while (cursor < text.length) {
2076
- const next = findNextDisplayMathBlock(text, cursor, scanLoose);
2165
+ let scanFrom = 0;
2166
+ while (scanFrom < text.length) {
2167
+ let next = findNextDisplayMathBlock(text, scanFrom, scanLoose);
2168
+ // Math delimiters found inside a fenced block or code span are literal
2169
+ // source, not math. Resume the scan after that code region so a later
2170
+ // genuine `$$ … $$` in prose is still honored.
2171
+ while (next) {
2172
+ const region = codeRegionContaining(codeRegions, next.index);
2173
+ if (!region) break;
2174
+ scanFrom = region.end;
2175
+ next = findNextDisplayMathBlock(text, scanFrom, scanLoose);
2176
+ }
2077
2177
  if (!next) break;
2078
- appendMarkdownSegment(segments, text.slice(cursor, next.index), theme);
2178
+ appendMarkdownSegment(segments, text.slice(cursor, next.index), theme, options);
2079
2179
  const raw = text.slice(next.contentStart, next.contentEnd).trim();
2080
2180
  if (raw) segments.push({ kind: "math", raw });
2081
2181
  cursor = next.endIndex;
2182
+ scanFrom = next.endIndex;
2082
2183
  }
2083
- appendMarkdownSegment(segments, text.slice(cursor), theme);
2184
+ appendMarkdownSegment(segments, text.slice(cursor), theme, options);
2084
2185
  return segments;
2085
2186
  }
2086
2187
 
@@ -2167,9 +2268,9 @@ class DottedParagraph {
2167
2268
  private cachedWidth?: number;
2168
2269
  private cachedLines?: string[];
2169
2270
 
2170
- constructor(text: string, markdownTheme: MarkdownThemeLike) {
2271
+ constructor(text: string, markdownTheme: MarkdownThemeLike, options?: MarkdownOptions) {
2171
2272
  this.markdownTheme = copySafeMarkdownTheme(markdownTheme);
2172
- this.segments = buildParagraphSegments(stripTransientMagicContextTags(text), this.markdownTheme);
2273
+ this.segments = buildParagraphSegments(stripTransientMagicContextTags(text), this.markdownTheme, options);
2173
2274
  }
2174
2275
 
2175
2276
  invalidate(): void {
@@ -2263,6 +2364,7 @@ class ThinkingParagraph {
2263
2364
  private cachedLines?: string[];
2264
2365
  private cachedHovered = false;
2265
2366
  private chromeEpoch = -1;
2367
+ private options?: MarkdownOptions;
2266
2368
 
2267
2369
  setHovered(hovered: boolean): void {
2268
2370
  if (this.hovered === hovered) return;
@@ -2274,8 +2376,12 @@ class ThinkingParagraph {
2274
2376
  text: string,
2275
2377
  _markdownTheme: ConstructorParameters<typeof Markdown>[3],
2276
2378
  _defaultTextStyle?: ConstructorParameters<typeof Markdown>[4],
2379
+ options?: MarkdownOptions,
2277
2380
  ) {
2278
- this.text = stripTransientMagicContextTags(text);
2381
+ // Prefix only the renderer's private copy. The assistant message itself is
2382
+ // canonical session data and must remain untouched for compaction replay.
2383
+ this.text = prefixThinkingLine(stripTransientMagicContextTags(text), undefined);
2384
+ this.options = options;
2279
2385
  }
2280
2386
 
2281
2387
  private thinkingMarkdown(): InstanceType<typeof Markdown> {
@@ -2303,7 +2409,7 @@ class ThinkingParagraph {
2303
2409
  italic: false,
2304
2410
  color: (s: string) => `${DIM_FG}${s}`,
2305
2411
  };
2306
- return new Markdown(this.text, 0, 0, plainTheme, plainStyle);
2412
+ return new Markdown(this.text, 0, 0, plainTheme, plainStyle, this.options);
2307
2413
  }
2308
2414
 
2309
2415
  invalidate(): void {
@@ -2667,17 +2773,18 @@ function patchAssistantMessages(): void {
2667
2773
  if (isMarkdownComponent(inner)) {
2668
2774
  const text = (inner as any).text;
2669
2775
  if (!text) continue;
2776
+ const options = (inner as any).options as MarkdownOptions;
2670
2777
  const isThinking = !!(inner as any).defaultTextStyle?.italic;
2671
2778
  if (isThinking) {
2672
2779
  const style = (inner as any).defaultTextStyle;
2673
- const replacement = new ThinkingParagraph(text, mdTheme, style);
2780
+ const replacement = new ThinkingParagraph(text, mdTheme, style, options);
2674
2781
  if ((child as any)?.child !== undefined) {
2675
2782
  (child as any).child = replacement;
2676
2783
  } else {
2677
2784
  container.children[i] = replacement;
2678
2785
  }
2679
2786
  } else {
2680
- const replacement = new DottedParagraph(text, mdTheme);
2787
+ const replacement = new DottedParagraph(text, mdTheme, options);
2681
2788
  if ((child as any)?.child !== undefined) {
2682
2789
  (child as any).child = replacement;
2683
2790
  } else {
@@ -2686,9 +2793,10 @@ function patchAssistantMessages(): void {
2686
2793
  }
2687
2794
  }
2688
2795
  }
2689
- const explicitDuration = (message as any)[WORKED_DURATION_KEY];
2690
- const explicitSessionTotal = (message as any)[WORKED_SESSION_TOTAL_KEY];
2691
- const explicitTurns = (message as any)[WORKED_TURNS_KEY];
2796
+ const workedMetadata = workedMetadataForMessage(message);
2797
+ const explicitDuration = workedMetadata?.duration;
2798
+ const explicitSessionTotal = workedMetadata?.sessionTotal;
2799
+ const explicitTurns = workedMetadata?.turns;
2692
2800
  // The "Turn took" line must only appear once the stream has truly closed.
2693
2801
  // `message.stopReason === "stop"` is not a safe "finished" signal here because
2694
2802
  // providers may initialize a live message with that value. The `message_end`
@@ -3775,6 +3883,10 @@ function bashCollapsedLimit(): number {
3775
3883
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 10;
3776
3884
  }
3777
3885
 
3886
+ function bashOutputMode(): "opencode" | "summary" | "preview" {
3887
+ return getMode(readSettings().bashOutputMode, ["opencode", "summary", "preview"] as const, "opencode");
3888
+ }
3889
+
3778
3890
  function bashCommandPreviewLimit(): number {
3779
3891
  const value = readSettings().bashCommandPreviewLines;
3780
3892
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 8;
@@ -5585,8 +5697,8 @@ function trackThinkingBlockEvents(event: any, ctx?: any): void {
5585
5697
  thinkingBlockInFlight = true;
5586
5698
  thinkingBlockStartMs = Date.now();
5587
5699
  if (message?.role === "assistant") {
5588
- (message as any)[THINKING_ACTIVE_KEY] = true;
5589
- delete (message as any)[THINKING_DURATION_KEY];
5700
+ thinkingActiveMessages.add(message);
5701
+ thinkingDurationByMessage.delete(message);
5590
5702
  }
5591
5703
  refreshThinkingChrome();
5592
5704
  return;
@@ -5596,9 +5708,9 @@ function trackThinkingBlockEvents(event: any, ctx?: any): void {
5596
5708
  const duration = thinkingBlockStartMs > 0
5597
5709
  ? Math.max(0, Date.now() - thinkingBlockStartMs)
5598
5710
  : undefined;
5599
- if (message?.role === "assistant") delete (message as any)[THINKING_ACTIVE_KEY];
5600
- if (typeof duration === "number" && duration <= MAX_REASONABLE_THINKING_DURATION_MS) {
5601
- if (message?.role === "assistant") (message as any)[THINKING_DURATION_KEY] = duration;
5711
+ if (message?.role === "assistant") thinkingActiveMessages.delete(message);
5712
+ if (typeof duration === "number" && duration <= MAX_REASONABLE_THINKING_DURATION_MS && message?.role === "assistant") {
5713
+ thinkingDurationByMessage.set(message, duration);
5602
5714
  }
5603
5715
  thinkingBlockStartMs = 0;
5604
5716
  refreshThinkingChrome();
@@ -5610,13 +5722,13 @@ function trackThinkingBlockEvents(event: any, ctx?: any): void {
5610
5722
  // run normally. Any non-thinking stream event on the same assistant message
5611
5723
  // means thinking is no longer the live activity: freeze the elapsed time into
5612
5724
  // a "Thought for Xs" duration so the row always resolves.
5613
- if ((message as any)?.[THINKING_ACTIVE_KEY] || thinkingBlockInFlight) {
5725
+ if (hasThinkingActiveMetadata(message) || thinkingBlockInFlight) {
5614
5726
  if (evt.type === "text_start" || evt.type === "text_delta" || evt.type === "toolcall_start" || evt.type === "toolcall_end") {
5615
5727
  thinkingBlockInFlight = false;
5616
5728
  const duration = thinkingBlockStartMs > 0 ? Math.max(0, Date.now() - thinkingBlockStartMs) : undefined;
5617
- if (message?.role === "assistant") delete (message as any)[THINKING_ACTIVE_KEY];
5618
- if (typeof duration === "number" && duration <= MAX_REASONABLE_THINKING_DURATION_MS) {
5619
- if (message?.role === "assistant") (message as any)[THINKING_DURATION_KEY] = duration;
5729
+ if (message?.role === "assistant") thinkingActiveMessages.delete(message);
5730
+ if (typeof duration === "number" && duration <= MAX_REASONABLE_THINKING_DURATION_MS && message?.role === "assistant") {
5731
+ thinkingDurationByMessage.set(message, duration);
5620
5732
  }
5621
5733
  thinkingBlockStartMs = 0;
5622
5734
  refreshThinkingChrome();
@@ -5631,11 +5743,9 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5631
5743
  if (theme) applyThemePaletteIfNeeded(theme);
5632
5744
  const message = event?.message;
5633
5745
  if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
5634
- for (const block of message.content) {
5635
- if (block && block.type === "thinking" && typeof block.thinking === "string") {
5636
- block.thinking = prefixThinkingLine(block.thinking, theme);
5637
- }
5638
- }
5746
+ // Thinking labels are applied by ThinkingParagraph while rendering. Never
5747
+ // rewrite the assistant message object: context/compaction consumers need
5748
+ // its canonical thinking content unchanged.
5639
5749
  };
5640
5750
  pi.on("before_agent_start", async () => {
5641
5751
  // Start once per top-level request. Steering/follow-up messages can be
@@ -5661,14 +5771,14 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5661
5771
  }
5662
5772
  if (message?.role === "assistant") {
5663
5773
  currentAssistantMessageStartMs = Date.now();
5664
- (message as any)[WORKED_START_KEY] = currentAssistantMessageStartMs;
5774
+ workedStartByMessage.set(message, currentAssistantMessageStartMs);
5665
5775
  // A new assistant message starts a fresh thinking lifecycle. Without
5666
5776
  // this, a missing thinking_end on the previous message leaves the
5667
5777
  // global in-flight flag set and the next message renders a stale
5668
5778
  // "Thinking..." row until its own thinking events arrive.
5669
5779
  thinkingBlockInFlight = false;
5670
5780
  thinkingBlockStartMs = 0;
5671
- delete (message as any)[THINKING_ACTIVE_KEY];
5781
+ thinkingActiveMessages.delete(message);
5672
5782
  }
5673
5783
  });
5674
5784
  pi.on("message_update", async (event, ctx) => {
@@ -5683,27 +5793,22 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5683
5793
  // into its "Thought for Xs" duration here so the row always resolves
5684
5794
  // at the end of the message instead of sticking into later calls.
5685
5795
  thinkingBlockInFlight = false;
5686
- delete (message as any)[THINKING_ACTIVE_KEY];
5687
- if (typeof (message as any)[THINKING_DURATION_KEY] !== "number" && thinkingBlockStartMs > 0) {
5796
+ thinkingActiveMessages.delete(message);
5797
+ if (getThinkingDurationMetadata(message) === undefined && thinkingBlockStartMs > 0) {
5688
5798
  const duration = Math.max(0, Date.now() - thinkingBlockStartMs);
5689
- if (duration <= MAX_REASONABLE_THINKING_DURATION_MS) {
5690
- (message as any)[THINKING_DURATION_KEY] = duration;
5691
- }
5799
+ if (duration <= MAX_REASONABLE_THINKING_DURATION_MS) thinkingDurationByMessage.set(message, duration);
5692
5800
  }
5693
5801
  thinkingBlockStartMs = 0;
5694
5802
  const started = typeof currentAgentWorkStartMs === "number"
5695
5803
  ? currentAgentWorkStartMs
5696
- : typeof (message as any)[WORKED_START_KEY] === "number"
5697
- ? (message as any)[WORKED_START_KEY]
5698
- : currentAssistantMessageStartMs;
5804
+ : workedStartByMessage.get(message)
5805
+ ?? (typeof (message as any)[WORKED_START_KEY] === "number" ? (message as any)[WORKED_START_KEY] : currentAssistantMessageStartMs);
5699
5806
  const isFinalAssistantMessage = message.stopReason === "stop";
5700
5807
  if (started !== undefined && isFinalAssistantMessage) {
5701
5808
  const durationMs = Date.now() - started;
5702
5809
  const sessionTotalMs = typeof sessionStartMs === "number" ? Date.now() - sessionStartMs : undefined;
5703
5810
  const turns = userTurnCount > 0 ? userTurnCount : undefined;
5704
- (message as any)[WORKED_DURATION_KEY] = durationMs;
5705
- if (typeof sessionTotalMs === "number") (message as any)[WORKED_SESSION_TOTAL_KEY] = sessionTotalMs;
5706
- if (typeof turns === "number") (message as any)[WORKED_TURNS_KEY] = turns;
5811
+ workedDurationByMessage.set(message, { duration: durationMs, sessionTotal: sessionTotalMs, turns });
5707
5812
  // Duration metadata drives the assistant component's TUI-only status line.
5708
5813
  // Message content stays presentation-neutral for persistence and consumers.
5709
5814
  }
@@ -5751,17 +5856,10 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5751
5856
  sessionStartMs = sessionStartMs === undefined ? earliest : Math.min(sessionStartMs, earliest);
5752
5857
  }
5753
5858
  if (userCount > userTurnCount) userTurnCount = userCount;
5754
- for (const msg of messages) {
5755
- if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
5756
- for (const block of msg.content) {
5757
- if (block && block.type === "thinking" && typeof block.thinking === "string") {
5758
- block.thinking = stripThinkingPresentationArtifacts(block.thinking);
5759
- }
5760
- if (block && block.type === "text" && typeof block.text === "string") {
5761
- block.text = stripWorkedDurationLine(block.text);
5762
- }
5763
- }
5764
- }
5859
+ // Do not normalize or strip message content here. The context event is also
5860
+ // consumed by compaction/replay integrations that compare these objects
5861
+ // against the persisted session projection. Presentation cleanup belongs in
5862
+ // the renderer and must not mutate canonical history.
5765
5863
  });
5766
5864
  }
5767
5865
 
@@ -7184,6 +7282,8 @@ export default function (pi: ExtensionAPI) {
7184
7282
  name: "read",
7185
7283
  label: "read",
7186
7284
  description: readTool.description,
7285
+ promptSnippet: (readTool as any).promptSnippet,
7286
+ promptGuidelines: (readTool as any).promptGuidelines,
7187
7287
  parameters: readTool.parameters,
7188
7288
  async execute(toolCallId, params, signal, onUpdate) {
7189
7289
  return readTool.execute(toolCallId, params, signal, onUpdate);
@@ -7239,6 +7339,8 @@ export default function (pi: ExtensionAPI) {
7239
7339
  name: "bash",
7240
7340
  label: "bash",
7241
7341
  description: bashTool.description,
7342
+ promptSnippet: (bashTool as any).promptSnippet,
7343
+ promptGuidelines: (bashTool as any).promptGuidelines,
7242
7344
  parameters: bashTool.parameters,
7243
7345
  async execute(toolCallId, params, signal, onUpdate) {
7244
7346
  return bashTool.execute(toolCallId, params, signal, onUpdate);
@@ -7296,9 +7398,18 @@ export default function (pi: ExtensionAPI) {
7296
7398
  let text = exitCode === null || exitCode === 0 ? theme.fg("success", "Done") : theme.fg("error", `Exit ${exitCode}`);
7297
7399
  text += theme.fg("muted", ` (${nonEmpty.total} lines)`);
7298
7400
  if (details?.truncation?.truncated) text += theme.fg("warning", " [truncated]");
7401
+ const mode = bashOutputMode();
7402
+ if (mode === "summary") return makeText(ctx.lastComponent, withBranch(text, theme));
7299
7403
  const persistentPreview = shouldPreserveBashPreview(ctx) ? buildPersistentBashPreview(nonEmpty.lines, theme) : "";
7300
- if (!expanded && persistentPreview) return makeText(ctx.lastComponent, withBranch(`${text}${toolOutputDetailHint(theme, expanded)}\n${persistentPreview}`, theme));
7301
- if (!expanded && nonEmpty.total > 0) return makeText(ctx.lastComponent, withBranch(`${text}${toolOutputDetailHint(theme, expanded)}`, theme));
7404
+ if (!expanded && mode === "preview") {
7405
+ const previewLines = nonEmpty.lines.slice(0, bashCollapsedLimit());
7406
+ const preview = previewLines.length > 0
7407
+ ? buildPreviewText(previewLines, false, theme, bashCollapsedLimit(), nonEmpty.total, (line) => theme.fg("dim", line || " "))
7408
+ : "";
7409
+ const suffix = toolOutputDetailHint(theme, expanded);
7410
+ return makeText(ctx.lastComponent, withBranch(`${text}${suffix}${preview ? `\n${preview}` : ""}`, theme));
7411
+ }
7412
+ if (!expanded && mode === "opencode" && nonEmpty.total > 0) return makeText(ctx.lastComponent, withBranch(`${text}${toolOutputDetailHint(theme, expanded)}`, theme));
7302
7413
  if (!expanded) return makeText(ctx.lastComponent, withBranch(text, theme));
7303
7414
  const collapsed = bashCollapsedLimit();
7304
7415
  if (rewrite) text += `\n${formatRtkRewriteDetails(rewrite, theme)}`;
@@ -7312,6 +7423,8 @@ export default function (pi: ExtensionAPI) {
7312
7423
  name: "grep",
7313
7424
  label: "grep",
7314
7425
  description: grepTool.description,
7426
+ promptSnippet: (grepTool as any).promptSnippet,
7427
+ promptGuidelines: (grepTool as any).promptGuidelines,
7315
7428
  parameters: grepTool.parameters,
7316
7429
  async execute(toolCallId, params, signal, onUpdate) {
7317
7430
  return grepTool.execute(toolCallId, params, signal, onUpdate);
@@ -7352,6 +7465,8 @@ export default function (pi: ExtensionAPI) {
7352
7465
  name: "find",
7353
7466
  label: "find",
7354
7467
  description: findTool.description,
7468
+ promptSnippet: (findTool as any).promptSnippet,
7469
+ promptGuidelines: (findTool as any).promptGuidelines,
7355
7470
  parameters: findTool.parameters,
7356
7471
  async execute(toolCallId, params, signal, onUpdate) {
7357
7472
  return findTool.execute(toolCallId, params, signal, onUpdate);
@@ -7403,6 +7518,8 @@ export default function (pi: ExtensionAPI) {
7403
7518
  name: "ls",
7404
7519
  label: "ls",
7405
7520
  description: lsTool.description,
7521
+ promptSnippet: (lsTool as any).promptSnippet,
7522
+ promptGuidelines: (lsTool as any).promptGuidelines,
7406
7523
  parameters: lsTool.parameters,
7407
7524
  async execute(toolCallId, params, signal, onUpdate) {
7408
7525
  return lsTool.execute(toolCallId, params, signal, onUpdate);
@@ -7454,6 +7571,8 @@ export default function (pi: ExtensionAPI) {
7454
7571
  name: "write",
7455
7572
  label: "write",
7456
7573
  description: writeTool.description,
7574
+ promptSnippet: (writeTool as any).promptSnippet,
7575
+ promptGuidelines: (writeTool as any).promptGuidelines,
7457
7576
  parameters: writeTool.parameters,
7458
7577
  async execute(toolCallId, params, signal, onUpdate, _ctx) {
7459
7578
  const fp = params.path ?? (params as any).file_path ?? "";
@@ -7569,6 +7688,8 @@ export default function (pi: ExtensionAPI) {
7569
7688
  name: "edit",
7570
7689
  label: "edit",
7571
7690
  description: editTool.description,
7691
+ promptSnippet: (editTool as any).promptSnippet,
7692
+ promptGuidelines: (editTool as any).promptGuidelines,
7572
7693
  parameters: editTool.parameters,
7573
7694
  async execute(toolCallId, params, signal, onUpdate, _ctx) {
7574
7695
  const fp = params.path ?? (params as any).file_path ?? "";
@@ -7795,3 +7916,11 @@ export default function (pi: ExtensionAPI) {
7795
7916
  bumpToolBranchVisualEpoch();
7796
7917
  });
7797
7918
  }
7919
+
7920
+ // Test-only escape hatch: this package is a default-export extension and the
7921
+ // export surface Pi consumes must stay minimal. Regression tests for math
7922
+ // preprocessing reach these internals through this namespace instead of
7923
+ // widening the public export (see scripts/math-preprocess.test.ts).
7924
+ export const __mathPreprocessInternals = {
7925
+ buildParagraphSegments,
7926
+ };
@@ -0,0 +1,4 @@
1
+ export function getUserSettingsPath(): string {
2
+ const agentDir = process.env.PI_CODING_AGENT_DIR || `${process.env.HOME ?? ""}/.pi`;
3
+ return `${agentDir}/settings.json`;
4
+ }
@@ -1,9 +1,13 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import net from "node:net";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
3
5
 
4
6
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
7
  import { Loader } from "@earendil-works/pi-tui";
6
8
 
9
+ import { getUserSettingsPath } from "./settings-path";
10
+
7
11
  // ---------------------------------------------------------------------------
8
12
  // Patch built-in Loader with Claude/OpenBrawd-style glyphs.
9
13
  // Keep animation cadence constant so the spinner doesn't appear to slow down
@@ -44,7 +48,7 @@ function readSpinnerSettings(): { adaptive: boolean; verbColor: string; statusCo
44
48
  // orange as the glyph on themes like openAntigravity-dark.
45
49
  let verbColor = "borderAccent";
46
50
  let statusColor = "muted";
47
- const paths = [`${process.cwd()}/.pi/settings.json`, `${process.env.HOME ?? ""}/.pi/settings.json`];
51
+ const paths = [`${process.cwd()}/.pi/settings.json`, getUserSettingsPath()];
48
52
  for (const p of paths) {
49
53
  try {
50
54
  if (!p || !existsSync(p)) continue;
@@ -517,7 +521,9 @@ function statusText(text: string): string {
517
521
  // This extension replaces that text with themed spinner verbs (Cooking…,
518
522
  // Syncing…, …), so herdr sidebars go idle/stale while pi is actually busy.
519
523
  // When running inside herdr (HERDR_ENV=1), report working/idle over the
520
- // socket the same way herdr's official pi integration does. UI is unchanged.
524
+ // socket only when herdr's official pi integration is not installed. The
525
+ // official integration owns the same lifecycle authority and must remain the
526
+ // sole reporter when present. UI is unchanged.
521
527
  // ---------------------------------------------------------------------------
522
528
 
523
529
  type HerdrAgentState = "working" | "idle";
@@ -532,9 +538,33 @@ const HERDR_PANE_ID = process.env.HERDR_PANE_ID;
532
538
  // Must be exactly "herdr:pi" — herdr only grants full lifecycle authority
533
539
  // (skip screen-manifest fallback) to that official source pair with agent "pi".
534
540
  const HERDR_SOURCE = "herdr:pi";
541
+ const HERDR_INTEGRATION_MARKER = "HERDR_INTEGRATION_ID=pi";
542
+ let officialHerdrIntegrationInstalled: boolean | undefined;
543
+
544
+ function hasOfficialHerdrIntegration(): boolean {
545
+ if (officialHerdrIntegrationInstalled !== undefined) {
546
+ return officialHerdrIntegrationInstalled;
547
+ }
548
+
549
+ const agentDir = process.env.PI_CODING_AGENT_DIR
550
+ || join(homedir(), ".pi", "agent");
551
+
552
+ try {
553
+ const integrationPath = join(agentDir, "extensions", "herdr-agent-state.ts");
554
+ officialHerdrIntegrationInstalled =
555
+ existsSync(integrationPath)
556
+ && readFileSync(integrationPath, "utf8").includes(HERDR_INTEGRATION_MARKER);
557
+ } catch {
558
+ officialHerdrIntegrationInstalled = false;
559
+ }
560
+ return officialHerdrIntegrationInstalled;
561
+ }
535
562
 
536
563
  function herdrEnabled(): boolean {
537
- return HERDR_ENV === "1" && !!HERDR_SOCKET_PATH && !!HERDR_PANE_ID;
564
+ return HERDR_ENV === "1"
565
+ && !!HERDR_SOCKET_PATH
566
+ && !!HERDR_PANE_ID
567
+ && !hasOfficialHerdrIntegration();
538
568
  }
539
569
 
540
570
  function herdrSendAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
@@ -754,7 +784,12 @@ export default function (pi: ExtensionAPI) {
754
784
  }
755
785
 
756
786
  function syncWorkingMessage(force = false): void {
757
- if (!activeCtx?.hasUI) return;
787
+ try {
788
+ if (!activeCtx?.hasUI) return;
789
+ } catch {
790
+ // Session replacement/reload invalidated the captured context.
791
+ return;
792
+ }
758
793
  // Re-derive colors on every tick so /cc-spinner verb/status changes
759
794
  // take effect within ~250 ms without waiting for the next pi event.
760
795
  // applyThemeColors is identity-cached on (theme, verbKey, statusKey) so
@@ -770,7 +805,12 @@ export default function (pi: ExtensionAPI) {
770
805
 
771
806
  function restoreDefaultWorkingMessage(): void {
772
807
  lastWorkingMessage = null;
773
- if (!activeCtx?.hasUI) return;
808
+ try {
809
+ if (!activeCtx?.hasUI) return;
810
+ } catch {
811
+ // Session replacement/reload invalidated the captured context.
812
+ return;
813
+ }
774
814
  try {
775
815
  activeCtx.ui.setWorkingMessage();
776
816
  } catch { /* noop */ }
@@ -1002,20 +1042,25 @@ export default function (pi: ExtensionAPI) {
1002
1042
  clearThoughtStatusTimer();
1003
1043
  }
1004
1044
 
1005
- if (activeCtx?.hasUI) {
1006
- const message = `${STATUS_DIM}✻ Turn took ${formatDuration(elapsed)}${RESET}`;
1007
- lastWorkingMessage = message;
1008
- try {
1009
- activeCtx.ui.setWorkingMessage(message);
1010
- } catch { /* noop */ }
1011
- completionTimer = setTimeout(() => {
1012
- completionTimer = null;
1013
- if (activeTurnId !== turnId) return;
1045
+ try {
1046
+ if (activeCtx?.hasUI) {
1047
+ const message = `${STATUS_DIM}✻ Turn took ${formatDuration(elapsed)}${RESET}`;
1048
+ lastWorkingMessage = message;
1049
+ try {
1050
+ activeCtx.ui.setWorkingMessage(message);
1051
+ } catch { /* noop */ }
1052
+ completionTimer = setTimeout(() => {
1053
+ completionTimer = null;
1054
+ if (activeTurnId !== turnId) return;
1055
+ restoreDefaultWorkingMessage();
1056
+ }, TURN_COMPLETION_MS);
1057
+ unrefTimer(completionTimer);
1058
+ } else if (typeof thinkingStatus !== "number") {
1014
1059
  restoreDefaultWorkingMessage();
1015
- }, TURN_COMPLETION_MS);
1016
- unrefTimer(completionTimer);
1017
- } else if (typeof thinkingStatus !== "number") {
1018
- restoreDefaultWorkingMessage();
1060
+ }
1061
+ } catch {
1062
+ // Session replacement/reload invalidated the captured context.
1063
+ return;
1019
1064
  }
1020
1065
 
1021
1066
  responseLength = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-code-ui",
3
- "version": "1.0.84",
3
+ "version": "1.0.86",
4
4
  "description": "Claude Code-style grouped tool rows for pi with Ctrl+O/Ctrl+Shift+O previews, math rendering, and consistent built-in, MCP, and custom tool rendering",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -30,13 +30,14 @@
30
30
  "files": [
31
31
  "extensions/index.ts",
32
32
  "extensions/spinner.ts",
33
+ "extensions/settings-path.ts",
33
34
  "extensions/bash-command.ts",
34
35
  "config/config.example.json",
35
36
  "README.md",
36
37
  "CHANGELOG.md"
37
38
  ],
38
39
  "scripts": {
39
- "test": "node --experimental-strip-types --test scripts/bash-command.test.ts",
40
+ "test": "node --experimental-strip-types --test scripts/*.test.ts scripts/*.test.mts",
40
41
  "test:fullscreen": "node scripts/fullscreen-mouse.test.mjs",
41
42
  "typecheck": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/index.ts extensions/spinner.ts",
42
43
  "pack:dry-run": "npm pack --dry-run",