pi-claude-code-ui 1.0.79 → 1.0.82

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.
@@ -34,6 +34,7 @@ import {
34
34
  getImageDimensions,
35
35
  imageFallback,
36
36
  Markdown,
37
+ ProcessTerminal,
37
38
  Spacer,
38
39
  Text,
39
40
  truncateToWidth,
@@ -44,6 +45,14 @@ import {
44
45
  import * as Diff from "diff";
45
46
  import type { BundledLanguage, BundledTheme } from "shiki";
46
47
 
48
+ import {
49
+ buildBashCommandPresentation,
50
+ buildBashPreview,
51
+ describeBashSource,
52
+ formatBashDuration,
53
+ getLastBashOutputLine,
54
+ } from "./bash-command";
55
+
47
56
  const RESET = "\x1b[0m";
48
57
  const TRANSPARENT_BG = "\x1b[49m";
49
58
  const TRANSPARENT_RESET = `${RESET}${TRANSPARENT_BG}`;
@@ -67,6 +76,8 @@ const CUSTOM_MESSAGE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-cust
67
76
  const USER_MESSAGE_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-user-message-render");
68
77
  const UI_NOTIFY_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-ui-notifications-v2");
69
78
  const WRAP_MARK = "\uE000";
79
+ const CLIP_MARK = "\uE001";
80
+ const TRAILING_MARK = " · ";
70
81
  const KITTY_IMAGE_PREFIX = "\x1b_G";
71
82
  const ITERM2_IMAGE_PREFIX = "\x1b]1337;File=";
72
83
 
@@ -84,6 +95,8 @@ interface SettingsFile {
84
95
  groupToolCalls?: boolean;
85
96
  bashOutputMode?: "opencode" | "summary" | "preview";
86
97
  bashCollapsedLines?: number;
98
+ /** Verbatim script lines shown while bash is running or after failure. Defaults to 8. */
99
+ bashCommandPreviewLines?: number;
87
100
  /** Show a small live output preview while tools are still running. Defaults to true. */
88
101
  liveToolPreview?: boolean;
89
102
  /** Number of live output lines to show while collapsed. Defaults to 5. */
@@ -112,6 +125,13 @@ interface SettingsFile {
112
125
  * "(thinking · ↓ 10 tokens · 2s)" trailer). Defaults to "muted".
113
126
  */
114
127
  spinnerStatusColor?: string;
128
+ /**
129
+ * Thinking display mode. `live` (default): only the currently-streaming
130
+ * thinking is expanded; finished thinking collapses to a one-line
131
+ * `Thought for Xs` row (Ctrl+O still expands it). `full`: thinking always
132
+ * renders expanded, like stock pi.
133
+ */
134
+ thinkingMode?: "live" | "full";
115
135
  /** Gray level 0–255 for ├ └ │ when branch color mode is `fixed`. */
116
136
  toolBranchRgbGray?: number;
117
137
  /** `fixed` (default): rgb gray 72, theme-independent. `theme`: dim → muted → borderMuted. */
@@ -478,6 +498,49 @@ function setToolGroupingEnabled(enabled: boolean): void {
478
498
  writeSettingsKey("groupToolCalls", enabled);
479
499
  }
480
500
 
501
+ type ThinkingMode = "live" | "full";
502
+
503
+ function getThinkingMode(): ThinkingMode {
504
+ return getMode(readSettings().thinkingMode, ["live", "full"] as const, "live");
505
+ }
506
+
507
+ function isAssistantThinkingComplete(comp: any, message: any): boolean {
508
+ if (!message || message.role !== "assistant") return false;
509
+ if (typeof message[THINKING_DURATION_KEY] === "number") return true;
510
+ if (message[THINKING_ACTIVE_KEY]) return false;
511
+ // Providers keep stopReason "pending" for the whole stream ("deferred" while
512
+ // a deferred call is unresolved); both are in-flight sentinels, never
513
+ // completion signals. Without this, live-thinking detection would depend on
514
+ // THINKING_ACTIVE_KEY being stamped before the UI renders the same event.
515
+ if (message.stopReason === "pending" || message.stopReason === "deferred") return false;
516
+ if (typeof message.stopReason === "string" && message.stopReason.length > 0) return true;
517
+ if (Array.isArray(message.content)) {
518
+ let sawThinking = false;
519
+ for (const block of message.content) {
520
+ if (block?.type === "thinking" && typeof block.thinking === "string" && block.thinking.trim()) {
521
+ sawThinking = true;
522
+ } else if (sawThinking && (
523
+ (block?.type === "text" && typeof block.text === "string" && block.text.trim()) ||
524
+ block?.type === "toolCall"
525
+ )) {
526
+ return true;
527
+ }
528
+ }
529
+ if (sawThinking) return false;
530
+ }
531
+ return true;
532
+ }
533
+
534
+ function isLiveThinkingMessage(comp: any, message: any): boolean {
535
+ if (!message || message.role !== "assistant") return false;
536
+ if (isAssistantThinkingComplete(comp, message)) return false;
537
+ if ((message as any)[THINKING_ACTIVE_KEY]) return true;
538
+ if (Array.isArray(message.content)) {
539
+ return message.content.some((b: any) => b?.type === "thinking" && typeof b?.thinking === "string" && b.thinking.trim());
540
+ }
541
+ return false;
542
+ }
543
+
481
544
  type ToolStatus = "pending" | "success" | "error";
482
545
 
483
546
  function getToolStatusForGroup(tool: any): ToolStatus {
@@ -597,9 +660,22 @@ function formatToolNameList(tools: any[]): string {
597
660
  counts.set(name, (counts.get(name) ?? 0) + 1);
598
661
  }
599
662
  return [...counts.entries()]
600
- .slice(0, 4)
601
- .map(([name, count]) => `${name}${count > 1 ? `×${count}` : ""}`)
602
- .join(", ") + (counts.size > 4 ? ", …" : "");
663
+ .map(([name, count]) => `${humanizeToolName(name)}${count > 1 ? `×${count}` : ""}`)
664
+ .join(", ");
665
+ }
666
+
667
+ function getRepeatedToolSubject(tools: any[], groupedName: string | undefined): string {
668
+ if (!groupedName || tools.length === 0) return "";
669
+ if (groupedName === "read") {
670
+ const paths = tools.map((tool) => String(tool?.args?.path ?? ""));
671
+ if (paths[0] && paths.every((path) => path === paths[0])) {
672
+ return shortPath(process.cwd(), paths[0]);
673
+ }
674
+ }
675
+ const summaries = tools.map(getToolArgSummary);
676
+ return summaries[0] && summaries.every((summary) => summary === summaries[0])
677
+ ? summaries[0]
678
+ : "";
603
679
  }
604
680
 
605
681
  function escapeRegex(text: string): string {
@@ -660,7 +736,7 @@ function getToolArgSummary(tool: any): string {
660
736
  if (parts.length > 0) value += ` (${parts.join(", ")})`;
661
737
  return value;
662
738
  }
663
- if (name === "bash") return summarizeText(args.command ?? "", 72);
739
+ if (name === "bash") return buildBashCommandPresentation(args.command ?? "").headline;
664
740
  if (name === "grep") return `"${summarizeText(args.pattern ?? "", 40)}"${args.path ? ` in ${args.path}` : ""}`;
665
741
  if (name === "find") return `"${summarizeText(args.pattern ?? "", 40)}"${args.path ? ` in ${args.path}` : ""}`;
666
742
  if (name === "ls") return shortPath(process.cwd(), args.path ?? ".");
@@ -671,16 +747,80 @@ function getToolCallLine(tool: any): string {
671
747
  const value = (tool as any)?.callRendererComponent?.value;
672
748
  if (typeof value === "string" && value.trim()) {
673
749
  const line = value.split("\n").find((line) => stripAnsi(line).trim()) ?? value;
674
- return line.replaceAll(WRAP_MARK, "");
750
+ return line.replaceAll(WRAP_MARK, "").replaceAll(CLIP_MARK, "");
675
751
  }
676
752
  const summary = getToolArgSummary(tool);
677
753
  const label = humanizeToolName(getToolName(tool));
678
754
  return `${label}${summary ? ` ${summary}` : ""}`;
679
755
  }
680
756
 
681
- function getCompactToolLine(tool: any, width: number, groupedLabel?: string): string {
682
- const content = removeGroupedToolPrefix(getToolCallLine(tool), groupedLabel);
683
- return clampLineWidth(content || getToolName(tool), width);
757
+ function alignTrailingMarkedLine(line: string, width: number): string {
758
+ const markerIndex = line.indexOf(TRAILING_MARK);
759
+ if (markerIndex === -1) return clampLineWidth(line, width);
760
+ const safeWidth = Math.max(1, width);
761
+ const left = line.slice(0, markerIndex);
762
+ const right = line.slice(markerIndex + TRAILING_MARK.length);
763
+ const rightWidth = visibleWidth(right);
764
+ if (rightWidth >= safeWidth) return truncateToWidth(right, safeWidth, "", false);
765
+ const leftBudget = Math.max(0, safeWidth - rightWidth - 2);
766
+ const clippedLeft = leftBudget > 0 ? truncateToWidth(left, leftBudget, "…", false) : "";
767
+ const gap = Math.max(1, safeWidth - visibleWidth(clippedLeft) - rightWidth);
768
+ return `${clippedLeft}${" ".repeat(gap)}${right}`;
769
+ }
770
+
771
+ function getCompactToolLine(tool: any, width: number, groupedLabel?: string, showTrailing = true): string {
772
+ let content = removeGroupedToolPrefix(getToolCallLine(tool), groupedLabel);
773
+ if (!showTrailing) content = content.split(TRAILING_MARK, 1)[0] ?? content;
774
+ return alignTrailingMarkedLine(content || getToolName(tool), width);
775
+ }
776
+
777
+ interface CollapsedToolEntry {
778
+ tools: any[];
779
+ name: string;
780
+ subject: string;
781
+ }
782
+
783
+ function collapseRepeatedToolEntries(tools: any[]): CollapsedToolEntry[] {
784
+ const entries: CollapsedToolEntry[] = [];
785
+ for (const tool of tools) {
786
+ const name = getToolName(tool);
787
+ const subject = getRepeatedToolSubject([tool], name);
788
+ const previous = entries[entries.length - 1];
789
+ if (subject && previous?.name === name && previous.subject === subject) {
790
+ previous.tools.push(tool);
791
+ } else {
792
+ entries.push({ tools: [tool], name, subject });
793
+ }
794
+ }
795
+ return entries;
796
+ }
797
+
798
+ function stripReadRangeFromToolLine(line: string): string {
799
+ return line.replace(
800
+ /\s+(?:\x1b\[[0-9;]*m)*\((?:offset|limit)=\d+(?:,\s*(?:offset|limit)=\d+)*\)(?=(?:\x1b\[[0-9;]*m)*$)/,
801
+ "",
802
+ );
803
+ }
804
+
805
+ function getCollapsedToolEntryLine(entry: CollapsedToolEntry, width: number, groupedLabel?: string): string {
806
+ if (entry.tools.length === 1) return getCompactToolLine(entry.tools[0], width, groupedLabel);
807
+ const counts = countToolStatuses(entry.tools);
808
+ const attentionCounts = counts.pending > 0 || counts.error > 0
809
+ ? ` • ${formatToolGroupCounts(entry.tools)}`
810
+ : "";
811
+ const suffix = ` ${FG_DIM}×${entry.tools.length}${TRANSPARENT_RESET}${attentionCounts}`;
812
+ const firstLine = getCompactToolLine(entry.tools[0], Math.max(1, width - visibleWidth(suffix)), groupedLabel, false);
813
+ const sharedLine = entry.name === "read" ? stripReadRangeFromToolLine(firstLine) : firstLine;
814
+ return clampLineWidth(`${sharedLine}${suffix}`, width);
815
+ }
816
+
817
+ function getCollapsedToolEntryLines(entry: CollapsedToolEntry, width: number, groupedLabel?: string): string[] {
818
+ const lines = [getCollapsedToolEntryLine(entry, width, groupedLabel)];
819
+ if (entry.name !== "bash") return lines;
820
+ const running = [...entry.tools].reverse().find((tool) => getToolStatusForGroup(tool) === "pending");
821
+ const latestOutput = running ? getLastBashOutputLine(getTextContent(running.result)) : undefined;
822
+ if (latestOutput) lines.push(`${FG_DIM}${latestOutput}${TRANSPARENT_RESET}`);
823
+ return lines;
684
824
  }
685
825
 
686
826
  function getExpandedToolGroupLines(tool: any, width: number, groupedLabel?: string): string[] {
@@ -859,26 +999,48 @@ class ToolGroupComponent extends Container {
859
999
  if (status.success) countParts.push(statusText("success", status.success));
860
1000
  if (status.error) countParts.push(statusText("error", status.error));
861
1001
  const countsText = countParts.join(`${TRANSPARENT_RESET} • `);
862
- const summary = ` ${light} ${summaryLabel} ${countsText}${names ? ` ${TRANSPARENT_RESET}• ${names}` : ""}${toolOutputDetailHint(undefined as any, this.expanded, true)}`;
863
- const lines = [" ".repeat(safeWidth), clampLineWidth(summary, safeWidth)];
864
- const childWidth = Math.max(1, safeWidth - 6);
865
1002
  const total = this.tools.length;
866
-
867
- for (let index = 0; index < total; index++) {
868
- const tool = this.tools[index];
869
- const rawLines = this.expanded
870
- ? getExpandedToolGroupLines(tool, childWidth, groupedName ? label : undefined)
871
- : [getCompactToolLine(tool, childWidth, groupedName ? label : undefined)];
872
- const branched = formatBranchedToolLines(
873
- rawLines,
874
- index,
875
- total,
1003
+ const lines: string[] = [];
1004
+ const subject = this.expanded ? "" : getRepeatedToolSubject(this.tools, groupedName);
1005
+ const collapseToSingleRow = !this.expanded && !!groupedName && !!subject;
1006
+ if (collapseToSingleRow) {
1007
+ const entry = { tools: this.tools, name: groupedName, subject };
1008
+ const entryLine = getCollapsedToolEntryLine(entry, Math.max(1, safeWidth - 3));
1009
+ lines.push(clampLineWidth(
1010
+ ` ${light} ${entryLine}${toolOutputDetailHint(undefined as any, false, true)}`,
876
1011
  safeWidth,
877
- getToolStatusForGroup(tool),
878
- { agentBreathe: isAgentFamilyToolName(getToolName(tool)) },
879
- );
880
- for (let i = 0; i < branched.length; i++) {
881
- lines.push(clampLineWidth(branched[i], safeWidth));
1012
+ ));
1013
+ } else {
1014
+ const summary = ` ${light} ${summaryLabel} ${countsText}${names ? ` ${TRANSPARENT_RESET}• ${names}` : ""}${toolOutputDetailHint(undefined as any, this.expanded, true)}`;
1015
+ lines.push(" ".repeat(safeWidth), clampLineWidth(summary, safeWidth));
1016
+ const childWidth = Math.max(1, safeWidth - 6);
1017
+ if (this.expanded) {
1018
+ for (let index = 0; index < total; index++) {
1019
+ const tool = this.tools[index];
1020
+ const branched = formatBranchedToolLines(
1021
+ getExpandedToolGroupLines(tool, childWidth, groupedName ? label : undefined),
1022
+ index,
1023
+ total,
1024
+ safeWidth,
1025
+ getToolStatusForGroup(tool),
1026
+ { agentBreathe: isAgentFamilyToolName(getToolName(tool)) },
1027
+ );
1028
+ for (const line of branched) lines.push(clampLineWidth(line, safeWidth));
1029
+ }
1030
+ } else {
1031
+ const entries = collapseRepeatedToolEntries(this.tools);
1032
+ for (let index = 0; index < entries.length; index++) {
1033
+ const entry = entries[index];
1034
+ const branched = formatBranchedToolLines(
1035
+ getCollapsedToolEntryLines(entry, childWidth, groupedName ? label : undefined),
1036
+ index,
1037
+ entries.length,
1038
+ safeWidth,
1039
+ getToolGroupOverallStatus(entry.tools),
1040
+ { agentBreathe: entry.tools.every((tool) => isAgentFamilyToolName(getToolName(tool))) },
1041
+ );
1042
+ for (const line of branched) lines.push(clampLineWidth(line, safeWidth));
1043
+ }
882
1044
  }
883
1045
  }
884
1046
 
@@ -904,23 +1066,36 @@ function isToolGroupComponent(value: unknown): value is ToolGroupComponent {
904
1066
  return value instanceof ToolGroupComponent;
905
1067
  }
906
1068
 
1069
+ function isSpacerComponent(value: unknown): value is InstanceType<typeof Spacer> {
1070
+ return value instanceof Spacer || (value as any)?.constructor?.name === "Spacer";
1071
+ }
1072
+
1073
+ function isTextComponent(value: unknown): value is InstanceType<typeof Text> {
1074
+ return value instanceof Text || (value as any)?.constructor?.name === "Text";
1075
+ }
1076
+
1077
+ function isMarkdownComponent(value: unknown): value is InstanceType<typeof Markdown> {
1078
+ return value instanceof Markdown || (value as any)?.constructor?.name === "Markdown";
1079
+ }
1080
+
907
1081
  function isIgnorableToolSeparator(value: unknown): boolean {
908
- if (value instanceof Spacer) return true;
909
- if (value instanceof AssistantMessageComponent) {
1082
+ if (isSpacerComponent(value)) return true;
1083
+ if (value instanceof AssistantMessageComponent || (value as any)?.constructor?.name === "AssistantMessageComponent") {
1084
+ // Empty assistant framing stays ignorable so it never splits tool groups.
1085
+ // A rendered thinking row ("Thought for Xs" / live thinking) is a visible
1086
+ // boundary: tool calls that follow it must start a new group instead of
1087
+ // silently joining the batch that ran before the thought.
910
1088
  const contentChildren = (value as any).contentContainer?.children;
911
- return Array.isArray(contentChildren) && contentChildren.length === 0;
1089
+ if (!Array.isArray(contentChildren) || contentChildren.length === 0) return true;
1090
+ return contentChildren.every((child: any) => isSpacerComponent(child));
912
1091
  }
913
1092
  return false;
914
1093
  }
915
1094
 
916
1095
  function findPreviousToolSibling(children: any[], startIndex: number): { child: any; index: number } | undefined {
917
- let skippedSeparators = 0;
918
1096
  for (let index = startIndex; index >= 0; index--) {
919
1097
  const child = children[index];
920
- if (isIgnorableToolSeparator(child) && skippedSeparators < 3) {
921
- skippedSeparators++;
922
- continue;
923
- }
1098
+ if (isIgnorableToolSeparator(child)) continue;
924
1099
  return { child, index };
925
1100
  }
926
1101
  return undefined;
@@ -945,6 +1120,58 @@ function ungroupActiveToolGroups(): void {
945
1120
  }
946
1121
  }
947
1122
 
1123
+ function isThinkingOnlyAssistantComponent(comp: unknown): comp is InstanceType<typeof AssistantMessageComponent> {
1124
+ if (!comp || ((comp as any).constructor?.name !== "AssistantMessageComponent" && !(comp instanceof AssistantMessageComponent))) {
1125
+ return false;
1126
+ }
1127
+ const msg = (comp as any).lastMessage;
1128
+ if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) return false;
1129
+ const hasThinking = msg.content.some((c: any) => c?.type === "thinking" && typeof c?.thinking === "string" && c.thinking.trim());
1130
+ if (!hasThinking) return false;
1131
+ const hasText = msg.content.some((c: any) => c?.type === "text" && typeof c?.text === "string" && c.text.trim());
1132
+ if (hasText) return false;
1133
+ const hasToolCalls = msg.content.some((c: any) => c?.type === "toolCall");
1134
+ if (hasToolCalls) return false;
1135
+ if ((comp as any).isStreaming === true || msg[THINKING_ACTIVE_KEY]) return false;
1136
+ return true;
1137
+ }
1138
+
1139
+ function maybeMergeConsecutiveThinkingMessages(parent: any): void {
1140
+ const children = parent?.children;
1141
+ if (!Array.isArray(children) || children.length < 2) return;
1142
+
1143
+ for (let i = 0; i < children.length; i++) {
1144
+ const current = children[i];
1145
+ if (!isThinkingOnlyAssistantComponent(current)) continue;
1146
+
1147
+ let nextIdx = i + 1;
1148
+ while (nextIdx < children.length && isSpacerComponent(children[nextIdx])) {
1149
+ nextIdx++;
1150
+ }
1151
+ if (nextIdx >= children.length) break;
1152
+
1153
+ const nextComp = children[nextIdx];
1154
+ if (isThinkingOnlyAssistantComponent(nextComp)) {
1155
+ const curMsg = (current as any).lastMessage;
1156
+ const nextMsg = (nextComp as any).lastMessage;
1157
+
1158
+ const durA = getMessageThinkingDurationMs(curMsg);
1159
+ const durB = getMessageThinkingDurationMs(nextMsg);
1160
+ const mergedDuration = durA + durB;
1161
+
1162
+ const nextThinkingBlocks = nextMsg.content.filter((c: any) => c?.type === "thinking");
1163
+ curMsg.content.push(...nextThinkingBlocks);
1164
+ curMsg[THINKING_DURATION_KEY] = mergedDuration;
1165
+
1166
+ (current as any).updateContent(curMsg);
1167
+
1168
+ const removeCount = nextIdx - i;
1169
+ children.splice(i + 1, removeCount);
1170
+ i--;
1171
+ }
1172
+ }
1173
+ }
1174
+
948
1175
  function maybeGroupToolComponent(parent: any, component: any): void {
949
1176
  if (!toolGroupingEnabled() || !isGroupableTool(component) || isToolGroupComponent(parent)) return;
950
1177
  const children = parent?.children;
@@ -957,6 +1184,7 @@ function maybeGroupToolComponent(parent: any, component: any): void {
957
1184
  if (isToolGroupComponent(previous)) {
958
1185
  children.splice(index, 1);
959
1186
  previous.addTool(component);
1187
+ maybeMergeConsecutiveThinkingMessages(parent);
960
1188
  return;
961
1189
  }
962
1190
  if (isGroupableTool(previous)) {
@@ -967,6 +1195,7 @@ function maybeGroupToolComponent(parent: any, component: any): void {
967
1195
  (group as any)[COMPONENT_PARENT] = parent;
968
1196
  children[previousEntry.index] = group;
969
1197
  children.splice(index, 1);
1198
+ maybeMergeConsecutiveThinkingMessages(parent);
970
1199
  }
971
1200
  }
972
1201
 
@@ -980,6 +1209,7 @@ function patchContainerParentTracking(): void {
980
1209
  const result = originalAddChild.call(this, component);
981
1210
  if (component && typeof component === "object") component[COMPONENT_PARENT] = this;
982
1211
  maybeGroupToolComponent(this, component);
1212
+ maybeMergeConsecutiveThinkingMessages(this);
983
1213
  return result;
984
1214
  };
985
1215
  proto.removeChild = function patchedRemoveChild(component: any) {
@@ -1025,6 +1255,7 @@ function patchGlobalToolBorders(): void {
1025
1255
 
1026
1256
  const originalRender = proto.render;
1027
1257
  proto.render = function patchedContainerRender(width: number): string[] {
1258
+ maybeMergeConsecutiveThinkingMessages(this);
1028
1259
  if (isToolExecutionLike(this)) {
1029
1260
  const cached = (this as any)[TOOL_RENDER_CACHE];
1030
1261
  const branchKey = toolBranchRenderCacheKey();
@@ -1213,12 +1444,12 @@ const OSC133_ZONE_START = "\x1b]133;A\x07";
1213
1444
  const OSC133_ZONE_END = "\x1b]133;B\x07";
1214
1445
  const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
1215
1446
  const WORKED_DURATION_KEY = "_piClaudeStyleWorkedDurationMs";
1216
- const THINKING_DURATION_KEY = "_piClaudeStyleThinkingDurationMs";
1217
- const THINKING_ACTIVE_KEY = "_piClaudeStyleThinkingActive";
1218
1447
  const WORKED_START_KEY = "_piClaudeStyleWorkedStartMs";
1219
1448
  const WORKED_SESSION_TOTAL_KEY = "_piClaudeStyleWorkedSessionTotalMs";
1220
1449
  const WORKED_TURNS_KEY = "_piClaudeStyleWorkedTurns";
1221
1450
  const WORKED_DURATION_MARKER = "Turn took";
1451
+ const THINKING_DURATION_KEY = "_piClaudeStyleThinkingDurationMs";
1452
+ const THINKING_ACTIVE_KEY = "_piClaudeStyleThinkingActive";
1222
1453
  const MIN_THINKING_SUMMARY_MS = 100;
1223
1454
 
1224
1455
  let lastThinkingBlockDurationMs: number | undefined;
@@ -1335,32 +1566,43 @@ class HiddenThinkingSummary {
1335
1566
  }
1336
1567
  }
1337
1568
 
1338
- function assistantMessageThinkingComplete(message: any): boolean {
1339
- // toolUse is an intermediate assistant chunk — thinking may still be in progress on the next chunk.
1340
- const reason = message?.stopReason;
1341
- if (reason === "toolUse") return false;
1342
- return typeof reason === "string" && reason.length > 0;
1569
+ function getMessageThinkingDurationMs(message: any): number {
1570
+ const stored = (message as any)?.[THINKING_DURATION_KEY];
1571
+ if (typeof stored === "number" && stored > 0) return stored;
1572
+ if (typeof lastThinkingBlockDurationMs === "number" && lastThinkingBlockDurationMs > 0) {
1573
+ return lastThinkingBlockDurationMs;
1574
+ }
1575
+ if (typeof (message as any)?.[WORKED_DURATION_KEY] === "number" && (message as any)[WORKED_DURATION_KEY] > 0) {
1576
+ return (message as any)[WORKED_DURATION_KEY];
1577
+ }
1578
+ let totalChars = 0;
1579
+ if (Array.isArray(message?.content)) {
1580
+ for (const block of message.content) {
1581
+ if (block?.type === "thinking" && typeof block.thinking === "string") {
1582
+ totalChars += block.thinking.length;
1583
+ }
1584
+ }
1585
+ }
1586
+ return Math.max(1000, Math.round((totalChars / 150) * 1000));
1343
1587
  }
1344
1588
 
1345
- function hiddenThinkingSummaryForMessage(message: any): string {
1346
- // Per-message flags win over globals so a late render pass cannot keep
1347
- // "Thinking…" after thinking_end already stored duration on this message.
1348
- if ((message as any)?.[THINKING_ACTIVE_KEY]) return thinkingActiveSummaryText();
1349
- const stored = (message as any)?.[THINKING_DURATION_KEY];
1350
- const durationMs = typeof stored === "number"
1351
- ? stored
1352
- : assistantMessageThinkingComplete(message) && typeof lastThinkingBlockDurationMs === "number"
1353
- ? lastThinkingBlockDurationMs
1354
- : undefined;
1355
- if (typeof durationMs === "number" && durationMs >= MIN_THINKING_SUMMARY_MS) {
1356
- return thoughtDurationSummaryText(durationMs);
1589
+ function assistantMessageThinkingComplete(this: any, message: any): boolean {
1590
+ return isAssistantThinkingComplete(this, message);
1591
+ }
1592
+
1593
+ function hiddenThinkingSummaryForMessage(message: any, comp?: any): string {
1594
+ if (message && !isAssistantThinkingComplete(comp, message) && isLiveThinkingMessage(comp, message)) {
1595
+ return thinkingActiveSummaryText();
1357
1596
  }
1358
- if (thinkingBlockInFlight) return thinkingActiveSummaryText();
1359
- return thinkingActiveSummaryText();
1597
+ const durationMs = getMessageThinkingDurationMs(message);
1598
+ if (message && typeof message === "object") {
1599
+ (message as any)[THINKING_DURATION_KEY] = durationMs;
1600
+ }
1601
+ return thoughtDurationSummaryText(durationMs);
1360
1602
  }
1361
1603
 
1362
1604
  function isHiddenThinkingPlaceholderText(child: unknown): child is InstanceType<typeof Text> {
1363
- if (!(child instanceof Text)) return false;
1605
+ if (!isTextComponent(child)) return false;
1364
1606
  const plain = stripAnsi(String((child as any).text ?? "")).trim();
1365
1607
  if (/^[✻∴]\s*Thinking/i.test(plain)) return true;
1366
1608
  if (/^[✻∴]\s*Thought for/i.test(plain)) return true;
@@ -1383,10 +1625,6 @@ function workedDurationText(ms: number, sessionTotalMs?: number, turns?: number)
1383
1625
  return `${text}${RESET}`;
1384
1626
  }
1385
1627
 
1386
- function inlineWorkedDurationText(ms: number, sessionTotalMs?: number, turns?: number): string {
1387
- return workedDurationText(ms, sessionTotalMs, turns);
1388
- }
1389
-
1390
1628
  function isWorkedDurationLine(line: string): boolean {
1391
1629
  return line.includes(WORKED_DURATION_MARKER) && /^✻ Turn took [^\r\n]+$/.test(stripAnsi(line).trim());
1392
1630
  }
@@ -1408,8 +1646,6 @@ function hasWorkedDurationLine(message: any): boolean {
1408
1646
  });
1409
1647
  }
1410
1648
 
1411
-
1412
-
1413
1649
  type MarkdownThemeLike = ConstructorParameters<typeof Markdown>[3];
1414
1650
 
1415
1651
  type ParagraphSegment =
@@ -1516,6 +1752,30 @@ function sanitizeToolResultForDisplay(result: any): any {
1516
1752
  return changed ? { ...result, content } : result;
1517
1753
  }
1518
1754
 
1755
+ // Last-resort display scrubber at the terminal writer choke point. Every
1756
+ // rendered frame — every component, overlay, preview, and search hit — exits
1757
+ // through ProcessTerminal.write, so stripping complete §N§ tokens there covers
1758
+ // any surface the targeted strips above can't reach, including mid-sentence
1759
+ // tag references replayed from old tool output on resume. Display only:
1760
+ // storage, LLM context, copy/paste sources, and ANSI sequences are untouched
1761
+ // (tags are plain characters; escape sequences never contain them).
1762
+ const MAGIC_CONTEXT_TAG_TOKEN = /§\d+§/g;
1763
+ const TERMINAL_SCRUB_PATCH_FLAG = Symbol.for("pi-claude-style-tools:terminal-write-tag-scrub");
1764
+
1765
+ function patchTerminalWriteTagScrubber(): void {
1766
+ const proto = (ProcessTerminal as any)?.prototype;
1767
+ if (!proto || proto[TERMINAL_SCRUB_PATCH_FLAG]) return;
1768
+ const originalWrite = proto.write;
1769
+ if (typeof originalWrite !== "function") return;
1770
+ proto.write = function patchedTerminalWrite(this: any, data: any, ...rest: any[]) {
1771
+ if (typeof data === "string" && data.includes("§")) {
1772
+ data = data.replace(MAGIC_CONTEXT_TAG_TOKEN, "");
1773
+ }
1774
+ return originalWrite.call(this, data, ...rest);
1775
+ };
1776
+ proto[TERMINAL_SCRUB_PATCH_FLAG] = true;
1777
+ }
1778
+
1519
1779
  function replaceInlineMath(text: string): string {
1520
1780
  if (!hasInlineMathMarkers(text)) return text;
1521
1781
  const withParens = text.replace(/\\\(([\s\S]*?)\\\)/g, (_match, body: string) => {
@@ -1780,14 +2040,27 @@ class DottedParagraph {
1780
2040
  function replaceHiddenThinkingPlaceholders(container: { children?: any[] }, message: any): void {
1781
2041
  if (!container?.children) return;
1782
2042
  const summary = hiddenThinkingSummaryForMessage(message);
2043
+ let firstReplaced = false;
1783
2044
  for (let i = 0; i < container.children.length; i++) {
1784
2045
  const child = container.children[i];
1785
- if (child instanceof HiddenThinkingSummary) {
1786
- child.setSummary(summary);
2046
+ if (child instanceof HiddenThinkingSummary || (child as any)?.constructor?.name === "HiddenThinkingSummary") {
2047
+ if (!firstReplaced) {
2048
+ child.setSummary(summary);
2049
+ firstReplaced = true;
2050
+ } else {
2051
+ container.children.splice(i, 1);
2052
+ i--;
2053
+ }
1787
2054
  continue;
1788
2055
  }
1789
2056
  if (isHiddenThinkingPlaceholderText(child)) {
1790
- container.children[i] = new HiddenThinkingSummary(summary);
2057
+ if (!firstReplaced) {
2058
+ container.children[i] = new HiddenThinkingSummary(summary);
2059
+ firstReplaced = true;
2060
+ } else {
2061
+ container.children.splice(i, 1);
2062
+ i--;
2063
+ }
1791
2064
  }
1792
2065
  }
1793
2066
  }
@@ -2073,7 +2346,7 @@ function visitMarkdownDescendants(root: unknown, visit: (md: InstanceType<typeof
2073
2346
  if (!root || typeof root !== "object") return;
2074
2347
  const node = root as { children?: unknown[] };
2075
2348
  for (const child of node.children ?? []) {
2076
- if (child instanceof Markdown) visit(child);
2349
+ if (isMarkdownComponent(child)) visit(child);
2077
2350
  else visitMarkdownDescendants(child, visit);
2078
2351
  }
2079
2352
  }
@@ -2107,7 +2380,7 @@ function patchUserMessageRender(): void {
2107
2380
  if (!Array.isArray(lines) || lines.length === 0) return lines;
2108
2381
  const rendered = [
2109
2382
  roundedUserBorder(borderWidth, true),
2110
- ...lines.map((line: string) => borderedUserMessageLine(line, borderWidth)),
2383
+ ...lines.slice(1, -1).map((line: string) => borderedUserMessageLine(line, borderWidth)),
2111
2384
  roundedUserBorder(borderWidth, false),
2112
2385
  ];
2113
2386
  const clamped = rendered.map((line) => clampLineWidth(line, borderWidth));
@@ -2146,7 +2419,7 @@ function patchAssistantMessages(): void {
2146
2419
  proto[ASSISTANT_RENDER_PATCH_FLAG] = true;
2147
2420
  }
2148
2421
  const originalUpdateContent = proto.updateContent;
2149
- proto.updateContent = function patchedUpdateContent(message: any) {
2422
+ proto.updateContent = function patchedUpdateContent(message: any, isStreaming?: boolean) {
2150
2423
  // Content changed (also reached via invalidate() → updateContent): drop the
2151
2424
  // cached rendered output so the next render rebuilds with the new children.
2152
2425
  clearMessageRenderCache(this);
@@ -2154,24 +2427,39 @@ function patchAssistantMessages(): void {
2154
2427
  (this as any)[WORKED_START_KEY] = Date.now();
2155
2428
  }
2156
2429
  if (!message || !Array.isArray(message.content)) {
2157
- return originalUpdateContent.call(this, message);
2430
+ return originalUpdateContent.call(this, message, isStreaming);
2158
2431
  }
2159
- if ((this as any).hideThinkingBlock && messageHasThinkingContent(message)) {
2432
+ // Thinking display:
2433
+ // When thinking blocks are expanded via Ctrl+T (`hideThinkingBlock === false`),
2434
+ // all thinking blocks (old and new) render in full markdown.
2435
+ // When thinking blocks are collapsed (`hideThinkingBlock === true`):
2436
+ // - "live" mode (default): the actively-streaming thinking block renders
2437
+ // expanded while streaming, and collapses to `Thought for Xs` once done.
2438
+ // - "full" mode: behaves like stock pi (stays collapsed).
2439
+ const liveMode = getThinkingMode() === "live";
2440
+ const thinkingCollapsed = !!(this as any).hideThinkingBlock;
2441
+ const showLiveThinking = liveMode && thinkingCollapsed && isLiveThinkingMessage(this, message);
2442
+ if (thinkingCollapsed && messageHasThinkingContent(message)) {
2160
2443
  // Pi wraps this in theme.italic/fg again — keep plain label for the placeholder pass.
2161
2444
  (this as any).hiddenThinkingLabel = "Thinking…";
2162
2445
  }
2163
- // Call original to build all children (text, thinking, spacers, errors)
2164
- originalUpdateContent.call(this, message);
2446
+ if (showLiveThinking) (this as any).hideThinkingBlock = false;
2447
+ try {
2448
+ // Call original to build all children (text, thinking, spacers, errors)
2449
+ originalUpdateContent.call(this, message, isStreaming);
2450
+ } finally {
2451
+ if (showLiveThinking) (this as any).hideThinkingBlock = true;
2452
+ }
2165
2453
  // Replace text-block Markdown children with DottedParagraph wrappers
2166
2454
  const container = (this as any).contentContainer;
2167
2455
  if (!container?.children) return;
2168
- if ((this as any).hideThinkingBlock && messageHasThinkingContent(message)) {
2456
+ if (thinkingCollapsed && !showLiveThinking && messageHasThinkingContent(message)) {
2169
2457
  replaceHiddenThinkingPlaceholders(container, message);
2170
2458
  }
2171
2459
  const mdTheme = (this as any).markdownTheme;
2172
2460
  for (let i = container.children.length - 1; i >= 0; i--) {
2173
2461
  const child = container.children[i];
2174
- if (child instanceof Markdown) {
2462
+ if (isMarkdownComponent(child)) {
2175
2463
  const text = (child as any).text;
2176
2464
  if (!text) continue;
2177
2465
  const isThinking = !!(child as any).defaultTextStyle?.italic;
@@ -2187,16 +2475,11 @@ function patchAssistantMessages(): void {
2187
2475
  const explicitSessionTotal = (message as any)[WORKED_SESSION_TOTAL_KEY];
2188
2476
  const explicitTurns = (message as any)[WORKED_TURNS_KEY];
2189
2477
  // The "Turn took" line must only appear once the stream has truly closed.
2190
- // `message.stopReason === "stop"` is NOT a safe "finished" signal here: the
2191
- // Anthropic provider initializes the live message's stopReason to "stop" at
2192
- // creation and only updates it to the real value when `message_delta` arrives
2193
- // near the end of the stream — so it is already "stop" while text is still
2194
- // streaming, which made the line appear mid-stream. `explicitDuration` is
2195
- // stamped onto the message by the `message_end` handler (which fires after
2196
- // `message_delta`, when stopReason is the real final value), so gating on it
2197
- // guarantees the line shows only after the run is actually done. The line is
2198
- // baked into the message text at message_end; this child is just a fallback
2199
- // for re-renders where that baked text isn't present.
2478
+ // `message.stopReason === "stop"` is not a safe "finished" signal here because
2479
+ // providers may initialize a live message with that value. The `message_end`
2480
+ // handler stamps `explicitDuration` after the final stream event. Render the
2481
+ // styled line as a TUI child so ANSI presentation never enters message content
2482
+ // or persisted session transcripts.
2200
2483
  const isFinalAssistantMessage = message.stopReason === "stop";
2201
2484
  const workedDuration = typeof explicitDuration === "number" ? explicitDuration : undefined;
2202
2485
  const workedSessionTotal = typeof explicitSessionTotal === "number"
@@ -2406,6 +2689,82 @@ function liveLineCountTrailing(ctx: any, theme: Theme): string {
2406
2689
  return ` ${theme.fg("muted", `(${lineCountLabel(count)})`)}`;
2407
2690
  }
2408
2691
 
2692
+ const BASH_STARTED_AT_KEY = "_bashStartedAtMs";
2693
+ const BASH_ENDED_AT_KEY = "_bashEndedAtMs";
2694
+
2695
+ type BashDurationEntry = { invalidate: () => void };
2696
+
2697
+ const BASH_DURATION_CONTEXTS = new Map<any, BashDurationEntry>();
2698
+ let bashDurationTimer: ReturnType<typeof setTimeout> | null = null;
2699
+
2700
+ function scheduleBashDurationTick(): void {
2701
+ if (bashDurationTimer || BASH_DURATION_CONTEXTS.size === 0) return;
2702
+ bashDurationTimer = setTimeout(() => {
2703
+ bashDurationTimer = null;
2704
+ for (const entry of BASH_DURATION_CONTEXTS.values()) {
2705
+ try { entry.invalidate(); } catch { /* noop */ }
2706
+ }
2707
+ scheduleBashDurationTick();
2708
+ }, 1_000);
2709
+ unrefTimer(bashDurationTimer);
2710
+ }
2711
+
2712
+ function registerBashDurationContext(ctx: any): void {
2713
+ const key = ctx?.state ?? ctx;
2714
+ if (!key) return;
2715
+ const invalidate = typeof ctx?.invalidate === "function" ? () => safeInvalidate(ctx) : () => {};
2716
+ BASH_DURATION_CONTEXTS.set(key, { invalidate });
2717
+ scheduleBashDurationTick();
2718
+ }
2719
+
2720
+ function clearBashDurationContext(ctx: any): void {
2721
+ const key = ctx?.state ?? ctx;
2722
+ if (key) BASH_DURATION_CONTEXTS.delete(key);
2723
+ if (bashDurationTimer && BASH_DURATION_CONTEXTS.size === 0) {
2724
+ clearTimeout(bashDurationTimer);
2725
+ bashDurationTimer = null;
2726
+ }
2727
+ }
2728
+
2729
+ function clearAllBashDurationContexts(): void {
2730
+ BASH_DURATION_CONTEXTS.clear();
2731
+ if (bashDurationTimer) {
2732
+ clearTimeout(bashDurationTimer);
2733
+ bashDurationTimer = null;
2734
+ }
2735
+ }
2736
+
2737
+ function syncBashDuration(ctx: any, isPartial = true): void {
2738
+ const state = ctx?.state;
2739
+ if (!state) return;
2740
+ if (state._toolStatus === "pending" && typeof state[BASH_STARTED_AT_KEY] !== "number") {
2741
+ state[BASH_STARTED_AT_KEY] = Date.now();
2742
+ delete state[BASH_ENDED_AT_KEY];
2743
+ }
2744
+ const startedAt = state[BASH_STARTED_AT_KEY];
2745
+ if (typeof startedAt !== "number") return;
2746
+ if (!isPartial || ctx?.isError) {
2747
+ if (typeof state[BASH_ENDED_AT_KEY] !== "number") state[BASH_ENDED_AT_KEY] = Date.now();
2748
+ clearBashDurationContext(ctx);
2749
+ return;
2750
+ }
2751
+ registerBashDurationContext(ctx);
2752
+ }
2753
+
2754
+ function bashHeaderTrailing(ctx: any, theme: Theme): string {
2755
+ const parts: string[] = [];
2756
+ if (ctx?.isPartial === true) {
2757
+ const count = ctx?.state?._liveLineCount;
2758
+ if (typeof count === "number" && Number.isFinite(count) && count > 0) parts.push(lineCountLabel(count));
2759
+ }
2760
+ const startedAt = ctx?.state?.[BASH_STARTED_AT_KEY];
2761
+ if (typeof startedAt === "number") {
2762
+ const endedAt = ctx?.state?.[BASH_ENDED_AT_KEY];
2763
+ parts.push(formatBashDuration((typeof endedAt === "number" ? endedAt : Date.now()) - startedAt));
2764
+ }
2765
+ return parts.length > 0 ? `${TRAILING_MARK}${theme.fg("muted", parts.join(" · "))}` : "";
2766
+ }
2767
+
2409
2768
  function setToolStatus(ctx: any, status: "pending" | "success" | "error" | "idle"): void {
2410
2769
  if (ctx?.state) ctx.state._toolStatus = status;
2411
2770
  }
@@ -2682,6 +3041,10 @@ function withBranch(content: string, theme: Theme, _isError = false, continued =
2682
3041
  return `${branchLead(first, continued, theme)}\n${rest.join("\n")}`;
2683
3042
  }
2684
3043
 
3044
+ function withClippedBranch(content: string, theme: Theme, continued = false): string {
3045
+ return withBranch(content, theme, false, continued).replaceAll(WRAP_MARK, CLIP_MARK);
3046
+ }
3047
+
2685
3048
  function withFinalBranchBlock(content: string, theme: Theme, isError = false): string {
2686
3049
  if (!content || !content.trim()) return "";
2687
3050
  const lines = content.split("\n");
@@ -2952,6 +3315,16 @@ function markedContinuationPrefix(prefix: string): string {
2952
3315
  }
2953
3316
 
2954
3317
  function wrapMarkedLine(line: string, width: number): string[] {
3318
+ const clipIndex = line.indexOf(CLIP_MARK);
3319
+ if (clipIndex !== -1) {
3320
+ const prefix = line.slice(0, clipIndex);
3321
+ const body = line.slice(clipIndex + CLIP_MARK.length);
3322
+ if (body.includes(TRAILING_MARK)) return [alignTrailingMarkedLine(`${prefix}${body}`, width)];
3323
+ const bodyWidth = Math.max(1, width - visibleWidth(prefix));
3324
+ if (visibleWidth(body) <= bodyWidth) return [`${prefix}${body}`];
3325
+ const hint = "…";
3326
+ return [`${prefix}${truncateToWidth(body, Math.max(0, bodyWidth - visibleWidth(hint)), "", false)}${hint}`];
3327
+ }
2955
3328
  const markerIndex = line.indexOf(WRAP_MARK);
2956
3329
  if (markerIndex === -1) return wrapTextWithAnsi(line, width);
2957
3330
  const prefix = line.slice(0, markerIndex);
@@ -2968,6 +3341,10 @@ class ToolText extends Text {
2968
3341
  private toolCachedValue?: string;
2969
3342
  private toolCachedWidth?: number;
2970
3343
  private toolCachedLines?: string[];
3344
+ private observedWidth?: number;
3345
+ private pendingObservedWidth?: number;
3346
+ private widthObserver?: (width: number) => void;
3347
+ private widthObserverScheduled = false;
2971
3348
 
2972
3349
  constructor(text = "") {
2973
3350
  super("", 0, 0);
@@ -2980,6 +3357,26 @@ class ToolText extends Text {
2980
3357
  this.invalidate();
2981
3358
  }
2982
3359
 
3360
+ setWidthObserver(observer?: (width: number) => void): void {
3361
+ this.widthObserver = observer;
3362
+ if (!observer) this.pendingObservedWidth = undefined;
3363
+ }
3364
+
3365
+ private observeWidth(width: number): void {
3366
+ if (this.observedWidth === width) return;
3367
+ this.observedWidth = width;
3368
+ if (!this.widthObserver) return;
3369
+ this.pendingObservedWidth = width;
3370
+ if (this.widthObserverScheduled) return;
3371
+ this.widthObserverScheduled = true;
3372
+ queueMicrotask(() => {
3373
+ this.widthObserverScheduled = false;
3374
+ const observed = this.pendingObservedWidth;
3375
+ this.pendingObservedWidth = undefined;
3376
+ if (observed !== undefined) this.widthObserver?.(observed);
3377
+ });
3378
+ }
3379
+
2983
3380
  invalidate(): void {
2984
3381
  this.toolCachedValue = undefined;
2985
3382
  this.toolCachedWidth = undefined;
@@ -2987,6 +3384,7 @@ class ToolText extends Text {
2987
3384
  }
2988
3385
 
2989
3386
  render(width: number): string[] {
3387
+ this.observeWidth(width);
2990
3388
  const branchKey = toolBranchRenderCacheKey();
2991
3389
  if (
2992
3390
  this.toolCachedLines
@@ -3003,7 +3401,9 @@ class ToolText extends Text {
3003
3401
  }
3004
3402
  const contentWidth = Math.max(1, width);
3005
3403
  const lines = this.value.replace(/\t/g, " ").split("\n");
3006
- const rendered = lines.flatMap((line) => wrapMarkedLine(line, contentWidth)).map((line) => padToWidth(line, width));
3404
+ const rendered = lines
3405
+ .flatMap((line) => wrapMarkedLine(line, contentWidth))
3406
+ .map((line) => padToWidth(line, width));
3007
3407
  this.toolCachedValue = this.value;
3008
3408
  this.toolCachedWidth = width;
3009
3409
  this.toolCachedLines = rendered;
@@ -3015,10 +3415,21 @@ class ToolText extends Text {
3015
3415
 
3016
3416
  function makeText(last: unknown, text: string): Text {
3017
3417
  const component = last instanceof ToolText ? last : new ToolText();
3418
+ component.setWidthObserver();
3018
3419
  component.setText(text);
3019
3420
  return component;
3020
3421
  }
3021
3422
 
3423
+ function makeResponsiveDiffText(ctx: any, last: unknown, text: string): Text {
3424
+ const component = makeText(last, text) as ToolText;
3425
+ component.setWidthObserver((width) => {
3426
+ if (ctx.state?._diffComponentWidth === width) return;
3427
+ if (ctx.state) ctx.state._diffComponentWidth = width;
3428
+ safeInvalidate(ctx);
3429
+ });
3430
+ return component;
3431
+ }
3432
+
3022
3433
  function previewLimit(): number {
3023
3434
  const value = readSettings().previewLines;
3024
3435
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 8;
@@ -3037,6 +3448,29 @@ function bashCollapsedLimit(): number {
3037
3448
  return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 10;
3038
3449
  }
3039
3450
 
3451
+ function bashCommandPreviewLimit(): number {
3452
+ const value = readSettings().bashCommandPreviewLines;
3453
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 8;
3454
+ }
3455
+
3456
+ function renderBashCommandBlock(
3457
+ command: string,
3458
+ expanded: boolean,
3459
+ theme: Theme,
3460
+ ): string {
3461
+ const presentation = buildBashCommandPresentation(command);
3462
+ const limit = bashCommandPreviewLimit();
3463
+ if (!expanded && (limit === 0 || presentation.sourceLineCount < 2)) return "";
3464
+ const sourceLimit = expandedPreviewLimit();
3465
+ const lines = expanded ? presentation.sourceLines.slice(0, sourceLimit) : buildBashPreview(presentation.sourceLines, limit);
3466
+ if (lines.length === 0) return "";
3467
+ if (expanded && presentation.sourceLines.length > sourceLimit) {
3468
+ lines.push(`... ${presentation.sourceLines.length - sourceLimit} more command lines`);
3469
+ }
3470
+ const body = lines.map((line) => theme.fg("accent", line || " ")).join("\n");
3471
+ return expanded ? withBranch(body, theme, false, true) : withClippedBranch(body, theme, true);
3472
+ }
3473
+
3040
3474
  function liveToolPreviewEnabled(): boolean {
3041
3475
  return readSettings().liveToolPreview !== false;
3042
3476
  }
@@ -3504,12 +3938,12 @@ function rebindUiChromeToTheme(ctx: any): void {
3504
3938
  const theme = ctx.ui?.theme;
3505
3939
  invalidateThemePaletteCache();
3506
3940
  clearHighlightCache();
3507
- autoDerivePending = true;
3941
+ applyDiffPalette();
3508
3942
  bustSpinnerSettingsCache();
3509
3943
  applyToolBackgroundMode(theme);
3510
3944
  applyThemePaletteIfNeeded(theme);
3511
3945
  syncDiffShikiTheme(theme);
3512
- if (themeAdaptiveEnabled() && theme?.getFgAnsi) {
3946
+ if (themeAdaptiveEnabled() && theme?.getFgAnsi && !hasExplicitBgConfig) {
3513
3947
  autoDeriveBgFromTheme(theme);
3514
3948
  autoDerivePending = false;
3515
3949
  }
@@ -3746,6 +4180,7 @@ function applyThemePaletteIfNeeded(theme: any): void {
3746
4180
 
3747
4181
  function applyDiffPalette(): void {
3748
4182
  const config = loadDiffConfig();
4183
+ hasExplicitBgConfig = false;
3749
4184
  const preset = config.diffTheme ? DIFF_PRESETS[config.diffTheme] : null;
3750
4185
  if (preset) hasExplicitBgConfig = true;
3751
4186
  const overrides = config.diffColors ?? {};
@@ -3866,8 +4301,15 @@ function termW(): number {
3866
4301
  return Math.max(40, Math.min(raw - 4, MAX_TERM_WIDTH));
3867
4302
  }
3868
4303
 
3869
- function branchDiffWidth(): number {
3870
- return Math.max(40, termW() - 8);
4304
+ function branchDiffWidth(componentWidth?: number, chromeWidth = 2): number {
4305
+ const width = typeof componentWidth === "number" && Number.isFinite(componentWidth)
4306
+ ? Math.floor(componentWidth)
4307
+ : termW();
4308
+ return Math.max(20, Math.min(width - chromeWidth, MAX_TERM_WIDTH));
4309
+ }
4310
+
4311
+ function contextDiffWidth(ctx: any, chromeWidth = 2): number {
4312
+ return branchDiffWidth(ctx.state?._diffComponentWidth, chromeWidth);
3871
4313
  }
3872
4314
 
3873
4315
  function adaptiveWrapRows(tw?: number): number {
@@ -4727,7 +5169,7 @@ function renderEditPreviewBody(
4727
5169
  summary: string,
4728
5170
  ): void {
4729
5171
  const dc = resolveDiffColors(theme);
4730
- const branchWidth = branchDiffWidth();
5172
+ const branchWidth = contextDiffWidth(ctx, 3);
4731
5173
  if (operations.length === 1) {
4732
5174
  const [diff] = diffs;
4733
5175
  const line = lines[0] ?? getFirstChangedNewLine(diff);
@@ -4825,16 +5267,30 @@ function trackThinkingBlockEvents(event: any, ctx?: any): void {
4825
5267
  }
4826
5268
  if (evt.type === "thinking_end") {
4827
5269
  thinkingBlockInFlight = false;
4828
- const duration = Date.now() - thinkingBlockStartMs;
5270
+ const duration = Math.max(0, Date.now() - thinkingBlockStartMs);
4829
5271
  if (message?.role === "assistant") delete (message as any)[THINKING_ACTIVE_KEY];
4830
- if (duration >= MIN_THINKING_SUMMARY_MS) {
4831
- lastThinkingBlockDurationMs = duration;
4832
- if (message?.role === "assistant") (message as any)[THINKING_DURATION_KEY] = duration;
4833
- } else {
4834
- lastThinkingBlockDurationMs = undefined;
4835
- if (message?.role === "assistant") delete (message as any)[THINKING_DURATION_KEY];
4836
- }
5272
+ lastThinkingBlockDurationMs = duration;
5273
+ if (message?.role === "assistant") (message as any)[THINKING_DURATION_KEY] = duration;
4837
5274
  refreshThinkingChrome();
5275
+ return;
5276
+ }
5277
+ // Fallback: some providers/models never emit thinking_end (a second
5278
+ // thinking_start can overwrite the first, or the turn can end with toolUse).
5279
+ // The live "Thinking..." row would otherwise stick forever while later calls
5280
+ // run normally. Any non-thinking stream event on the same assistant message
5281
+ // means thinking is no longer the live activity: freeze the elapsed time into
5282
+ // a "Thought for Xs" duration so the row always resolves.
5283
+ if ((message as any)?.[THINKING_ACTIVE_KEY] || thinkingBlockInFlight) {
5284
+ if (evt.type === "text_start" || evt.type === "text_delta" || evt.type === "toolcall_start" || evt.type === "toolcall_end") {
5285
+ thinkingBlockInFlight = false;
5286
+ const duration = thinkingBlockStartMs > 0 ? Math.max(0, Date.now() - thinkingBlockStartMs) : undefined;
5287
+ if (message?.role === "assistant") delete (message as any)[THINKING_ACTIVE_KEY];
5288
+ if (typeof duration === "number") {
5289
+ lastThinkingBlockDurationMs = duration;
5290
+ if (message?.role === "assistant") (message as any)[THINKING_DURATION_KEY] = duration;
5291
+ }
5292
+ refreshThinkingChrome();
5293
+ }
4838
5294
  }
4839
5295
  }
4840
5296
 
@@ -4860,7 +5316,6 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
4860
5316
  }
4861
5317
  if (sessionStartMs === undefined) sessionStartMs = Date.now();
4862
5318
  currentAssistantMessageStartMs = undefined;
4863
- thinkingBlockInFlight = false;
4864
5319
  });
4865
5320
  pi.on("agent_start", async () => {
4866
5321
  if (currentAgentWorkStartMs === undefined) {
@@ -4877,6 +5332,10 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
4877
5332
  if (message?.role === "assistant") {
4878
5333
  currentAssistantMessageStartMs = Date.now();
4879
5334
  (message as any)[WORKED_START_KEY] = currentAssistantMessageStartMs;
5335
+ // A new assistant message starts a fresh thinking lifecycle. Without
5336
+ // this, a missing thinking_end on the previous message leaves the
5337
+ // global in-flight flag set and the next message renders a stale
5338
+ // "Thinking..." row until its own thinking events arrive.
4880
5339
  thinkingBlockInFlight = false;
4881
5340
  delete (message as any)[THINKING_ACTIVE_KEY];
4882
5341
  }
@@ -4888,9 +5347,22 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
4888
5347
  pi.on("message_end", async (event, ctx) => {
4889
5348
  const message = (event as any)?.message;
4890
5349
  if (message?.role === "assistant") {
4891
- if (typeof lastThinkingBlockDurationMs === "number") {
4892
- (message as any)[THINKING_DURATION_KEY] = lastThinkingBlockDurationMs;
5350
+ // Belt-and-suspenders: if thinking_end never fired (provider skipped
5351
+ // it, or the turn ended on toolUse/text), freeze any live "Thinking..."
5352
+ // into its "Thought for Xs" duration here so the row always resolves
5353
+ // at the end of the message instead of sticking into later calls.
5354
+ thinkingBlockInFlight = false;
5355
+ delete (message as any)[THINKING_ACTIVE_KEY];
5356
+ if (typeof (message as any)[THINKING_DURATION_KEY] !== "number") {
5357
+ const duration = thinkingBlockStartMs > 0 ? Math.max(0, Date.now() - thinkingBlockStartMs) : undefined;
5358
+ if (typeof duration === "number" && duration > 0) {
5359
+ lastThinkingBlockDurationMs = duration;
5360
+ (message as any)[THINKING_DURATION_KEY] = duration;
5361
+ } else if (typeof lastThinkingBlockDurationMs === "number" && lastThinkingBlockDurationMs > 0) {
5362
+ (message as any)[THINKING_DURATION_KEY] = lastThinkingBlockDurationMs;
5363
+ }
4893
5364
  }
5365
+ thinkingBlockStartMs = 0;
4894
5366
  const started = typeof currentAgentWorkStartMs === "number"
4895
5367
  ? currentAgentWorkStartMs
4896
5368
  : typeof (message as any)[WORKED_START_KEY] === "number"
@@ -4904,6 +5376,8 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
4904
5376
  (message as any)[WORKED_DURATION_KEY] = durationMs;
4905
5377
  if (typeof sessionTotalMs === "number") (message as any)[WORKED_SESSION_TOTAL_KEY] = sessionTotalMs;
4906
5378
  if (typeof turns === "number") (message as any)[WORKED_TURNS_KEY] = turns;
5379
+ // Duration metadata drives the assistant component's TUI-only status line.
5380
+ // Message content stays presentation-neutral for persistence and consumers.
4907
5381
  }
4908
5382
  currentAssistantMessageStartMs = undefined;
4909
5383
  }
@@ -5543,7 +6017,7 @@ function renderApplyPatchCall(args: any, theme: Theme, ctx: any, sp: (path: stri
5543
6017
  }
5544
6018
  ctx.state._openAiPatchFiles = preview.changes.map((change) => change.displayPath);
5545
6019
 
5546
- const diffWidth = branchDiffWidth();
6020
+ const diffWidth = contextDiffWidth(ctx);
5547
6021
  const key = `apply-preview:${ctx.state._applyPatchMetaKey ?? hashText(patchText)}:${diffWidth}:${ctx.expanded ? 1 : 0}`;
5548
6022
  if (ctx.state._applyPatchPreviewKey !== key) {
5549
6023
  ctx.state._applyPatchPreviewKey = key;
@@ -5596,7 +6070,7 @@ function renderApplyPatchCall(args: any, theme: Theme, ctx: any, sp: (path: stri
5596
6070
  }
5597
6071
 
5598
6072
  const body = ctx.state._applyPatchPreviewDisplay as string | undefined;
5599
- return makeText(ctx.lastComponent, body ? `${hdr}\n${body}` : hdr);
6073
+ return makeResponsiveDiffText(ctx, ctx.lastComponent, body ? `${hdr}\n${body}` : hdr);
5600
6074
  }
5601
6075
 
5602
6076
  function renderApplyPatchResult(result: any, isPartial: boolean, theme: Theme, ctx: any): Text {
@@ -5945,6 +6419,7 @@ function renderOpenAiToolResult(name: string, result: any, expanded: boolean, is
5945
6419
  // ===========================================================================
5946
6420
 
5947
6421
  export default function (pi: ExtensionAPI) {
6422
+ patchTerminalWriteTagScrubber();
5948
6423
  patchToolExecutionBackgroundSync();
5949
6424
  patchToolRenderCacheInvalidation();
5950
6425
  patchReadImageExpansion();
@@ -5972,7 +6447,7 @@ export default function (pi: ExtensionAPI) {
5972
6447
  // /cc-tools command — control tool chrome, grouping, and detail level.
5973
6448
  const TOOL_MODES = ["outlines", "transparent", "default"] as const;
5974
6449
  const TOOL_BOOL_MODES = ["on", "off", "toggle", "status"] as const;
5975
- const TOOL_SUBCOMMANDS = [...TOOL_MODES, "group", "detail", "branch", "status"] as const;
6450
+ const TOOL_SUBCOMMANDS = [...TOOL_MODES, "group", "detail", "thinking", "branch", "status"] as const;
5976
6451
  const booleanMode = (raw: string | undefined, current: boolean): boolean | "status" | undefined => {
5977
6452
  const mode = raw || "toggle";
5978
6453
  if (mode === "on") return true;
@@ -5995,6 +6470,7 @@ export default function (pi: ExtensionAPI) {
5995
6470
  ctx.ui.notify([
5996
6471
  `Tool style: ${toolBackgroundMode}`,
5997
6472
  `Tool grouping: ${toolGroupingEnabled() ? "on" : "off"}`,
6473
+ `Thinking: ${getThinkingMode()}`,
5998
6474
  `Extra detail: ${extraToolOutputExpanded ? "on" : "off"} (${rawKeyHint("ctrl+shift+o", "toggle")})`,
5999
6475
  branchLine,
6000
6476
  ` /cc-tools branch <0-255> | theme | fixed | reset`,
@@ -6013,6 +6489,7 @@ export default function (pi: ExtensionAPI) {
6013
6489
  label: m,
6014
6490
  description:
6015
6491
  m === "group" ? "Toggle grouped adjacent/concurrent tool rows"
6492
+ : m === "thinking" ? "Thinking display: live (default) or full"
6016
6493
  : m === "detail" ? "Toggle Ctrl+Shift+O extra-detail mode"
6017
6494
  : m === "branch" ? "├ └ │ gray (0-255), theme, fixed, or reset"
6018
6495
  : m === "status" ? "Show tool UI settings"
@@ -6028,6 +6505,12 @@ export default function (pi: ExtensionAPI) {
6028
6505
  .filter((o) => o.startsWith(second))
6029
6506
  .map((o) => ({ value: `branch ${o}`, label: o, description: "Branch connector color" }));
6030
6507
  }
6508
+ if (first === "thinking") {
6509
+ const second = parts[1] ?? "";
6510
+ return ["live", "full", "status"]
6511
+ .filter((m) => m.startsWith(second))
6512
+ .map((m) => ({ value: `thinking ${m}`, label: m, description: `${m} thinking display` }));
6513
+ }
6031
6514
  if (first === "group" || first === "detail" || first === "extra") {
6032
6515
  const second = parts[1] ?? "";
6033
6516
  return TOOL_BOOL_MODES
@@ -6100,6 +6583,24 @@ export default function (pi: ExtensionAPI) {
6100
6583
  return;
6101
6584
  }
6102
6585
 
6586
+ if (sub === "thinking") {
6587
+ const arg = (parts[1] ?? "status").toLowerCase();
6588
+ if (arg === "status" || !arg) {
6589
+ if (ctx.hasUI) ctx.ui.notify(`Thinking: ${getThinkingMode()} (live = only streaming thinking expands; full = always expanded)`, "info");
6590
+ return;
6591
+ }
6592
+ if (arg !== "live" && arg !== "full") {
6593
+ if (ctx.hasUI) ctx.ui.notify("Usage: /cc-tools thinking live|full|status", "error");
6594
+ return;
6595
+ }
6596
+ writeSettingsKey("thinkingMode", arg);
6597
+ if (ctx.hasUI) {
6598
+ ctx.ui.setToolsExpanded(ctx.ui.getToolsExpanded());
6599
+ ctx.ui.notify(`Thinking → ${arg}${arg === "live" ? " (only the active thinking expands)" : " (thinking always expanded)"}`, "info");
6600
+ }
6601
+ return;
6602
+ }
6603
+
6103
6604
  if (sub === "detail" || sub === "extra") {
6104
6605
  const next = booleanMode(parts[1], extraToolOutputExpanded);
6105
6606
  if (next === undefined) {
@@ -6119,7 +6620,7 @@ export default function (pi: ExtensionAPI) {
6119
6620
  }
6120
6621
 
6121
6622
  if (!(TOOL_MODES as readonly string[]).includes(sub)) {
6122
- if (ctx.hasUI) ctx.ui.notify(`Unknown option "${sub}". Try /cc-tools status, /cc-tools branch 72, or /cc-tools group toggle.`, "error");
6623
+ if (ctx.hasUI) ctx.ui.notify(`Unknown option "${sub}". Try /cc-tools status, /cc-tools thinking live, or /cc-tools group toggle.`, "error");
6123
6624
  return;
6124
6625
  }
6125
6626
  toolBackgroundOverride = sub as typeof toolBackgroundMode;
@@ -6413,15 +6914,27 @@ export default function (pi: ExtensionAPI) {
6413
6914
  },
6414
6915
  renderCall(args, theme, ctx) {
6415
6916
  syncToolCallStatus(ctx);
6917
+ syncBashDuration(ctx);
6416
6918
  const rewrite = ensureRtkRewriteForContext(ctx, args);
6417
- const summary = stableCallSummary(ctx, "_callSummary", () => summarizeText(args.command, 72));
6919
+ const command = typeof args.command === "string" ? args.command : "";
6920
+ const presentation = buildBashCommandPresentation(command);
6921
+ const summary = stableCallSummary(ctx, "_bashHeadline", () => presentation.headline);
6418
6922
  const rtkBadge = rewrite ? theme.fg("muted", " (RTK)") : "";
6419
- return makeText(
6420
- ctx.lastComponent,
6421
- toolHeader("Bash", `${summary}${rtkBadge}`, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
6422
- );
6923
+ const status = ctx?.state?._toolStatus;
6924
+ const showCommand = ctx.argsComplete === true && (status === "pending" || status === "error" || ctx.expanded === true);
6925
+ const commandBlock = showCommand ? renderBashCommandBlock(command, ctx.expanded === true, theme) : "";
6926
+ const headerSummary = ctx.expanded === true && commandBlock ? describeBashSource(presentation) : summary;
6927
+ const header = toolHeader(
6928
+ "Bash",
6929
+ `${headerSummary}${rtkBadge}`,
6930
+ theme,
6931
+ toolStatusDot(ctx, theme),
6932
+ bashHeaderTrailing(ctx, theme),
6933
+ ).replace(WRAP_MARK, CLIP_MARK);
6934
+ return makeText(ctx.lastComponent, commandBlock ? `${header}\n${commandBlock}` : header);
6423
6935
  },
6424
6936
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6937
+ syncBashDuration(ctx, isPartial);
6425
6938
  const details = result.details as BashToolDetails | undefined;
6426
6939
  const rewrite = ensureRtkRewriteForContext(ctx, ctx.args);
6427
6940
  const output = result.content[0]?.type === "text" ? result.content[0].text : "";
@@ -6666,7 +7179,7 @@ export default function (pi: ExtensionAPI) {
6666
7179
  if (d?._type === "diff") {
6667
7180
  const previewLines = ctx.expanded ? MAX_RENDER_LINES : diffCollapsedLimit();
6668
7181
  const hunks = d.diff?.lines?.filter((l: any) => l.type === "sep").length + (d.diff?.lines?.length ? 1 : 0);
6669
- const diffWidth = branchDiffWidth();
7182
+ const diffWidth = contextDiffWidth(ctx);
6670
7183
  const mode = shouldUseSplit(d.diff, diffWidth, previewLines) ? "split" : "unified";
6671
7184
  const richSummary = diffSummaryWithMeta(d.diff.added, d.diff.removed, hunks, mode);
6672
7185
  const key = `wd:${diffWidth}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}:${ctx.expanded ? 1 : 0}`;
@@ -6686,7 +7199,7 @@ export default function (pi: ExtensionAPI) {
6686
7199
  safeInvalidate(ctx);
6687
7200
  });
6688
7201
  }
6689
- return makeText(ctx.lastComponent, ctx.state._wdt ?? withBranch(richSummary, theme));
7202
+ return makeResponsiveDiffText(ctx, ctx.lastComponent, ctx.state._wdt ?? withBranch(richSummary, theme));
6690
7203
  }
6691
7204
  if (d?._type === "noChange") return makeText(ctx.lastComponent, withBranch(theme.fg("muted", "✓ no changes"), theme));
6692
7205
  if (d?._type === "new") {
@@ -6696,7 +7209,7 @@ export default function (pi: ExtensionAPI) {
6696
7209
  const syntheticDiff = getCachedParsedDiff(ctx, `nf-diff:${d.filePath}:${contentHash}`, "", content);
6697
7210
  const richSummary = diffSummaryWithMeta(syntheticDiff.added, 0, 1, "new file");
6698
7211
  const previewLines = ctx.expanded ? MAX_RENDER_LINES : diffCollapsedLimit();
6699
- const diffWidth = branchDiffWidth();
7212
+ const diffWidth = contextDiffWidth(ctx);
6700
7213
  const pk = `nf:${d.filePath}:${contentHash}:${diffWidth}:${ctx.expanded ? 1 : 0}`;
6701
7214
  if (ctx.state._nfk !== pk) {
6702
7215
  ctx.state._nfk = pk;
@@ -6714,7 +7227,7 @@ export default function (pi: ExtensionAPI) {
6714
7227
  safeInvalidate(ctx);
6715
7228
  });
6716
7229
  }
6717
- return makeText(ctx.lastComponent, ctx.state._nft ?? withBranch(`${richSummary} ${theme.fg("muted", `(${lineTotal} lines)`)}`, theme));
7230
+ return makeResponsiveDiffText(ctx, ctx.lastComponent, ctx.state._nft ?? withBranch(`${richSummary} ${theme.fg("muted", `(${lineTotal} lines)`)}`, theme));
6718
7231
  }
6719
7232
  return makeText(ctx.lastComponent, withBranch(theme.fg("success", "Written"), theme));
6720
7233
  },
@@ -6769,7 +7282,7 @@ export default function (pi: ExtensionAPI) {
6769
7282
  syncToolCallStatus(ctx);
6770
7283
  const hdr = toolHeader("Edit", summary, theme, ` ${toolStatusDot(ctx, theme)}`, liveLineCountTrailing(ctx, theme));
6771
7284
  if (!(ctx.argsComplete && operations.length > 0)) return makeText(ctx.lastComponent, hdr);
6772
- const diffWidth = branchDiffWidth();
7285
+ const diffWidth = contextDiffWidth(ctx, 3);
6773
7286
  const key = `edit:${fp}:${hashText(operations.map((edit) => `${edit.oldText}\u0000${edit.newText}`).join("\u0001"))}:${diffWidth}:${ctx.expanded ? 1 : 0}`;
6774
7287
  const { diffs: fallbackDiffs, summary: editSummary } = getCachedEditOperationSummary(ctx, key, operations);
6775
7288
  if (ctx.state._pk !== key) {
@@ -6789,8 +7302,8 @@ export default function (pi: ExtensionAPI) {
6789
7302
  renderEditPreviewBody(ctx, key, theme, lg, operations, fallbackDiffs, fallbackDiffs.map((diff) => getFirstChangedNewLine(diff)), editSummary);
6790
7303
  });
6791
7304
  }
6792
- const body = liveBranchDisplay(ctx.state, theme) ?? (ctx.state._ptDisplay as string | undefined);
6793
- return makeText(ctx.lastComponent, body ? `${hdr}\n${body}` : hdr);
7305
+ const body = liveBranchDisplay(ctx.state, theme) ?? (ctx.state._ptDisplay as string | undefined);
7306
+ return makeResponsiveDiffText(ctx, ctx.lastComponent, body ? `${hdr}\n${body}` : hdr);
6794
7307
  },
6795
7308
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6796
7309
  if (isPartial) {
@@ -6943,6 +7456,7 @@ export default function (pi: ExtensionAPI) {
6943
7456
  });
6944
7457
  pi.on("session_shutdown", async () => {
6945
7458
  _clearAllBlinkContexts();
7459
+ clearAllBashDurationContexts();
6946
7460
  clearRtkRewriteState();
6947
7461
  WRITE_EXISTED_BEFORE.clear();
6948
7462
  clearHighlightCache();