pi-cc-extensions 0.8.13 → 0.8.15

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.
@@ -1,6 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
- DynamicBorder,
4
3
  getSettingsListTheme,
5
4
  keyHint,
6
5
  ToolExecutionComponent,
@@ -10,14 +9,18 @@ import {
10
9
  type CompactStyleHooks,
11
10
  type CompactStyleMode,
12
11
  } from "./compact-style.ts";
12
+ import { showTextPreview } from "./context.ts";
13
13
  import { sanitizeToolResultText } from "./tool-result-sanitize.ts";
14
14
  import {
15
+ DEFAULT_TOOL_DISPLAY_CONFIG,
15
16
  installWriteOverride,
16
17
  renderRichToolResult,
17
18
  WriteExecutionMetadataStore,
19
+ type DiffIndicatorMode,
20
+ type DiffViewMode,
21
+ type ToolDisplayConfig,
18
22
  } from "./tool-diff/index.ts";
19
23
  import {
20
- Container,
21
24
  SettingsList,
22
25
  Text,
23
26
  matchesKey,
@@ -40,10 +43,75 @@ export type Config = {
40
43
  mode: CompactStyleMode;
41
44
  excludeRenderers: string[];
42
45
  fixedEditorFeatures: boolean;
46
+ diffViewMode: DiffViewMode;
47
+ diffIndicatorMode: DiffIndicatorMode;
48
+ diffSplitMinWidth: number;
49
+ diffCollapsedLines: number;
50
+ diffWordWrap: boolean;
51
+ expandedPreviewMaxLines: number;
43
52
  };
44
53
 
45
54
  const CONFIG_PATH = join(homedir(), ".pi", "agent", "claude-code-style.json");
46
55
 
56
+ const DIFF_VIEW_MODES: DiffViewMode[] = ["auto", "split", "unified"];
57
+ const DIFF_INDICATOR_MODES: DiffIndicatorMode[] = ["bars", "classic", "none"];
58
+ const DIFF_SPLIT_MIN_WIDTH_VALUES = ["80", "100", "120", "140", "160", "180"];
59
+ const DIFF_COLLAPSED_LINES_VALUES = ["12", "24", "36", "48", "80", "120"];
60
+ /** Presets for expanded body height — keep low options first so cycling stays TUI-friendly. */
61
+ const EXPANDED_PREVIEW_MAX_LINES_VALUES = ["40", "60", "80", "120", "200", "500", "2000"];
62
+ /** Tools commonly toggled in excludeRenderers via the settings panel. */
63
+ const EXCLUDE_RENDERER_CANDIDATES = [
64
+ "bash",
65
+ "read",
66
+ "edit",
67
+ "write",
68
+ "grep",
69
+ "find",
70
+ "ls",
71
+ "webfetch",
72
+ "wait",
73
+ ];
74
+
75
+ export const DEFAULT_CONFIG: Config = {
76
+ mode: "on",
77
+ excludeRenderers: [],
78
+ fixedEditorFeatures: true,
79
+ diffViewMode: DEFAULT_TOOL_DISPLAY_CONFIG.diffViewMode,
80
+ diffIndicatorMode: DEFAULT_TOOL_DISPLAY_CONFIG.diffIndicatorMode,
81
+ diffSplitMinWidth: DEFAULT_TOOL_DISPLAY_CONFIG.diffSplitMinWidth,
82
+ diffCollapsedLines: DEFAULT_TOOL_DISPLAY_CONFIG.diffCollapsedLines,
83
+ diffWordWrap: DEFAULT_TOOL_DISPLAY_CONFIG.diffWordWrap,
84
+ expandedPreviewMaxLines: DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
85
+ };
86
+
87
+ function pickEnum<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
88
+ return typeof value === "string" && (allowed as readonly string[]).includes(value)
89
+ ? (value as T)
90
+ : fallback;
91
+ }
92
+
93
+ function pickPositiveInt(value: unknown, fallback: number, min = 1, max = 100_000): number {
94
+ const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
95
+ if (!Number.isFinite(n)) return fallback;
96
+ return Math.min(max, Math.max(min, Math.floor(n)));
97
+ }
98
+
99
+ function nearestPreset(value: number, presets: readonly string[]): string {
100
+ const numeric = presets.map((p) => Number(p));
101
+ let best = presets[0] ?? String(value);
102
+ let bestDist = Number.POSITIVE_INFINITY;
103
+ for (let i = 0; i < numeric.length; i++) {
104
+ const dist = Math.abs((numeric[i] ?? 0) - value);
105
+ if (dist < bestDist) {
106
+ bestDist = dist;
107
+ best = presets[i] ?? best;
108
+ }
109
+ }
110
+ // Prefer exact match when value is already a preset.
111
+ const exact = presets.find((p) => Number(p) === value);
112
+ return exact ?? best;
113
+ }
114
+
47
115
  export function normalizeConfig(input: unknown): Config {
48
116
  const source = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
49
117
  const mode = source.mode;
@@ -65,7 +133,65 @@ export function normalizeConfig(input: unknown): Config {
65
133
  ]
66
134
  : [];
67
135
  const fixedEditorFeatures = source.fixedEditorFeatures !== false;
68
- return { mode: migratedMode, excludeRenderers, fixedEditorFeatures };
136
+ return {
137
+ mode: migratedMode,
138
+ excludeRenderers,
139
+ fixedEditorFeatures,
140
+ diffViewMode: pickEnum(source.diffViewMode, DIFF_VIEW_MODES, DEFAULT_CONFIG.diffViewMode),
141
+ diffIndicatorMode: pickEnum(
142
+ source.diffIndicatorMode,
143
+ DIFF_INDICATOR_MODES,
144
+ DEFAULT_CONFIG.diffIndicatorMode,
145
+ ),
146
+ diffSplitMinWidth: pickPositiveInt(
147
+ source.diffSplitMinWidth,
148
+ DEFAULT_CONFIG.diffSplitMinWidth,
149
+ 40,
150
+ 300,
151
+ ),
152
+ diffCollapsedLines: pickPositiveInt(
153
+ source.diffCollapsedLines,
154
+ DEFAULT_CONFIG.diffCollapsedLines,
155
+ 1,
156
+ 500,
157
+ ),
158
+ diffWordWrap: source.diffWordWrap !== false,
159
+ expandedPreviewMaxLines: pickPositiveInt(
160
+ source.expandedPreviewMaxLines,
161
+ DEFAULT_CONFIG.expandedPreviewMaxLines,
162
+ 10,
163
+ 50_000,
164
+ ),
165
+ };
166
+ }
167
+
168
+ export function getToolDisplayConfig(source: Config = config): ToolDisplayConfig {
169
+ return {
170
+ diffViewMode: source.diffViewMode,
171
+ diffIndicatorMode: source.diffIndicatorMode,
172
+ diffSplitMinWidth: source.diffSplitMinWidth,
173
+ diffCollapsedLines: source.diffCollapsedLines,
174
+ diffWordWrap: source.diffWordWrap,
175
+ expandedPreviewMaxLines: source.expandedPreviewMaxLines,
176
+ };
177
+ }
178
+
179
+ function formatExcludeRenderers(names: readonly string[]): string {
180
+ return names.length === 0 ? "none" : names.join(", ");
181
+ }
182
+
183
+ export function formatConfigStatus(source: Config = config): string {
184
+ return [
185
+ `mode=${source.mode}`,
186
+ `fixedEditor=${source.fixedEditorFeatures ? "on" : "off"}`,
187
+ `exclude=[${source.excludeRenderers.join(", ") || "none"}]`,
188
+ `diffView=${source.diffViewMode}`,
189
+ `diffIndicator=${source.diffIndicatorMode}`,
190
+ `diffSplitMin=${source.diffSplitMinWidth}`,
191
+ `diffCollapsed=${source.diffCollapsedLines}`,
192
+ `diffWordWrap=${source.diffWordWrap ? "on" : "off"}`,
193
+ `expandedMax=${source.expandedPreviewMaxLines}`,
194
+ ].join(" · ");
69
195
  }
70
196
 
71
197
  let config: Config = loadConfig();
@@ -95,7 +221,7 @@ function loadConfig(): Config {
95
221
  } catch {
96
222
  // Ignore bad config and fall back to defaults.
97
223
  }
98
- return { mode: "on", excludeRenderers: [], fixedEditorFeatures: true };
224
+ return { ...DEFAULT_CONFIG };
99
225
  }
100
226
 
101
227
  function saveConfig() {
@@ -111,7 +237,8 @@ function oneLine(value: unknown, max = 72): string {
111
237
 
112
238
  function textFromResult(result: any): string {
113
239
  const item = result?.content?.find?.((c: any) => c.type === "text");
114
- return item?.type === "text" ? sanitizeToolResultText(item.text ?? "") : "";
240
+ // Compact previews only need line counts / short text; bound sanitize work.
241
+ return item?.type === "text" ? sanitizeToolResultText(item.text ?? "", 16_384) : "";
115
242
  }
116
243
 
117
244
  function countLines(text: string): number {
@@ -283,29 +410,361 @@ export class ExpandedToolResultText {
283
410
  }
284
411
  }
285
412
 
413
+ /** Affordance next to truncated Input/Output headers — click opens full preview. */
414
+ export const SHOW_MORE_LABEL = "[show more]";
415
+
416
+ export type ToolIoSection = "input" | "output";
417
+
418
+ /**
419
+ * Expanded tool body with clear Input / Output sections (Grok Build–style).
420
+ *
421
+ * Visual frame:
422
+ * ┌ Input [show more]
423
+ * │ path: src/a.ts
424
+ * │
425
+ * └ Output [show more]
426
+ * result line…
427
+ *
428
+ * Reused across re-renders via context.lastComponent when possible.
429
+ */
430
+ export class ExpandedToolIoView {
431
+ private inputBody: string;
432
+ private outputBody: string;
433
+ private isError: boolean;
434
+ private theme: any;
435
+ private maxOutputLines: number;
436
+ private maxInputLines: number;
437
+ private cachedWidth: number | undefined;
438
+ private cachedLines: string[] | undefined;
439
+ /** Which sections currently show the [show more] affordance (after last render). */
440
+ private truncated: { input: boolean; output: boolean } = { input: false, output: false };
441
+
442
+ constructor(
443
+ theme: any,
444
+ inputBody: string,
445
+ outputBody: string,
446
+ isError: boolean,
447
+ maxOutputLines = config.expandedPreviewMaxLines,
448
+ maxInputLines = config.expandedPreviewMaxLines,
449
+ ) {
450
+ this.theme = theme;
451
+ this.inputBody = inputBody;
452
+ this.outputBody = outputBody;
453
+ this.isError = isError;
454
+ this.maxOutputLines = Math.max(1, maxOutputLines);
455
+ this.maxInputLines = Math.max(1, maxInputLines);
456
+ }
457
+
458
+ setContent(
459
+ inputBody: string,
460
+ outputBody: string,
461
+ isError: boolean,
462
+ maxOutputLines?: number,
463
+ maxInputLines?: number,
464
+ ): void {
465
+ const nextOut =
466
+ maxOutputLines !== undefined ? Math.max(1, maxOutputLines) : this.maxOutputLines;
467
+ const nextIn = maxInputLines !== undefined ? Math.max(1, maxInputLines) : this.maxInputLines;
468
+ if (
469
+ this.inputBody === inputBody &&
470
+ this.outputBody === outputBody &&
471
+ this.isError === isError &&
472
+ this.maxOutputLines === nextOut &&
473
+ this.maxInputLines === nextIn
474
+ ) {
475
+ return;
476
+ }
477
+ this.inputBody = inputBody;
478
+ this.outputBody = outputBody;
479
+ this.isError = isError;
480
+ this.maxOutputLines = nextOut;
481
+ this.maxInputLines = nextIn;
482
+ this.invalidate();
483
+ }
484
+
485
+ getInputBody(): string {
486
+ return this.inputBody;
487
+ }
488
+
489
+ getOutputBody(): string {
490
+ return this.outputBody.trim() ? this.outputBody : "Done";
491
+ }
492
+
493
+ /** True when the plain header line is a truncated section with [show more]. */
494
+ matchShowMoreLine(plainLine: string): ToolIoSection | null {
495
+ const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
496
+ if (!line.includes(SHOW_MORE_LABEL)) return null;
497
+ if (/\bInput\b/.test(line) && this.truncated.input) return "input";
498
+ if (/\bOutput\b/.test(line) && this.truncated.output) return "output";
499
+ return null;
500
+ }
501
+
502
+ /** Column range (1-based, visible cells) of [show more] on a rendered header, if present. */
503
+ showMoreHitbox(plainLine: string): { startCol: number; endCol: number } | null {
504
+ const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
505
+ const idx = line.indexOf(SHOW_MORE_LABEL);
506
+ if (idx < 0) return null;
507
+ const before = line.slice(0, idx);
508
+ const startCol = visibleWidth(before) + 1;
509
+ const endCol = startCol + visibleWidth(SHOW_MORE_LABEL) - 1;
510
+ return { startCol, endCol };
511
+ }
512
+
513
+ render(width: number): string[] {
514
+ if (this.cachedLines !== undefined && this.cachedWidth === width) return this.cachedLines;
515
+
516
+ const theme = this.theme;
517
+ const safeWidth = Math.max(1, Math.floor(width));
518
+ const rail = " │ ";
519
+ const railWidth = visibleWidth(rail);
520
+ const contentWidth = Math.max(1, safeWidth - railWidth);
521
+ const bodyColor = this.isError ? "error" : "toolOutput";
522
+ const lines: string[] = [];
523
+ this.truncated = { input: false, output: false };
524
+
525
+ const pushHeader = (corner: "┌" | "└", label: string, showMore: boolean) => {
526
+ const mark = theme.fg("dim", ` ${corner} `);
527
+ const title = theme.fg(
528
+ "accent",
529
+ typeof theme.bold === "function" ? theme.bold(label) : label,
530
+ );
531
+ const more = showMore ? theme.fg("dim", ` ${SHOW_MORE_LABEL}`) : "";
532
+ lines.push(truncateToWidth(mark + title + more, safeWidth, ""));
533
+ };
534
+
535
+ const pushRailLine = (styledContent: string) => {
536
+ lines.push(truncateToWidth(theme.fg("dim", rail) + styledContent, safeWidth, ""));
537
+ };
538
+
539
+ const pushBlankRail = () => {
540
+ lines.push(truncateToWidth(theme.fg("dim", " │"), safeWidth, ""));
541
+ };
542
+
543
+ /** Style `key: value` input rows — dim keys, readable values. */
544
+ const styleInputLine = (rawLine: string): string => {
545
+ const match = rawLine.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
546
+ if (!match) return theme.fg("muted", rawLine);
547
+ const [, key, sep, rest] = match;
548
+ return theme.fg("dim", key + sep) + theme.fg("text", rest ?? "");
549
+ };
550
+
551
+ const pushBody = (
552
+ body: string,
553
+ opts: { input?: boolean; limit: number },
554
+ ): boolean /* truncated */ => {
555
+ const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
556
+ if (!raw.trim()) {
557
+ pushRailLine(theme.fg("dim", "(empty)"));
558
+ return false;
559
+ }
560
+ const sourceLines = raw.split("\n");
561
+ const wrapped: string[] = [];
562
+ for (const source of sourceLines) {
563
+ const styled = opts.input ? styleInputLine(source) : theme.fg(bodyColor, source);
564
+ const parts = wrapTextWithAnsi(styled, contentWidth);
565
+ if (parts.length === 0) wrapped.push(styled);
566
+ else wrapped.push(...parts);
567
+ }
568
+ // Prefer source-line count so plain multi-line dumps always cap, even when
569
+ // theme/wrap measurements disagree slightly.
570
+ const truncated = wrapped.length > opts.limit || sourceLines.length > opts.limit;
571
+ const visible = truncated ? wrapped.slice(0, Math.min(opts.limit, wrapped.length)) : wrapped;
572
+ for (const line of visible) pushRailLine(line);
573
+ if (truncated) {
574
+ const hidden = Math.max(0, wrapped.length - visible.length);
575
+ if (hidden > 0) {
576
+ pushRailLine(theme.fg("dim", `… +${hidden} more lines`));
577
+ }
578
+ }
579
+ return truncated;
580
+ };
581
+
582
+ const hasInput = this.inputBody.trim().length > 0;
583
+ const outputText = this.getOutputBody();
584
+
585
+ // Decide [show more] from the same truncation rules as pushBody.
586
+ const inputWouldTruncate =
587
+ hasInput &&
588
+ bodyExceedsLineLimit(this.inputBody, this.maxInputLines, contentWidth, true, theme);
589
+ const outputWouldTruncate = bodyExceedsLineLimit(
590
+ outputText,
591
+ this.maxOutputLines,
592
+ contentWidth,
593
+ false,
594
+ theme,
595
+ bodyColor,
596
+ );
597
+
598
+ if (hasInput) {
599
+ this.truncated.input = inputWouldTruncate;
600
+ pushHeader("┌", "Input", inputWouldTruncate);
601
+ pushBody(this.inputBody, { input: true, limit: this.maxInputLines });
602
+ pushBlankRail();
603
+ this.truncated.output = outputWouldTruncate;
604
+ pushHeader("└", "Output", outputWouldTruncate);
605
+ pushBody(outputText, { limit: this.maxOutputLines });
606
+ } else {
607
+ this.truncated.output = outputWouldTruncate;
608
+ pushHeader("┌", "Output", outputWouldTruncate);
609
+ pushBody(outputText, { limit: this.maxOutputLines });
610
+ }
611
+
612
+ this.cachedWidth = width;
613
+ this.cachedLines = lines;
614
+ return lines;
615
+ }
616
+
617
+ invalidate(): void {
618
+ this.cachedWidth = undefined;
619
+ this.cachedLines = undefined;
620
+ }
621
+ }
622
+
623
+ /** True when body needs truncation at the given line limit (source lines or wrapped rows). */
624
+ function bodyExceedsLineLimit(
625
+ body: string,
626
+ limit: number,
627
+ contentWidth: number,
628
+ asInput: boolean,
629
+ theme: any,
630
+ bodyColor = "toolOutput",
631
+ ): boolean {
632
+ const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
633
+ if (!raw.trim()) return false;
634
+ const sourceLines = raw.split("\n");
635
+ if (sourceLines.length > limit) return true;
636
+ let total = 0;
637
+ for (const source of sourceLines) {
638
+ let styled: string;
639
+ if (asInput) {
640
+ const match = source.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
641
+ styled = match
642
+ ? theme.fg("dim", match[1] + match[2]) + theme.fg("text", match[3] ?? "")
643
+ : theme.fg("muted", source);
644
+ } else {
645
+ styled = theme.fg(bodyColor, source);
646
+ }
647
+ const parts = wrapTextWithAnsi(styled, contentWidth);
648
+ total += Math.max(1, parts.length);
649
+ if (total > limit) return true;
650
+ }
651
+ return false;
652
+ }
653
+
286
654
  export function renderCollapsedToolResult(body: string, collapsedHint = ""): string {
287
655
  return ` ↳ ${body}${collapsedHint}`;
288
656
  }
289
657
 
658
+ /** Pretty-print tool call args for the expanded Input section. */
659
+ export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
660
+ if (args === undefined || args === null) return "";
661
+ if (typeof args !== "object") {
662
+ const text = String(args);
663
+ return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
664
+ }
665
+ if (Array.isArray(args)) {
666
+ try {
667
+ const json = JSON.stringify(args, null, 2);
668
+ return json.length > maxChars ? `${json.slice(0, maxChars)}…` : json;
669
+ } catch {
670
+ return String(args);
671
+ }
672
+ }
673
+
674
+ const entries = Object.entries(args as Record<string, unknown>).filter(
675
+ ([, value]) => value !== undefined,
676
+ );
677
+ if (entries.length === 0) return "";
678
+
679
+ // Stable, human-first field order for common tools.
680
+ const preferred = [
681
+ "path",
682
+ "file_path",
683
+ "command",
684
+ "query",
685
+ "pattern",
686
+ "url",
687
+ "name",
688
+ "message",
689
+ "content",
690
+ "old_string",
691
+ "new_string",
692
+ ];
693
+ entries.sort(([left], [right]) => {
694
+ const li = preferred.indexOf(left);
695
+ const ri = preferred.indexOf(right);
696
+ if (li === -1 && ri === -1) return left.localeCompare(right);
697
+ if (li === -1) return 1;
698
+ if (ri === -1) return -1;
699
+ return li - ri;
700
+ });
701
+
702
+ const lines: string[] = [];
703
+ for (const [key, value] of entries) {
704
+ if (typeof value === "string") {
705
+ if (value.includes("\n")) {
706
+ lines.push(`${key}:`);
707
+ for (const line of value.replace(/\t/g, " ").split("\n")) {
708
+ lines.push(` ${line}`);
709
+ }
710
+ } else {
711
+ lines.push(`${key}: ${value}`);
712
+ }
713
+ continue;
714
+ }
715
+ if (typeof value === "number" || typeof value === "boolean" || value === null) {
716
+ lines.push(`${key}: ${String(value)}`);
717
+ continue;
718
+ }
719
+ try {
720
+ const json = JSON.stringify(value, null, 2);
721
+ if (json.includes("\n")) {
722
+ lines.push(`${key}:`);
723
+ for (const line of json.split("\n")) lines.push(` ${line}`);
724
+ } else {
725
+ lines.push(`${key}: ${json}`);
726
+ }
727
+ } catch {
728
+ lines.push(`${key}: [unserializable]`);
729
+ }
730
+ }
731
+ const text = lines.join("\n");
732
+ return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
733
+ }
734
+
735
+ function hasExpandableDetail(outputText: string, args: unknown): boolean {
736
+ if (hasExpandableResult(outputText)) return true;
737
+ return formatToolInputArgs(args).trim().length > 0;
738
+ }
739
+
290
740
  function renderExpandedToolResult(
291
741
  body: string,
292
742
  theme: any,
293
743
  isError: boolean,
294
744
  lastComponent?: unknown,
295
- ): ExpandedToolResultText | Text {
296
- const color = isError ? "error" : "muted";
297
- if (!body.trim()) {
298
- return new Text(theme.fg(color, renderCollapsedToolResult("Done")), 0, 0);
745
+ args?: unknown,
746
+ context?: any,
747
+ ): ExpandedToolIoView | ExpandedToolResultText | Text {
748
+ const inputBody = formatToolInputArgs(args);
749
+ const outputBody = body;
750
+ const maxLines = config.expandedPreviewMaxLines;
751
+
752
+ // Prefer structured Input/Output when we have args or non-empty output.
753
+ if (inputBody.trim() || outputBody.trim()) {
754
+ let view: ExpandedToolIoView;
755
+ if (lastComponent instanceof ExpandedToolIoView) {
756
+ lastComponent.setContent(inputBody, outputBody, isError, maxLines, maxLines);
757
+ view = lastComponent;
758
+ } else {
759
+ view = new ExpandedToolIoView(theme, inputBody, outputBody, isError, maxLines, maxLines);
760
+ }
761
+ if (context) rememberIoView(context, view);
762
+ return view;
299
763
  }
300
764
 
301
- const text = theme.fg(color, body);
302
- const prefix = theme.fg(color, " │ ");
303
- if (lastComponent instanceof ExpandedToolResultText) {
304
- lastComponent.setText(text);
305
- lastComponent.setPrefix(prefix);
306
- return lastComponent;
307
- }
308
- return new ExpandedToolResultText(text, prefix);
765
+ if (context?.state) context.state.ccstyleIoView = undefined;
766
+ const color = isError ? "error" : "muted";
767
+ return new Text(theme.fg(color, renderCollapsedToolResult("Done")), 0, 0);
309
768
  }
310
769
 
311
770
  function expandHint(theme: any): string {
@@ -473,6 +932,11 @@ function fixedEditorContextScore(
473
932
  return score;
474
933
  }
475
934
 
935
+ /** Collapsed tool rows expose "expand / click"; expanded body clicks do not. */
936
+ const COLLAPSED_TOOL_CLICK_HINT = /(?:expand|\/ click)/i;
937
+ /** fixedEditorContextScore max ≈ 100 + 2*(5+4+3+2); stop once we hit a near-perfect match. */
938
+ const TOOL_CLICK_EARLY_EXIT_SCORE = 120;
939
+
476
940
  function findToolAtFixedEditorRow(
477
941
  tui: any,
478
942
  visibleRow: number,
@@ -486,10 +950,12 @@ function findToolAtFixedEditorRow(
486
950
 
487
951
  const tools: any[] = [];
488
952
  collectToolComponents(tui, tools);
953
+ // Expand-hint → only collapsed tools; body click → only expanded (collapse-any-line).
954
+ // Skips re-rendering the opposite half of the transcript tools.
955
+ const wantExpanded = !COLLAPSED_TOOL_CLICK_HINT.test(clickedLine);
489
956
  let best: { hit: ToolRenderHit; score: number } | null = null;
490
957
  for (const component of tools) {
491
- const expanded = Boolean(component.expanded);
492
- if (!expanded && !/(?:expand|\/ click)/i.test(clickedLine)) continue;
958
+ if (Boolean(component.expanded) !== wantExpanded) continue;
493
959
  const renderedLines = renderComponentTree(component, width).map((line) =>
494
960
  stripTerminalSequences(String(line)),
495
961
  );
@@ -501,6 +967,7 @@ function findToolAtFixedEditorRow(
501
967
  hit: { component, start: visibleRow, end: visibleRow + renderedLines.length },
502
968
  score,
503
969
  };
970
+ if (score >= TOOL_CLICK_EARLY_EXIT_SCORE) return best.hit;
504
971
  }
505
972
  }
506
973
  }
@@ -525,13 +992,12 @@ function findToolAtScreenRow(tui: any, screenRow: number): ToolRenderHit | null
525
992
  collectToolRenderHits(tui, 0, width, hits, cache, new Set<object>());
526
993
 
527
994
  const clickedLine = stripTerminalSequences(String(previousLines[bufferRow] ?? ""));
995
+ const wantExpanded = !COLLAPSED_TOOL_CLICK_HINT.test(clickedLine);
528
996
  for (const hit of hits) {
529
997
  if (bufferRow < hit.start || bufferRow >= hit.end) continue;
530
- const component = hit.component;
531
- const expanded = Boolean(component.expanded);
532
- // Match Pi's old click contract: collapsed results expose a click hint;
533
- // expanded results can be collapsed by clicking any rendered line.
534
- if (!expanded && !/(?:expand|\/ click)/i.test(clickedLine)) continue;
998
+ // Same分流 as fixed-editor: hint clicks expand collapsed rows; body clicks
999
+ // collapse expanded rows. Avoids accidental toggles on the wrong set.
1000
+ if (Boolean(hit.component.expanded) !== wantExpanded) continue;
535
1001
  return hit;
536
1002
  }
537
1003
  return null;
@@ -613,14 +1079,36 @@ function directRenderLines(component: any, width: number): string[] {
613
1079
  }
614
1080
  }
615
1081
 
616
- /** Render only the transcript portion that fixed-editor leaves scrollable. */
617
- function renderFixedScrollableRoot(tui: any, width: number): string[] {
1082
+ /** Index just before the fixed editor cluster in the TUI child list. */
1083
+ function fixedScrollableRootEnd(tui: any): number {
618
1084
  const children = Array.isArray(tui?.children) ? tui.children : [];
619
1085
  const editorIndex = children.findIndex((child: any) =>
620
1086
  containsEditorLike(child, tui.focusedComponent),
621
1087
  );
622
- const end = editorIndex >= 2 ? editorIndex - 2 : children.length;
623
- return children.slice(0, end).flatMap((child: any) => directRenderLines(child, width));
1088
+ return editorIndex >= 2 ? editorIndex - 2 : children.length;
1089
+ }
1090
+
1091
+ /**
1092
+ * Last N stripped lines of the scrollable root (after trimming trailing blanks).
1093
+ * Walks children backwards and stops once the tail is fully determined, so long
1094
+ * transcripts with many sibling nodes do not re-render the whole tree on scroll.
1095
+ */
1096
+ function renderFixedScrollableRootTail(tui: any, width: number, matchLength: number): string[] {
1097
+ const children = Array.isArray(tui?.children) ? tui.children : [];
1098
+ const end = fixedScrollableRootEnd(tui);
1099
+ const collected: string[] = [];
1100
+ for (let index = end - 1; index >= 0; index--) {
1101
+ const lines = directRenderLines(children[index], width).map((line) =>
1102
+ stripTerminalSequences(String(line)),
1103
+ );
1104
+ collected.unshift(...lines);
1105
+ let meaningful = collected.length;
1106
+ while (meaningful > 0 && collected[meaningful - 1] === "") meaningful--;
1107
+ if (meaningful >= matchLength) break;
1108
+ }
1109
+ while (collected.length > 0 && collected[collected.length - 1] === "") collected.pop();
1110
+ if (collected.length === 0) return [];
1111
+ return collected.slice(-Math.min(matchLength, collected.length));
624
1112
  }
625
1113
 
626
1114
  function isFixedEditorAtBottom(tui: any): boolean {
@@ -628,18 +1116,14 @@ function isFixedEditorAtBottom(tui: any): boolean {
628
1116
  const visibleLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
629
1117
  if (visibleLines.length === 0) return true;
630
1118
  const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
631
- const rootLines = renderFixedScrollableRoot(tui, width).map((line) =>
632
- stripTerminalSequences(String(line)),
633
- );
634
- while (rootLines.length > 0 && rootLines[rootLines.length - 1] === "") rootLines.pop();
635
- if (rootLines.length === 0) return true;
1119
+ const expected = renderFixedScrollableRootTail(tui, width, 3);
1120
+ if (expected.length === 0) return true;
636
1121
 
637
1122
  // previousLines contains both the scrollable root and Zentui's fixed cluster.
638
1123
  // Locate the root tail within that full frame instead of requiring it to be
639
1124
  // the frame suffix; otherwise status/editor/footer rows keep the button alive.
640
1125
  const visible = visibleLines.map((line: unknown) => stripTerminalSequences(String(line)));
641
- const matchLength = Math.min(3, rootLines.length);
642
- const expected = rootLines.slice(-matchLength);
1126
+ const matchLength = expected.length;
643
1127
  for (let end = matchLength; end <= visible.length; end++) {
644
1128
  if (expected.every((line, index) => line === visible[end - matchLength + index])) return true;
645
1129
  }
@@ -879,10 +1363,88 @@ function scheduleCollapseViewportCompensation(
879
1363
  });
880
1364
  }
881
1365
 
1366
+ /** toolCallId → latest expanded IO view (survives context/state identity quirks). */
1367
+ const ioViewsByToolCallId = new Map<string, ExpandedToolIoView>();
1368
+
1369
+ function rememberIoView(context: any, view: ExpandedToolIoView): void {
1370
+ if (!context || typeof context !== "object") return;
1371
+ if (!context.state || typeof context.state !== "object") context.state = {};
1372
+ const state = context.state as Record<string, unknown>;
1373
+ state.ccstyleIoView = view;
1374
+ const id =
1375
+ (typeof context?.toolCallId === "string" && context.toolCallId) ||
1376
+ (typeof context?.id === "string" && context.id) ||
1377
+ (typeof state?.toolCallId === "string" && state.toolCallId) ||
1378
+ undefined;
1379
+ if (id) {
1380
+ ioViewsByToolCallId.set(id, view);
1381
+ // Bound growth in long sessions.
1382
+ if (ioViewsByToolCallId.size > 200) {
1383
+ const oldest = ioViewsByToolCallId.keys().next().value;
1384
+ if (oldest !== undefined) ioViewsByToolCallId.delete(oldest);
1385
+ }
1386
+ }
1387
+ }
1388
+
1389
+ function resolveIoViewFromTool(component: any): ExpandedToolIoView | null {
1390
+ const fromState = component?.state?.ccstyleIoView;
1391
+ if (fromState instanceof ExpandedToolIoView) return fromState;
1392
+ const id =
1393
+ (typeof component?.toolCallId === "string" && component.toolCallId) ||
1394
+ (typeof component?.state?.toolCallId === "string" && component.state.toolCallId) ||
1395
+ undefined;
1396
+ if (id) {
1397
+ const mapped = ioViewsByToolCallId.get(id);
1398
+ if (mapped) return mapped;
1399
+ }
1400
+ // Some hosts keep the last result component on the tool instance.
1401
+ const last = component?.lastComponent ?? component?.resultComponent ?? component?.content;
1402
+ if (last instanceof ExpandedToolIoView) return last;
1403
+ return null;
1404
+ }
1405
+
1406
+ /**
1407
+ * If the click lands on a truncated section's [show more], open the /context-style
1408
+ * full-text preview instead of toggling expand/collapse.
1409
+ */
1410
+ function tryOpenToolIoShowMore(tui: any, packet: SgrMousePacket, hit: ToolRenderHit): boolean {
1411
+ if (!Boolean(hit.component.expanded)) return false;
1412
+ const ioView = resolveIoViewFromTool(hit.component);
1413
+ if (!ioView) return false;
1414
+
1415
+ const previousLines = Array.isArray(tui?.previousLines) ? tui.previousLines : [];
1416
+ const visibleRow = useFixedEditorFeatures(tui)
1417
+ ? packet.row - 1
1418
+ : (Number.isFinite(tui?.previousViewportTop) ? tui.previousViewportTop : 0) + packet.row - 1;
1419
+ if (visibleRow < 0 || visibleRow >= previousLines.length) return false;
1420
+
1421
+ const plain = stripTerminalSequences(String(previousLines[visibleRow] ?? ""));
1422
+ const section = ioView.matchShowMoreLine(plain);
1423
+ if (!section) return false;
1424
+
1425
+ // Prefer the [show more] cells; allow a little slack so imprecise clicks still work.
1426
+ const box = ioView.showMoreHitbox(plain);
1427
+ if (box && packet.col > 0 && packet.col < box.startCol - 4) return false;
1428
+
1429
+ const ui = toolMouseUi;
1430
+ if (!ui || typeof ui.custom !== "function") {
1431
+ ui?.notify?.("Full preview requires TUI custom UI", "warning");
1432
+ return true;
1433
+ }
1434
+
1435
+ const title = section === "input" ? "Tool Input" : "Tool Output";
1436
+ const content = section === "input" ? ioView.getInputBody() : ioView.getOutputBody();
1437
+ // Fire-and-forget overlay; click is still consumed.
1438
+ void showTextPreview({ ui }, title, content || "(empty)");
1439
+ return true;
1440
+ }
1441
+
882
1442
  function toggleToolAtMouseClick(tui: any, packet: SgrMousePacket): boolean {
883
1443
  const hit = findToolAtScreenRow(tui, packet.row);
884
1444
  if (!hit) return false;
885
1445
 
1446
+ if (tryOpenToolIoShowMore(tui, packet, hit)) return true;
1447
+
886
1448
  const wasExpanded = Boolean(hit.component.expanded);
887
1449
  const width = Math.max(1, Number(tui?.terminal?.columns) || 80);
888
1450
  const previousHeight = wasExpanded ? renderComponentTree(hit.component, width).length : 0;
@@ -1106,18 +1668,123 @@ function applyStyleMode(mode: CompactStyleMode, ctx: any, compactStyle: CompactS
1106
1668
 
1107
1669
  function modeSettingDescription(mode: CompactStyleMode): string {
1108
1670
  if (mode === "compact") {
1109
- return "Compact transcript summaries are enabled. Fixed editor interaction is configured separately below.";
1671
+ return "Compact transcript summaries. Fixed editor and diff options below still apply independently.";
1110
1672
  }
1111
1673
  if (mode === "off") {
1112
- return "Pi native tool rendering is enabled. Fixed editor interaction is configured separately below.";
1674
+ return "Pi native tool rendering. Fixed editor and diff options below still apply independently.";
1113
1675
  }
1114
- return "Claude Code style and rich edit/write diffs are enabled. Fixed editor interaction is configured separately below.";
1676
+ return "Claude Code style with rich edit/write diffs. Tune fixed editor and diff options below.";
1115
1677
  }
1116
1678
 
1117
1679
  function fixedEditorSettingDescription(enabled: boolean): string {
1118
1680
  return enabled
1119
- ? "Enables mouse capture, 5-row wheel scrolling, tool click expansion with collapse anchoring, fixed viewport mapping, the back-to-bottom button, message count, and Ctrl+End."
1120
- : "Uses terminal-native wheel scrolling; disables mouse capture, tool clicks, fixed viewport mapping, button, and message count. Ctrl+End remains enabled.";
1681
+ ? "Mouse capture, 5-row wheel, tool click expand/collapse, viewport mapping, back-to-bottom button, message count, Ctrl+End."
1682
+ : "Terminal-native wheel; mouse capture, tool clicks, viewport mapping, and button off. Ctrl+End remains enabled.";
1683
+ }
1684
+
1685
+ function excludeRenderersDescription(names: readonly string[]): string {
1686
+ return names.length === 0
1687
+ ? "No tools excluded. Agent always keeps its dedicated renderer. Enter to toggle common tools."
1688
+ : `Native renderer for: ${names.join(", ")}. Agent is always native. Enter to toggle.`;
1689
+ }
1690
+
1691
+ function diffViewModeDescription(mode: DiffViewMode): string {
1692
+ if (mode === "split") return "Force side-by-side diff when width allows; otherwise unified.";
1693
+ if (mode === "unified") return "Always render a single unified diff column.";
1694
+ return "Auto: split when terminal is wide enough, otherwise unified.";
1695
+ }
1696
+
1697
+ function diffIndicatorDescription(mode: DiffIndicatorMode): string {
1698
+ if (mode === "classic") return "Classic +/- gutters on changed lines.";
1699
+ if (mode === "none") return "No change indicators; rely on color alone.";
1700
+ return "Vertical bar indicators on changed lines (default).";
1701
+ }
1702
+
1703
+ function buildExcludeRenderersSubmenu(
1704
+ onClose: () => void,
1705
+ onLiveChange: () => void,
1706
+ ): {
1707
+ render: (width: number) => string[];
1708
+ invalidate: () => void;
1709
+ handleInput: (data: string) => void;
1710
+ } {
1711
+ const candidates = [
1712
+ ...new Set([...EXCLUDE_RENDERER_CANDIDATES, ...config.excludeRenderers]),
1713
+ ].sort((a, b) => a.localeCompare(b));
1714
+ const items = candidates.map((name) => ({
1715
+ id: name,
1716
+ label: name,
1717
+ description:
1718
+ name === "Agent"
1719
+ ? "Agent always uses its dedicated renderer and cannot be forced through ccstyle."
1720
+ : `Use Pi native renderer for ${name} instead of Claude Code / compact styling.`,
1721
+ currentValue: config.excludeRenderers.includes(name) ? "exclude" : "style",
1722
+ values: ["style", "exclude"],
1723
+ }));
1724
+ const list = new SettingsList(
1725
+ items,
1726
+ Math.min(8, Math.max(4, items.length)),
1727
+ getSettingsListTheme(),
1728
+ (id: string, value: string) => {
1729
+ const excluded = new Set(config.excludeRenderers);
1730
+ if (value === "exclude") excluded.add(id);
1731
+ else excluded.delete(id);
1732
+ config.excludeRenderers = [...excluded].sort((a, b) => a.localeCompare(b));
1733
+ saveConfig();
1734
+ onLiveChange();
1735
+ },
1736
+ () => onClose(),
1737
+ { enableSearch: candidates.length > 8 },
1738
+ );
1739
+ return {
1740
+ render: (width: number) => [
1741
+ ...list.render(width),
1742
+ "",
1743
+ // Extra hint: Esc returns to the Style section list.
1744
+ truncateToWidth(" Esc back to Style settings", width),
1745
+ ],
1746
+ invalidate: () => list.invalidate(),
1747
+ handleInput: (data: string) => list.handleInput(data),
1748
+ };
1749
+ }
1750
+
1751
+ /** Section tabs for /ccstyle — matches Zentui-style "A / B / C" headers. */
1752
+ type CcstyleSection = {
1753
+ id: "style" | "editor" | "diff";
1754
+ label: string;
1755
+ items: any[];
1756
+ };
1757
+
1758
+ function isForwardTabKey(data: string): boolean {
1759
+ return data === "\t" || matchesKey(data, "tab");
1760
+ }
1761
+
1762
+ function isBackTabKey(data: string): boolean {
1763
+ // CSI Z is the common terminal encoding for Shift+Tab.
1764
+ return data === "\x1b[Z" || matchesKey(data, "shift+tab");
1765
+ }
1766
+
1767
+ function renderPanelRule(theme: any, width: number): string {
1768
+ return theme.fg("dim", "─".repeat(Math.max(0, width)));
1769
+ }
1770
+
1771
+ function renderSectionTabBar(
1772
+ theme: any,
1773
+ sections: readonly { label: string }[],
1774
+ activeIndex: number,
1775
+ width: number,
1776
+ ): string {
1777
+ const pieces: string[] = [];
1778
+ for (let i = 0; i < sections.length; i++) {
1779
+ if (i > 0) pieces.push(theme.fg("dim", " / "));
1780
+ const label = sections[i]?.label ?? "";
1781
+ pieces.push(
1782
+ i === activeIndex
1783
+ ? theme.fg("text", typeof theme.bold === "function" ? theme.bold(label) : label)
1784
+ : theme.fg("dim", label),
1785
+ );
1786
+ }
1787
+ return truncateToWidth(pieces.join(""), Math.max(0, width));
1121
1788
  }
1122
1789
 
1123
1790
  async function showCcstylePanel(ctx: any, compactStyle: CompactStyleHooks): Promise<void> {
@@ -1127,9 +1794,6 @@ async function showCcstylePanel(ctx: any, compactStyle: CompactStyleHooks): Prom
1127
1794
  }
1128
1795
 
1129
1796
  await ctx.ui.custom((tui: any, theme: any, _keybindings: any, done: () => void) => {
1130
- const container = new Container();
1131
- container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
1132
- container.addChild(new Text(theme.fg("accent", theme.bold("Claude Code Style")), 1, 0));
1133
1797
  const modeSetting = {
1134
1798
  id: "mode",
1135
1799
  label: "Mode",
@@ -1139,22 +1803,92 @@ async function showCcstylePanel(ctx: any, compactStyle: CompactStyleHooks): Prom
1139
1803
  };
1140
1804
  const fixedEditorSetting = {
1141
1805
  id: "fixedEditorFeatures",
1142
- label: "Fixed editor feature",
1806
+ label: "Fixed editor",
1143
1807
  description: fixedEditorSettingDescription(config.fixedEditorFeatures),
1144
1808
  currentValue: config.fixedEditorFeatures ? "on" : "off",
1145
1809
  values: ["on", "off"],
1146
1810
  };
1147
- const settingsList = new SettingsList(
1148
- [modeSetting, fixedEditorSetting],
1149
- 4,
1150
- getSettingsListTheme(),
1151
- (id: string, value: string) => {
1152
- if (id === "mode") {
1811
+ // Tracks whether the Exclude-tools submenu is open so Tab switches sections
1812
+ // only at the top level (mirrors Zentui settings: Tab = switch sections).
1813
+ let excludeSubmenuOpen = false;
1814
+ const excludeSetting = {
1815
+ id: "excludeRenderers",
1816
+ label: "Exclude tools",
1817
+ description: excludeRenderersDescription(config.excludeRenderers),
1818
+ currentValue: formatExcludeRenderers(config.excludeRenderers),
1819
+ submenu: (_current: string, closeSubmenu: (selected?: string) => void) => {
1820
+ excludeSubmenuOpen = true;
1821
+ return buildExcludeRenderersSubmenu(
1822
+ () => {
1823
+ excludeSubmenuOpen = false;
1824
+ excludeSetting.currentValue = formatExcludeRenderers(config.excludeRenderers);
1825
+ excludeSetting.description = excludeRenderersDescription(config.excludeRenderers);
1826
+ closeSubmenu();
1827
+ },
1828
+ () => {
1829
+ excludeSetting.currentValue = formatExcludeRenderers(config.excludeRenderers);
1830
+ excludeSetting.description = excludeRenderersDescription(config.excludeRenderers);
1831
+ refreshCurrentTranscript(compactStyle, ctx);
1832
+ },
1833
+ );
1834
+ },
1835
+ };
1836
+ const diffViewSetting = {
1837
+ id: "diffViewMode",
1838
+ label: "Diff layout",
1839
+ description: diffViewModeDescription(config.diffViewMode),
1840
+ currentValue: config.diffViewMode,
1841
+ values: [...DIFF_VIEW_MODES],
1842
+ };
1843
+ const diffIndicatorSetting = {
1844
+ id: "diffIndicatorMode",
1845
+ label: "Diff indicator",
1846
+ description: diffIndicatorDescription(config.diffIndicatorMode),
1847
+ currentValue: config.diffIndicatorMode,
1848
+ values: [...DIFF_INDICATOR_MODES],
1849
+ };
1850
+ const diffSplitSetting = {
1851
+ id: "diffSplitMinWidth",
1852
+ label: "Split min width",
1853
+ description: "Minimum terminal width before auto/split layout uses side-by-side columns.",
1854
+ currentValue: nearestPreset(config.diffSplitMinWidth, DIFF_SPLIT_MIN_WIDTH_VALUES),
1855
+ values: [...DIFF_SPLIT_MIN_WIDTH_VALUES],
1856
+ };
1857
+ const diffCollapsedSetting = {
1858
+ id: "diffCollapsedLines",
1859
+ label: "Collapsed lines",
1860
+ description: "How many diff body lines to show before the expand hint (Ctrl+O / click).",
1861
+ currentValue: nearestPreset(config.diffCollapsedLines, DIFF_COLLAPSED_LINES_VALUES),
1862
+ values: [...DIFF_COLLAPSED_LINES_VALUES],
1863
+ };
1864
+ const diffWordWrapSetting = {
1865
+ id: "diffWordWrap",
1866
+ label: "Diff word wrap",
1867
+ description: config.diffWordWrap
1868
+ ? "Long diff lines wrap within the panel width."
1869
+ : "Long diff lines are truncated to the panel width.",
1870
+ currentValue: config.diffWordWrap ? "on" : "off",
1871
+ values: ["on", "off"],
1872
+ };
1873
+ const expandedMaxSetting = {
1874
+ id: "expandedPreviewMaxLines",
1875
+ label: "Expanded max lines",
1876
+ description:
1877
+ "Max Output/diff body lines when expanded. Default 40 keeps the TUI compact; raise for large dumps.",
1878
+ currentValue: nearestPreset(
1879
+ config.expandedPreviewMaxLines,
1880
+ EXPANDED_PREVIEW_MAX_LINES_VALUES,
1881
+ ),
1882
+ values: [...EXPANDED_PREVIEW_MAX_LINES_VALUES],
1883
+ };
1884
+
1885
+ const onSettingChange = (id: string, value: string) => {
1886
+ switch (id) {
1887
+ case "mode":
1153
1888
  modeSetting.description = modeSettingDescription(value as CompactStyleMode);
1154
1889
  applyStyleMode(value as CompactStyleMode, ctx, compactStyle);
1155
1890
  return;
1156
- }
1157
- if (id === "fixedEditorFeatures") {
1891
+ case "fixedEditorFeatures":
1158
1892
  config.fixedEditorFeatures = value === "on";
1159
1893
  fixedEditorSetting.description = fixedEditorSettingDescription(
1160
1894
  config.fixedEditorFeatures,
@@ -1162,22 +1896,151 @@ async function showCcstylePanel(ctx: any, compactStyle: CompactStyleHooks): Prom
1162
1896
  saveConfig();
1163
1897
  installToolMouseInteraction(ctx);
1164
1898
  refreshCurrentTranscript(compactStyle, ctx);
1165
- ctx.ui.notify(`Fixed editor feature: ${value}`, "info");
1166
- }
1899
+ ctx.ui.notify(`Fixed editor: ${value}`, "info");
1900
+ return;
1901
+ case "excludeRenderers":
1902
+ excludeSetting.currentValue = formatExcludeRenderers(config.excludeRenderers);
1903
+ excludeSetting.description = excludeRenderersDescription(config.excludeRenderers);
1904
+ return;
1905
+ case "diffViewMode":
1906
+ config.diffViewMode = value as DiffViewMode;
1907
+ diffViewSetting.description = diffViewModeDescription(config.diffViewMode);
1908
+ break;
1909
+ case "diffIndicatorMode":
1910
+ config.diffIndicatorMode = value as DiffIndicatorMode;
1911
+ diffIndicatorSetting.description = diffIndicatorDescription(config.diffIndicatorMode);
1912
+ break;
1913
+ case "diffSplitMinWidth":
1914
+ config.diffSplitMinWidth = pickPositiveInt(
1915
+ value,
1916
+ DEFAULT_CONFIG.diffSplitMinWidth,
1917
+ 40,
1918
+ 300,
1919
+ );
1920
+ break;
1921
+ case "diffCollapsedLines":
1922
+ config.diffCollapsedLines = pickPositiveInt(
1923
+ value,
1924
+ DEFAULT_CONFIG.diffCollapsedLines,
1925
+ 1,
1926
+ 500,
1927
+ );
1928
+ break;
1929
+ case "diffWordWrap":
1930
+ config.diffWordWrap = value === "on";
1931
+ diffWordWrapSetting.description = config.diffWordWrap
1932
+ ? "Long diff lines wrap within the panel width."
1933
+ : "Long diff lines are truncated to the panel width.";
1934
+ break;
1935
+ case "expandedPreviewMaxLines":
1936
+ config.expandedPreviewMaxLines = pickPositiveInt(
1937
+ value,
1938
+ DEFAULT_CONFIG.expandedPreviewMaxLines,
1939
+ 10,
1940
+ 50_000,
1941
+ );
1942
+ break;
1943
+ default:
1944
+ return;
1945
+ }
1946
+ saveConfig();
1947
+ refreshCurrentTranscript(compactStyle, ctx);
1948
+ ctx.ui.notify(`Updated ${id}: ${value}`, "info");
1949
+ };
1950
+
1951
+ const sections: CcstyleSection[] = [
1952
+ {
1953
+ id: "style",
1954
+ label: "Style",
1955
+ items: [modeSetting, excludeSetting],
1167
1956
  },
1168
- () => done(),
1169
- { enableSearch: false },
1170
- );
1171
- container.addChild(settingsList);
1172
- container.addChild(
1173
- new Text(theme.fg("dim", "↑↓ navigate · enter/space change · esc close"), 1, 0),
1957
+ {
1958
+ id: "editor",
1959
+ label: "Editor",
1960
+ items: [fixedEditorSetting],
1961
+ },
1962
+ {
1963
+ id: "diff",
1964
+ label: "Diff",
1965
+ items: [
1966
+ diffViewSetting,
1967
+ diffIndicatorSetting,
1968
+ diffSplitSetting,
1969
+ diffCollapsedSetting,
1970
+ diffWordWrapSetting,
1971
+ expandedMaxSetting,
1972
+ ],
1973
+ },
1974
+ ];
1975
+
1976
+ let activeSection = 0;
1977
+ const settingsTheme = getSettingsListTheme();
1978
+ const lists = sections.map(
1979
+ (section) =>
1980
+ new SettingsList(
1981
+ section.items,
1982
+ Math.min(8, Math.max(section.items.length, 1)),
1983
+ settingsTheme,
1984
+ onSettingChange,
1985
+ () => done(),
1986
+ { enableSearch: false },
1987
+ ),
1174
1988
  );
1175
- container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
1989
+
1990
+ const activeList = () => lists[activeSection]!;
1991
+
1992
+ const switchSection = (delta: number) => {
1993
+ if (excludeSubmenuOpen) return;
1994
+ activeSection = (activeSection + delta + sections.length) % sections.length;
1995
+ };
1996
+
1176
1997
  return {
1177
- render: (width: number) => container.render(width),
1178
- invalidate: () => container.invalidate(),
1998
+ render(width: number): string[] {
1999
+ const safeWidth = Math.max(0, Math.floor(width));
2000
+ const rule = renderPanelRule(theme, safeWidth);
2001
+ const body = activeList().render(safeWidth);
2002
+ // Drop SettingsList's built-in hint — the panel footer below is the single source.
2003
+ while (body.length > 0 && body[body.length - 1] === "") body.pop();
2004
+ const listHintIndex = body.findIndex(
2005
+ (line) =>
2006
+ typeof line === "string" &&
2007
+ (line.includes("Enter/Space to change") || line.includes("Esc to cancel")),
2008
+ );
2009
+ const listBody = listHintIndex >= 0 ? body.slice(0, listHintIndex) : body;
2010
+ while (listBody.length > 0 && listBody[listBody.length - 1] === "") listBody.pop();
2011
+
2012
+ // Frame: top rule · tabs · mid rule · settings · mid rule · footer · bottom rule
2013
+ return [
2014
+ rule,
2015
+ renderSectionTabBar(theme, sections, activeSection, safeWidth),
2016
+ rule,
2017
+ ...listBody,
2018
+ rule,
2019
+ truncateToWidth(
2020
+ theme.fg(
2021
+ "dim",
2022
+ " Enter/Space to change · Tab/Shift+Tab to switch sections · Esc to close",
2023
+ ),
2024
+ safeWidth,
2025
+ ),
2026
+ rule,
2027
+ ];
2028
+ },
2029
+ invalidate() {
2030
+ for (const list of lists) list.invalidate();
2031
+ },
1179
2032
  handleInput(data: string) {
1180
- settingsList.handleInput?.(data);
2033
+ if (!excludeSubmenuOpen && isForwardTabKey(data)) {
2034
+ switchSection(1);
2035
+ tui.requestRender();
2036
+ return;
2037
+ }
2038
+ if (!excludeSubmenuOpen && isBackTabKey(data)) {
2039
+ switchSection(-1);
2040
+ tui.requestRender();
2041
+ return;
2042
+ }
2043
+ activeList().handleInput?.(data);
1181
2044
  tui.requestRender();
1182
2045
  },
1183
2046
  };
@@ -1275,6 +2138,8 @@ function createCcstyleTool(
1275
2138
  setToolVisualState(context, isError ? "error" : "success");
1276
2139
  const expanded = isToolExpanded(options, context);
1277
2140
  if (shouldRenderRichDiff(config.mode, toolName, Boolean(isError))) {
2141
+ // Pass getter so Diff indicator / wrap / limits update on the next paint
2142
+ // without recreating the tool result component.
1278
2143
  const richResult = renderRichToolResult(
1279
2144
  toolName,
1280
2145
  result,
@@ -1282,16 +2147,27 @@ function createCcstyleTool(
1282
2147
  theme,
1283
2148
  context,
1284
2149
  writeExecutionMetadata,
2150
+ getToolDisplayConfig,
1285
2151
  );
1286
2152
  if (richResult) return richResult;
1287
2153
  }
1288
2154
 
1289
2155
  const text = textFromResult(result);
2156
+ const args = context?.args;
1290
2157
  const rendered = !expanded ? (text ? oneLine(text, 96) : "Done") : text;
1291
2158
 
1292
- const hint = !expanded && hasExpandableResult(text) ? expandHint(theme) : "";
1293
- if (expanded)
1294
- return renderExpandedToolResult(rendered, theme, isError, context?.lastComponent);
2159
+ const hint = !expanded && hasExpandableDetail(text, args) ? expandHint(theme) : "";
2160
+ if (expanded) {
2161
+ return renderExpandedToolResult(
2162
+ text || "",
2163
+ theme,
2164
+ Boolean(isError),
2165
+ context?.lastComponent,
2166
+ args,
2167
+ context,
2168
+ );
2169
+ }
2170
+ if (context?.state) context.state.ccstyleIoView = undefined;
1295
2171
  return new Text(
1296
2172
  theme.fg(isError ? "error" : "muted", renderCollapsedToolResult(rendered, hint)),
1297
2173
  0,
@@ -1637,19 +2513,20 @@ export default function (pi: ExtensionAPI, configOverride?: Partial<Config>) {
1637
2513
  });
1638
2514
 
1639
2515
  pi.registerCommand("ccstyle", {
1640
- description: "Configure Claude Code style",
2516
+ description: "Configure Claude Code style, fixed editor, and rich diff options",
1641
2517
  getArgumentCompletions: (prefix) => {
1642
2518
  const topLevel = [
1643
2519
  { value: "on", label: "on", description: "Enable Claude Code style" },
1644
2520
  { value: "off", label: "off", description: "Use Pi's native renderer" },
1645
2521
  { value: "compact", label: "compact", description: "Use compact transcript rendering" },
1646
- { value: "status", label: "status", description: "Show current state" },
2522
+ { value: "status", label: "status", description: "Show full configuration" },
2523
+ { value: "panel", label: "panel", description: "Open interactive settings panel" },
1647
2524
  ];
1648
2525
  return topLevel.filter((item) => item.value.startsWith(prefix));
1649
2526
  },
1650
2527
  handler: async (args, ctx) => {
1651
2528
  const arg = args.trim().toLowerCase();
1652
- if (!arg) {
2529
+ if (!arg || arg === "panel") {
1653
2530
  await showCcstylePanel(ctx, compactStyle);
1654
2531
  return;
1655
2532
  }
@@ -1658,10 +2535,10 @@ export default function (pi: ExtensionAPI, configOverride?: Partial<Config>) {
1658
2535
  return;
1659
2536
  }
1660
2537
  if (arg === "status") {
1661
- ctx.ui.notify(`Claude Code style: ${config.mode}`, "info");
2538
+ ctx.ui.notify(`Claude Code style: ${formatConfigStatus(config)}`, "info");
1662
2539
  return;
1663
2540
  }
1664
- ctx.ui.notify("Usage: /ccstyle [on|off|compact|status]", "warning");
2541
+ ctx.ui.notify("Usage: /ccstyle [on|off|compact|status|panel]", "warning");
1665
2542
  },
1666
2543
  });
1667
2544