pi-claude-code-ui 1.0.84 → 1.0.85

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
@@ -39,6 +39,16 @@
39
39
 
40
40
  - **Hide transient context tags** — hide transient context tags in streaming prose.
41
41
 
42
+ ## 1.0.85 — 2026-09-07
43
+
44
+ ### Fixed
45
+
46
+ - Preserve Markdown code literals while formatting inline mathematics.
47
+ - Keep assistant presentation state out of canonical session messages so compacted sessions can replay safely.
48
+ - Respect `bashOutputMode` for completed Bash output.
49
+ - Preserve native prompt metadata in wrapped core tools.
50
+ - Harden spinner cleanup when Pi replaces a stale UI context.
51
+
42
52
  ## Unreleased
43
53
 
44
54
  ### 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
 
@@ -1963,13 +2003,20 @@ function patchTerminalWriteTagScrubber(): void {
1963
2003
 
1964
2004
  function replaceInlineMath(text: string): string {
1965
2005
  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
- });
2006
+ const transform = (segment: string): string => {
2007
+ const withParens = segment.replace(/\\\(([\s\S]*?)\\\)/g, (_match, body: string) => {
2008
+ return codeSpan(formatMathForDisplay(body, false));
2009
+ });
2010
+ return withParens.replace(/(^|[^\\])\$([^\n$]{1,200})\$/g, (match, prefix: string, body: string) => {
2011
+ if (!looksLikeInlineMath(body)) return match;
2012
+ return `${prefix}${codeSpan(formatMathForDisplay(body, false))}`;
2013
+ });
2014
+ };
2015
+ // Markdown code spans and fenced blocks are literal source. Apply math
2016
+ // formatting only to prose segments so shell variables such as `$base` and
2017
+ // `$scope` remain byte-for-byte unchanged in displayed/copied commands.
2018
+ const parts = text.split(/(`{3,}[\s\S]*?`{3,}|`[^`\n]*`)/g);
2019
+ return parts.map((part, index) => index % 2 === 1 ? part : transform(part)).join("");
1973
2020
  }
1974
2021
 
1975
2022
  interface MathBlock {
@@ -2058,16 +2105,30 @@ function shouldFormatStandaloneMath(text: string): boolean {
2058
2105
  return !/[.!?]\s/.test(plain) && plain.length <= 240;
2059
2106
  }
2060
2107
 
2061
- function appendMarkdownSegment(segments: ParagraphSegment[], text: string, theme: MarkdownThemeLike): void {
2108
+ // Options Pi hands to the Markdown components it builds, including the markdown
2109
+ // transformers (Pi's own Mermaid renderer, extension transformers, ...). Paragraph
2110
+ // wrappers rebuild those components, so they have to forward the options.
2111
+ type MarkdownOptions = ConstructorParameters<typeof Markdown>[5]
2112
+
2113
+ function appendMarkdownSegment(
2114
+ segments: ParagraphSegment[],
2115
+ text: string,
2116
+ theme: MarkdownThemeLike,
2117
+ options?: MarkdownOptions,
2118
+ ): void {
2062
2119
  if (!text.trim()) return;
2063
2120
  const normalized = shouldFormatStandaloneMath(text) ? formatMathForDisplay(text, false) : replaceInlineMath(text);
2064
- segments.push({ kind: "markdown", md: new Markdown(normalized, 0, 0, theme) });
2121
+ segments.push({ kind: "markdown", md: new Markdown(normalized, 0, 0, theme, undefined, options) });
2065
2122
  }
2066
2123
 
2067
- function buildParagraphSegments(text: string, theme: MarkdownThemeLike): ParagraphSegment[] {
2124
+ function buildParagraphSegments(
2125
+ text: string,
2126
+ theme: MarkdownThemeLike,
2127
+ options?: MarkdownOptions,
2128
+ ): ParagraphSegment[] {
2068
2129
  const segments: ParagraphSegment[] = [];
2069
2130
  if (!hasDisplayMathMarkers(text)) {
2070
- appendMarkdownSegment(segments, text, theme);
2131
+ appendMarkdownSegment(segments, text, theme, options);
2071
2132
  return segments;
2072
2133
  }
2073
2134
  const scanLoose = shouldScanLooseBracketMath(text);
@@ -2075,12 +2136,12 @@ function buildParagraphSegments(text: string, theme: MarkdownThemeLike): Paragra
2075
2136
  while (cursor < text.length) {
2076
2137
  const next = findNextDisplayMathBlock(text, cursor, scanLoose);
2077
2138
  if (!next) break;
2078
- appendMarkdownSegment(segments, text.slice(cursor, next.index), theme);
2139
+ appendMarkdownSegment(segments, text.slice(cursor, next.index), theme, options);
2079
2140
  const raw = text.slice(next.contentStart, next.contentEnd).trim();
2080
2141
  if (raw) segments.push({ kind: "math", raw });
2081
2142
  cursor = next.endIndex;
2082
2143
  }
2083
- appendMarkdownSegment(segments, text.slice(cursor), theme);
2144
+ appendMarkdownSegment(segments, text.slice(cursor), theme, options);
2084
2145
  return segments;
2085
2146
  }
2086
2147
 
@@ -2167,9 +2228,9 @@ class DottedParagraph {
2167
2228
  private cachedWidth?: number;
2168
2229
  private cachedLines?: string[];
2169
2230
 
2170
- constructor(text: string, markdownTheme: MarkdownThemeLike) {
2231
+ constructor(text: string, markdownTheme: MarkdownThemeLike, options?: MarkdownOptions) {
2171
2232
  this.markdownTheme = copySafeMarkdownTheme(markdownTheme);
2172
- this.segments = buildParagraphSegments(stripTransientMagicContextTags(text), this.markdownTheme);
2233
+ this.segments = buildParagraphSegments(stripTransientMagicContextTags(text), this.markdownTheme, options);
2173
2234
  }
2174
2235
 
2175
2236
  invalidate(): void {
@@ -2263,6 +2324,7 @@ class ThinkingParagraph {
2263
2324
  private cachedLines?: string[];
2264
2325
  private cachedHovered = false;
2265
2326
  private chromeEpoch = -1;
2327
+ private options?: MarkdownOptions;
2266
2328
 
2267
2329
  setHovered(hovered: boolean): void {
2268
2330
  if (this.hovered === hovered) return;
@@ -2274,8 +2336,12 @@ class ThinkingParagraph {
2274
2336
  text: string,
2275
2337
  _markdownTheme: ConstructorParameters<typeof Markdown>[3],
2276
2338
  _defaultTextStyle?: ConstructorParameters<typeof Markdown>[4],
2339
+ options?: MarkdownOptions,
2277
2340
  ) {
2278
- this.text = stripTransientMagicContextTags(text);
2341
+ // Prefix only the renderer's private copy. The assistant message itself is
2342
+ // canonical session data and must remain untouched for compaction replay.
2343
+ this.text = prefixThinkingLine(stripTransientMagicContextTags(text), undefined);
2344
+ this.options = options;
2279
2345
  }
2280
2346
 
2281
2347
  private thinkingMarkdown(): InstanceType<typeof Markdown> {
@@ -2303,7 +2369,7 @@ class ThinkingParagraph {
2303
2369
  italic: false,
2304
2370
  color: (s: string) => `${DIM_FG}${s}`,
2305
2371
  };
2306
- return new Markdown(this.text, 0, 0, plainTheme, plainStyle);
2372
+ return new Markdown(this.text, 0, 0, plainTheme, plainStyle, this.options);
2307
2373
  }
2308
2374
 
2309
2375
  invalidate(): void {
@@ -2667,17 +2733,18 @@ function patchAssistantMessages(): void {
2667
2733
  if (isMarkdownComponent(inner)) {
2668
2734
  const text = (inner as any).text;
2669
2735
  if (!text) continue;
2736
+ const options = (inner as any).options as MarkdownOptions;
2670
2737
  const isThinking = !!(inner as any).defaultTextStyle?.italic;
2671
2738
  if (isThinking) {
2672
2739
  const style = (inner as any).defaultTextStyle;
2673
- const replacement = new ThinkingParagraph(text, mdTheme, style);
2740
+ const replacement = new ThinkingParagraph(text, mdTheme, style, options);
2674
2741
  if ((child as any)?.child !== undefined) {
2675
2742
  (child as any).child = replacement;
2676
2743
  } else {
2677
2744
  container.children[i] = replacement;
2678
2745
  }
2679
2746
  } else {
2680
- const replacement = new DottedParagraph(text, mdTheme);
2747
+ const replacement = new DottedParagraph(text, mdTheme, options);
2681
2748
  if ((child as any)?.child !== undefined) {
2682
2749
  (child as any).child = replacement;
2683
2750
  } else {
@@ -2686,9 +2753,10 @@ function patchAssistantMessages(): void {
2686
2753
  }
2687
2754
  }
2688
2755
  }
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];
2756
+ const workedMetadata = workedMetadataForMessage(message);
2757
+ const explicitDuration = workedMetadata?.duration;
2758
+ const explicitSessionTotal = workedMetadata?.sessionTotal;
2759
+ const explicitTurns = workedMetadata?.turns;
2692
2760
  // The "Turn took" line must only appear once the stream has truly closed.
2693
2761
  // `message.stopReason === "stop"` is not a safe "finished" signal here because
2694
2762
  // providers may initialize a live message with that value. The `message_end`
@@ -3775,6 +3843,10 @@ function bashCollapsedLimit(): number {
3775
3843
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 10;
3776
3844
  }
3777
3845
 
3846
+ function bashOutputMode(): "opencode" | "summary" | "preview" {
3847
+ return getMode(readSettings().bashOutputMode, ["opencode", "summary", "preview"] as const, "opencode");
3848
+ }
3849
+
3778
3850
  function bashCommandPreviewLimit(): number {
3779
3851
  const value = readSettings().bashCommandPreviewLines;
3780
3852
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 8;
@@ -5585,8 +5657,8 @@ function trackThinkingBlockEvents(event: any, ctx?: any): void {
5585
5657
  thinkingBlockInFlight = true;
5586
5658
  thinkingBlockStartMs = Date.now();
5587
5659
  if (message?.role === "assistant") {
5588
- (message as any)[THINKING_ACTIVE_KEY] = true;
5589
- delete (message as any)[THINKING_DURATION_KEY];
5660
+ thinkingActiveMessages.add(message);
5661
+ thinkingDurationByMessage.delete(message);
5590
5662
  }
5591
5663
  refreshThinkingChrome();
5592
5664
  return;
@@ -5596,9 +5668,9 @@ function trackThinkingBlockEvents(event: any, ctx?: any): void {
5596
5668
  const duration = thinkingBlockStartMs > 0
5597
5669
  ? Math.max(0, Date.now() - thinkingBlockStartMs)
5598
5670
  : 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;
5671
+ if (message?.role === "assistant") thinkingActiveMessages.delete(message);
5672
+ if (typeof duration === "number" && duration <= MAX_REASONABLE_THINKING_DURATION_MS && message?.role === "assistant") {
5673
+ thinkingDurationByMessage.set(message, duration);
5602
5674
  }
5603
5675
  thinkingBlockStartMs = 0;
5604
5676
  refreshThinkingChrome();
@@ -5610,13 +5682,13 @@ function trackThinkingBlockEvents(event: any, ctx?: any): void {
5610
5682
  // run normally. Any non-thinking stream event on the same assistant message
5611
5683
  // means thinking is no longer the live activity: freeze the elapsed time into
5612
5684
  // a "Thought for Xs" duration so the row always resolves.
5613
- if ((message as any)?.[THINKING_ACTIVE_KEY] || thinkingBlockInFlight) {
5685
+ if (hasThinkingActiveMetadata(message) || thinkingBlockInFlight) {
5614
5686
  if (evt.type === "text_start" || evt.type === "text_delta" || evt.type === "toolcall_start" || evt.type === "toolcall_end") {
5615
5687
  thinkingBlockInFlight = false;
5616
5688
  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;
5689
+ if (message?.role === "assistant") thinkingActiveMessages.delete(message);
5690
+ if (typeof duration === "number" && duration <= MAX_REASONABLE_THINKING_DURATION_MS && message?.role === "assistant") {
5691
+ thinkingDurationByMessage.set(message, duration);
5620
5692
  }
5621
5693
  thinkingBlockStartMs = 0;
5622
5694
  refreshThinkingChrome();
@@ -5631,11 +5703,9 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5631
5703
  if (theme) applyThemePaletteIfNeeded(theme);
5632
5704
  const message = event?.message;
5633
5705
  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
- }
5706
+ // Thinking labels are applied by ThinkingParagraph while rendering. Never
5707
+ // rewrite the assistant message object: context/compaction consumers need
5708
+ // its canonical thinking content unchanged.
5639
5709
  };
5640
5710
  pi.on("before_agent_start", async () => {
5641
5711
  // Start once per top-level request. Steering/follow-up messages can be
@@ -5661,14 +5731,14 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5661
5731
  }
5662
5732
  if (message?.role === "assistant") {
5663
5733
  currentAssistantMessageStartMs = Date.now();
5664
- (message as any)[WORKED_START_KEY] = currentAssistantMessageStartMs;
5734
+ workedStartByMessage.set(message, currentAssistantMessageStartMs);
5665
5735
  // A new assistant message starts a fresh thinking lifecycle. Without
5666
5736
  // this, a missing thinking_end on the previous message leaves the
5667
5737
  // global in-flight flag set and the next message renders a stale
5668
5738
  // "Thinking..." row until its own thinking events arrive.
5669
5739
  thinkingBlockInFlight = false;
5670
5740
  thinkingBlockStartMs = 0;
5671
- delete (message as any)[THINKING_ACTIVE_KEY];
5741
+ thinkingActiveMessages.delete(message);
5672
5742
  }
5673
5743
  });
5674
5744
  pi.on("message_update", async (event, ctx) => {
@@ -5683,27 +5753,22 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5683
5753
  // into its "Thought for Xs" duration here so the row always resolves
5684
5754
  // at the end of the message instead of sticking into later calls.
5685
5755
  thinkingBlockInFlight = false;
5686
- delete (message as any)[THINKING_ACTIVE_KEY];
5687
- if (typeof (message as any)[THINKING_DURATION_KEY] !== "number" && thinkingBlockStartMs > 0) {
5756
+ thinkingActiveMessages.delete(message);
5757
+ if (getThinkingDurationMetadata(message) === undefined && thinkingBlockStartMs > 0) {
5688
5758
  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
- }
5759
+ if (duration <= MAX_REASONABLE_THINKING_DURATION_MS) thinkingDurationByMessage.set(message, duration);
5692
5760
  }
5693
5761
  thinkingBlockStartMs = 0;
5694
5762
  const started = typeof currentAgentWorkStartMs === "number"
5695
5763
  ? currentAgentWorkStartMs
5696
- : typeof (message as any)[WORKED_START_KEY] === "number"
5697
- ? (message as any)[WORKED_START_KEY]
5698
- : currentAssistantMessageStartMs;
5764
+ : workedStartByMessage.get(message)
5765
+ ?? (typeof (message as any)[WORKED_START_KEY] === "number" ? (message as any)[WORKED_START_KEY] : currentAssistantMessageStartMs);
5699
5766
  const isFinalAssistantMessage = message.stopReason === "stop";
5700
5767
  if (started !== undefined && isFinalAssistantMessage) {
5701
5768
  const durationMs = Date.now() - started;
5702
5769
  const sessionTotalMs = typeof sessionStartMs === "number" ? Date.now() - sessionStartMs : undefined;
5703
5770
  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;
5771
+ workedDurationByMessage.set(message, { duration: durationMs, sessionTotal: sessionTotalMs, turns });
5707
5772
  // Duration metadata drives the assistant component's TUI-only status line.
5708
5773
  // Message content stays presentation-neutral for persistence and consumers.
5709
5774
  }
@@ -5751,17 +5816,10 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
5751
5816
  sessionStartMs = sessionStartMs === undefined ? earliest : Math.min(sessionStartMs, earliest);
5752
5817
  }
5753
5818
  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
- }
5819
+ // Do not normalize or strip message content here. The context event is also
5820
+ // consumed by compaction/replay integrations that compare these objects
5821
+ // against the persisted session projection. Presentation cleanup belongs in
5822
+ // the renderer and must not mutate canonical history.
5765
5823
  });
5766
5824
  }
5767
5825
 
@@ -7184,6 +7242,8 @@ export default function (pi: ExtensionAPI) {
7184
7242
  name: "read",
7185
7243
  label: "read",
7186
7244
  description: readTool.description,
7245
+ promptSnippet: (readTool as any).promptSnippet,
7246
+ promptGuidelines: (readTool as any).promptGuidelines,
7187
7247
  parameters: readTool.parameters,
7188
7248
  async execute(toolCallId, params, signal, onUpdate) {
7189
7249
  return readTool.execute(toolCallId, params, signal, onUpdate);
@@ -7239,6 +7299,8 @@ export default function (pi: ExtensionAPI) {
7239
7299
  name: "bash",
7240
7300
  label: "bash",
7241
7301
  description: bashTool.description,
7302
+ promptSnippet: (bashTool as any).promptSnippet,
7303
+ promptGuidelines: (bashTool as any).promptGuidelines,
7242
7304
  parameters: bashTool.parameters,
7243
7305
  async execute(toolCallId, params, signal, onUpdate) {
7244
7306
  return bashTool.execute(toolCallId, params, signal, onUpdate);
@@ -7296,9 +7358,18 @@ export default function (pi: ExtensionAPI) {
7296
7358
  let text = exitCode === null || exitCode === 0 ? theme.fg("success", "Done") : theme.fg("error", `Exit ${exitCode}`);
7297
7359
  text += theme.fg("muted", ` (${nonEmpty.total} lines)`);
7298
7360
  if (details?.truncation?.truncated) text += theme.fg("warning", " [truncated]");
7361
+ const mode = bashOutputMode();
7362
+ if (mode === "summary") return makeText(ctx.lastComponent, withBranch(text, theme));
7299
7363
  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));
7364
+ if (!expanded && mode === "preview") {
7365
+ const previewLines = nonEmpty.lines.slice(0, bashCollapsedLimit());
7366
+ const preview = previewLines.length > 0
7367
+ ? buildPreviewText(previewLines, false, theme, bashCollapsedLimit(), nonEmpty.total, (line) => theme.fg("dim", line || " "))
7368
+ : "";
7369
+ const suffix = toolOutputDetailHint(theme, expanded);
7370
+ return makeText(ctx.lastComponent, withBranch(`${text}${suffix}${preview ? `\n${preview}` : ""}`, theme));
7371
+ }
7372
+ if (!expanded && mode === "opencode" && nonEmpty.total > 0) return makeText(ctx.lastComponent, withBranch(`${text}${toolOutputDetailHint(theme, expanded)}`, theme));
7302
7373
  if (!expanded) return makeText(ctx.lastComponent, withBranch(text, theme));
7303
7374
  const collapsed = bashCollapsedLimit();
7304
7375
  if (rewrite) text += `\n${formatRtkRewriteDetails(rewrite, theme)}`;
@@ -7312,6 +7383,8 @@ export default function (pi: ExtensionAPI) {
7312
7383
  name: "grep",
7313
7384
  label: "grep",
7314
7385
  description: grepTool.description,
7386
+ promptSnippet: (grepTool as any).promptSnippet,
7387
+ promptGuidelines: (grepTool as any).promptGuidelines,
7315
7388
  parameters: grepTool.parameters,
7316
7389
  async execute(toolCallId, params, signal, onUpdate) {
7317
7390
  return grepTool.execute(toolCallId, params, signal, onUpdate);
@@ -7352,6 +7425,8 @@ export default function (pi: ExtensionAPI) {
7352
7425
  name: "find",
7353
7426
  label: "find",
7354
7427
  description: findTool.description,
7428
+ promptSnippet: (findTool as any).promptSnippet,
7429
+ promptGuidelines: (findTool as any).promptGuidelines,
7355
7430
  parameters: findTool.parameters,
7356
7431
  async execute(toolCallId, params, signal, onUpdate) {
7357
7432
  return findTool.execute(toolCallId, params, signal, onUpdate);
@@ -7403,6 +7478,8 @@ export default function (pi: ExtensionAPI) {
7403
7478
  name: "ls",
7404
7479
  label: "ls",
7405
7480
  description: lsTool.description,
7481
+ promptSnippet: (lsTool as any).promptSnippet,
7482
+ promptGuidelines: (lsTool as any).promptGuidelines,
7406
7483
  parameters: lsTool.parameters,
7407
7484
  async execute(toolCallId, params, signal, onUpdate) {
7408
7485
  return lsTool.execute(toolCallId, params, signal, onUpdate);
@@ -7454,6 +7531,8 @@ export default function (pi: ExtensionAPI) {
7454
7531
  name: "write",
7455
7532
  label: "write",
7456
7533
  description: writeTool.description,
7534
+ promptSnippet: (writeTool as any).promptSnippet,
7535
+ promptGuidelines: (writeTool as any).promptGuidelines,
7457
7536
  parameters: writeTool.parameters,
7458
7537
  async execute(toolCallId, params, signal, onUpdate, _ctx) {
7459
7538
  const fp = params.path ?? (params as any).file_path ?? "";
@@ -7569,6 +7648,8 @@ export default function (pi: ExtensionAPI) {
7569
7648
  name: "edit",
7570
7649
  label: "edit",
7571
7650
  description: editTool.description,
7651
+ promptSnippet: (editTool as any).promptSnippet,
7652
+ promptGuidelines: (editTool as any).promptGuidelines,
7572
7653
  parameters: editTool.parameters,
7573
7654
  async execute(toolCallId, params, signal, onUpdate, _ctx) {
7574
7655
  const fp = params.path ?? (params as any).file_path ?? "";
@@ -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.85",
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",
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",