pi-pretty-tui 0.3.0 → 0.3.2

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,7 +1,15 @@
1
+ import { installSelectionCopy, selectableRow, selectionMetadataActive } from "./selection-ranges.js";
1
2
  import { isNativeImageRow } from "./ansi-style.js";
2
3
  import { cardMargin, cardBackground, frameNativeRows, nativeFrameWidth } from "./full-frame.js";
4
+ import {
5
+ themedInlineContext,
6
+ withNativeEmphasis,
7
+ type InlineStyleContext,
8
+ type InlineToken,
9
+ } from "./markdown-emphasis.js";
10
+ import { gapRowsAfterBlock, renderSpacedList } from "./markdown-spacing.js";
3
11
  import { ThoughtPresentation } from "./thought-presentation.js";
4
- import { isThirdPartyTool, renderToolProjection, toolLabel, type ToolProjection } from "./tool-presentation.js";
12
+ import { isThirdPartyTool, renderToolProjection, THIRD_PARTY_TOOL, toolLabel, type ToolProjection } from "./tool-presentation.js";
5
13
  import { NestedTools, NESTED_SNAPSHOT_ENTRY } from "./nested-tools.js";
6
14
  import {
7
15
  AssistantMessageComponent,
@@ -62,6 +70,10 @@ const RESPONSE_FOOTER_ENTRY_TYPE = "pretty-tui-response-footer";
62
70
  type PrettyTuiConfig = {
63
71
  enabled?: boolean;
64
72
  mode?: PrettyTuiMode;
73
+ /** Exclude plugin decoration from fullscreen selections. Opt-in. */
74
+ selectionCopy?: boolean;
75
+ /** "native" keeps Pi's flags-only strong and emphasis. Read when the extension loads. */
76
+ markdownEmphasis?: "themed" | "native";
65
77
  /** Legacy location used by the first mode implementation. */
66
78
  bash?: {
67
79
  mode?: "full" | "compact";
@@ -300,6 +312,17 @@ export default function prettyTui(pi: ExtensionAPI) {
300
312
  }
301
313
  };
302
314
  const transcriptMarkdownActive = () => transcriptMarkdownDepth > 0;
315
+ // Assistant answers only. The transcript scope also covers user messages,
316
+ // thinking and activity updates, which keep Pi's strong and emphasis styling.
317
+ const assistantBodyMarkdown = new WeakSet<object>();
318
+ const markAssistantBodyMarkdown = (component: any) => {
319
+ // Pi adds answer text directly; a thought block is wrapped in a MouseRegion.
320
+ // A component built before this runtime is covered too: the rendered-mode
321
+ // symbols are per runtime, so its first render here re-runs updateContent.
322
+ for (const child of component.contentContainer?.children ?? []) {
323
+ if (child instanceof Markdown && !(child as any).defaultTextStyle) assistantBodyMarkdown.add(child);
324
+ }
325
+ };
303
326
 
304
327
  const setCleanGroupMembers = (lastToolCallId: string, toolCallIds: string[]) => {
305
328
  cleanGroupToolCallIds.set(lastToolCallId, toolCallIds);
@@ -676,7 +699,9 @@ export default function prettyTui(pi: ExtensionAPI) {
676
699
  { value: "full", label: "full", description: "Full tool details and output" },
677
700
  { value: "compact", label: "compact", description: "Concise summaries for all built-in tools" },
678
701
  { value: "clean", label: "clean", description: "Group supported tools into Running/Done status" },
679
- { value: "status", label: "status", description: "Show the current mode" },
702
+ { value: "selection-copy on", label: "selection-copy on", description: "Copy selected text without UI borders in fullscreen mode; enable and reload" },
703
+ { value: "selection-copy off", label: "selection-copy off", description: "Restore native selection copying and reload" },
704
+ { value: "status", label: "status", description: "Show the current settings" },
680
705
  ];
681
706
  const filtered = items.filter((item) => item.value.startsWith(prefix.trim().toLowerCase()));
682
707
  return filtered.length > 0 ? filtered : null;
@@ -687,7 +712,7 @@ export default function prettyTui(pi: ExtensionAPI) {
687
712
  if (!requested) {
688
713
  if (!ctx.hasUI) {
689
714
  ctx.ui.notify(
690
- `pi-pretty-tui: ${config.enabled !== false ? "enabled" : "disabled"} · mode: ${renderMode}`,
715
+ `pi-pretty-tui: ${config.enabled !== false ? "enabled" : "disabled"} · mode: ${renderMode} · selection copy: ${config.selectionCopy === true ? "on" : "off"}`,
691
716
  "info",
692
717
  );
693
718
  return;
@@ -696,9 +721,12 @@ export default function prettyTui(pi: ExtensionAPI) {
696
721
  const full = `Full — full tool details and output${renderMode === "full" ? " (current)" : ""}`;
697
722
  const compact = `Compact — concise tool summaries and nested calls${renderMode === "compact" ? " (current)" : ""}`;
698
723
  const clean = `Clean — group supported tools into Running/Done status${renderMode === "clean" ? " (current)" : ""}`;
699
- const selected = await ctx.ui.select("pi-pretty-tui settings", [enabled, full, compact, clean]);
724
+ const selectionCopy = `Selection copy — ${config.selectionCopy === true ? "on" : "off"} · copy text without UI borders (fullscreen, experimental)`;
725
+ const selected = await ctx.ui.select("pi-pretty-tui settings", [enabled, full, compact, clean, selectionCopy]);
700
726
  if (!selected) return;
701
- requested = selected === enabled
727
+ requested = selected === selectionCopy
728
+ ? config.selectionCopy === true ? "selection-copy off" : "selection-copy on"
729
+ : selected === enabled
702
730
  ? config.enabled !== false ? "disable" : "enable"
703
731
  : selected === full
704
732
  ? "full"
@@ -709,11 +737,28 @@ export default function prettyTui(pi: ExtensionAPI) {
709
737
 
710
738
  if (requested === "status") {
711
739
  ctx.ui.notify(
712
- `pi-pretty-tui: ${config.enabled !== false ? "enabled" : "disabled"} · mode: ${renderMode}`,
740
+ `pi-pretty-tui: ${config.enabled !== false ? "enabled" : "disabled"} · mode: ${renderMode} · selection copy: ${config.selectionCopy === true ? "on" : "off"}`,
713
741
  "info",
714
742
  );
715
743
  return;
716
744
  }
745
+ if (requested === "selection-copy on" || requested === "selection-copy off") {
746
+ const selectionCopy = requested.endsWith(" on");
747
+ try {
748
+ saveConfig({ ...config, selectionCopy });
749
+ } catch (error) {
750
+ const message = error instanceof Error ? error.message : String(error);
751
+ ctx.ui.notify(`Could not save selection-copy setting: ${message}`, "error");
752
+ return;
753
+ }
754
+ ctx.ui.notify(`Border-free selection copying ${selectionCopy ? "enabled" : "disabled"}; reloading…`, "info");
755
+ try { await ctx.reload(); }
756
+ catch (error) {
757
+ const message = error instanceof Error ? error.message : String(error);
758
+ ctx.ui.notify(`Setting saved, but automatic reload failed: ${message}`, "warning");
759
+ }
760
+ return;
761
+ }
717
762
  if (requested === "enable" || requested === "disable") {
718
763
  const enabled = requested === "enable";
719
764
  try {
@@ -737,7 +782,7 @@ export default function prettyTui(pi: ExtensionAPI) {
737
782
  }
738
783
  }
739
784
  if (requested !== "full" && requested !== "compact" && requested !== "clean") {
740
- ctx.ui.notify("Usage: /pretty-tui [enable|disable|full|compact|clean|status]", "error");
785
+ ctx.ui.notify("Usage: /pretty-tui [enable|disable|full|compact|clean|status|selection-copy on|selection-copy off]", "error");
741
786
  return;
742
787
  }
743
788
 
@@ -764,6 +809,7 @@ export default function prettyTui(pi: ExtensionAPI) {
764
809
  // render patches or override built-in tools until the next enabled reload.
765
810
  if (!activeForSession) return;
766
811
  pi.on("session_shutdown", stopWorkingElapsedTimer);
812
+ if (config.selectionCopy === true) pi.on("session_shutdown", installSelectionCopy());
767
813
 
768
814
  // Give Pi's main prompt editor a complete rounded frame. Render the native
769
815
  // editor at a two-column narrower width so cursor layout, wrapping, IME, and
@@ -812,9 +858,9 @@ export default function prettyTui(pi: ExtensionAPI) {
812
858
 
813
859
  return lines.map((line, index) => {
814
860
  const fitted = fitInnerWidth(line);
815
- if (index === 0) return this.borderColor("╭") + fitted + this.borderColor("╮");
816
- if (index === bottomBorderIndex) return this.borderColor("╰") + fitted + this.borderColor("╯");
817
- if (index < bottomBorderIndex) return this.borderColor("│") + fitted + this.borderColor("│");
861
+ if (index === 0) return selectableRow(this.borderColor("╭") + fitted + this.borderColor("╮"), width, []);
862
+ if (index === bottomBorderIndex) return selectableRow(this.borderColor("╰") + fitted + this.borderColor("╯"), width, []);
863
+ if (index < bottomBorderIndex) return selectableRow(this.borderColor("│") + fitted + this.borderColor("│"), width, [[1, width - 1]]);
818
864
  // Autocomplete remains outside the input frame, aligned to its content.
819
865
  return " " + fitted + " ";
820
866
  });
@@ -980,9 +1026,10 @@ export default function prettyTui(pi: ExtensionAPI) {
980
1026
  const bottom = border("╰" + "─".repeat(Math.max(0, measured.frameWidth - 2)) + "╯");
981
1027
  const body = measured.body.map((line: string) => {
982
1028
  const padding = " ".repeat(Math.max(0, measured.contentWidth - visibleWidth(line)));
983
- return border("│ ") + line + padding + border(" │");
1029
+ return selectableRow(border("│ ") + line + padding + border(" │"), measured.frameWidth, [[2, 2 + Math.max(1, Math.min(measured.contentWidth, visibleWidth(line)))]]);
984
1030
  });
985
- return [top, ...body, bottom].map((line: string) => left + line + right);
1031
+ return [selectableRow(top, measured.frameWidth, []), ...body, selectableRow(bottom, measured.frameWidth, [])]
1032
+ .map((line: string) => selectableRow(left + line + right, width, [[measured.leftPad, width - measured.rightPad]]));
986
1033
  },
987
1034
  handleMouse(event: any) {
988
1035
  const measured = layout(event.width);
@@ -1378,6 +1425,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1378
1425
  originalUpdateContent.call(this, renderMode === "compact" && assistantTerminalState(message)
1379
1426
  ? { ...message, errorMessage: undefined, stopReason: "stop" } : message, isStreaming);
1380
1427
  this.lastMessage = message;
1428
+ markAssistantBodyMarkdown(this);
1381
1429
  if (eligibleThinking(message)) thinkingComponents.set(assistantMessageKey(message), this);
1382
1430
  return;
1383
1431
  }
@@ -1405,6 +1453,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1405
1453
  this.hideThinkingBlock = previousHideThinkingBlock;
1406
1454
  }
1407
1455
  this.lastMessage = message;
1456
+ markAssistantBodyMarkdown(this);
1408
1457
  };
1409
1458
 
1410
1459
  const renderAssistantTranscript = function (this: any, width: number): string[] {
@@ -2261,6 +2310,11 @@ export default function prettyTui(pi: ExtensionAPI) {
2261
2310
  const codeBlockRegionsKey = Symbol("pretty-tui.code-block-regions");
2262
2311
  const transcriptMarkdownStateKey = Symbol("pretty-tui.transcript-markdown-state");
2263
2312
  if (!markdownPrototype[codeBlockPatchKey]) {
2313
+ // Pi 1.5 reuses rendered top-level blocks while text streams. Keep copy
2314
+ // descriptors with each native block's highlighting-cache identity, weakly,
2315
+ // so reused pixels retain their hit regions without disabling that cache.
2316
+ const blockCopyDescriptors = new WeakMap<object, { generation: number; blocks: any[] }>();
2317
+ let copyRenderGeneration = 0;
2264
2318
  const originalInvalidate = markdownPrototype.invalidate;
2265
2319
  const originalRender = markdownPrototype.render;
2266
2320
  const originalRenderToken = markdownPrototype.renderToken;
@@ -2274,6 +2328,21 @@ export default function prettyTui(pi: ExtensionAPI) {
2274
2328
  return originalInvalidate.call(this);
2275
2329
  };
2276
2330
 
2331
+ const copyRows = new WeakMap<object, { source: string[]; padding: number; lines: string[] }>();
2332
+ const selectableMarkdown = (owner: any, source: string[]) => {
2333
+ if (!selectionMetadataActive()) return source;
2334
+ const padding = Math.max(0, Number(owner.paddingX) || 0);
2335
+ if (!padding) return source;
2336
+ const cached = copyRows.get(owner);
2337
+ if (cached?.source === source && cached.padding === padding) return cached.lines;
2338
+ const lines = source.map(line => {
2339
+ const width = visibleWidth(line);
2340
+ return selectableRow(line, width, [[Math.min(padding, width), Math.max(Math.min(padding, width), width - padding)]]);
2341
+ });
2342
+ copyRows.set(owner, { source, padding, lines });
2343
+ return lines;
2344
+ };
2345
+
2277
2346
  const patchedRender = function (this: any, width: number): string[] {
2278
2347
  const scoped = transcriptMarkdownActive();
2279
2348
  mouseClickHitTests.set(this, (event) => fullscreenTui &&
@@ -2290,14 +2359,22 @@ export default function prettyTui(pi: ExtensionAPI) {
2290
2359
 
2291
2360
  const needsRender =
2292
2361
  !this.cachedLines || this.cachedText !== this.text || this.cachedWidth !== width;
2293
- if (needsRender) this[codeBlockCollectionKey] = [];
2362
+ if (needsRender) this[codeBlockCollectionKey] = Object.assign([], { generation: ++copyRenderGeneration });
2294
2363
 
2295
2364
  const lines = originalRender.call(this, width);
2296
- if (!needsRender) return lines;
2365
+ if (!needsRender) return selectableMarkdown(this, lines);
2297
2366
 
2298
2367
  const regions: Array<{ xStart: number; xEnd: number; y: number; code: string }> = [];
2299
2368
  let searchFrom = 0;
2300
- for (const block of this[codeBlockCollectionKey] ?? []) {
2369
+ const nativeBlocks = this.blocks?.deref();
2370
+ const nativeTokens = this.cachedTokens?.deref()?.tokens;
2371
+ const copyBlocks = nativeBlocks && nativeTokens
2372
+ ? nativeTokens.flatMap((token: any) => {
2373
+ const block = nativeBlocks.get(token.raw);
2374
+ return block ? blockCopyDescriptors.get(block.highlightedCode)?.blocks ?? [] : [];
2375
+ })
2376
+ : this[codeBlockCollectionKey] ?? [];
2377
+ for (const block of copyBlocks) {
2301
2378
  let headerLine = -1;
2302
2379
  for (let lineIndex = searchFrom; lineIndex < lines.length; lineIndex += 1) {
2303
2380
  const renderedLine = lines[lineIndex] ?? "";
@@ -2321,7 +2398,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2321
2398
  }
2322
2399
  this[codeBlockRegionsKey] = regions;
2323
2400
  delete this[codeBlockCollectionKey];
2324
- return lines;
2401
+ return selectableMarkdown(this, lines);
2325
2402
  };
2326
2403
 
2327
2404
  const patchedRenderToken = function (
@@ -2343,36 +2420,20 @@ export default function prettyTui(pi: ExtensionAPI) {
2343
2420
  return lines;
2344
2421
  };
2345
2422
  const headingStyle = (text: string) => this.theme.heading(text);
2346
- const foregroundLuminance = (styled: string): number | undefined => {
2347
- const trueColor = /\x1b\[38;2;(\d+);(\d+);(\d+)m/.exec(styled);
2348
- if (!trueColor) return undefined;
2349
- const [, red, green, blue] = trueColor.map(Number);
2350
- return red * 0.2126 + green * 0.7152 + blue * 0.0722;
2351
- };
2352
- const defaultLuminance = foregroundLuminance(this.applyDefaultStyle("M"));
2353
- const headingLuminance = foregroundLuminance(headingStyle("M"));
2354
- const darkTheme =
2355
- defaultLuminance !== undefined
2356
- ? defaultLuminance >= 128
2357
- : (headingLuminance ?? 255) >= 160;
2358
- const whiteStyle = (text: string) => `\x1b[97m${text}\x1b[39m`;
2423
+ // Descending emphasis: framed H1, underlined H2, bold H3,
2424
+ // plain H4, decorative H5, and quote-colored italic H6.
2359
2425
  const titleStyle =
2360
- level === 1 || level === 3
2426
+ level === 1 || level === 2
2361
2427
  ? (text: string) => headingStyle(this.theme.bold(this.theme.underline(text)))
2362
- : level === 2
2363
- ? (text: string) =>
2364
- (darkTheme ? whiteStyle : headingStyle)(
2365
- this.theme.bold(this.theme.underline(text)),
2366
- )
2367
- : level === 4
2368
- ? (text: string) => headingStyle(this.theme.bold(text))
2369
- : level === 5
2370
- ? headingStyle
2371
- : (text: string) => this.theme.quote(this.theme.italic(text));
2372
- const titleContext = {
2428
+ : level === 3
2429
+ ? (text: string) => headingStyle(this.theme.bold(text))
2430
+ : level === 4 || level === 5
2431
+ ? headingStyle
2432
+ : (text: string) => this.theme.quote(this.theme.italic(text));
2433
+ const titleContext = withNativeEmphasis({
2373
2434
  applyText: titleStyle,
2374
2435
  stylePrefix: this.getStylePrefix(titleStyle),
2375
- };
2436
+ });
2376
2437
  const title = this.renderInlineTokens(token.tokens || [], titleContext);
2377
2438
 
2378
2439
  if (level === 1 && maxWidth >= 6) {
@@ -2395,17 +2456,6 @@ export default function prettyTui(pi: ExtensionAPI) {
2395
2456
  return addHeadingSpacing(lines);
2396
2457
  }
2397
2458
 
2398
- if (level === 2) {
2399
- // Reverse only the rendered title cells: the theme's heading color
2400
- // becomes a compact, content-width background label with no frame.
2401
- const lines = wrapTextWithAnsi(title, maxWidth).map((line: string) =>
2402
- darkTheme
2403
- ? `\x1b[48;5;94m${line}\x1b[49m`
2404
- : `\x1b[107m\x1b[7m${line}\x1b[27m\x1b[49m`
2405
- );
2406
- return addHeadingSpacing(lines);
2407
- }
2408
-
2409
2459
  if (level === 5 && maxWidth >= 8) {
2410
2460
  const prefix = "┄┄ ";
2411
2461
  const suffix = " ┄┄";
@@ -2426,7 +2476,10 @@ export default function prettyTui(pi: ExtensionAPI) {
2426
2476
  }
2427
2477
 
2428
2478
  if (token?.type !== "code") {
2429
- return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
2479
+ return [
2480
+ ...originalRenderToken.call(this, token, width, nextTokenType, styleContext),
2481
+ ...gapRowsAfterBlock(token?.type, nextTokenType),
2482
+ ];
2430
2483
  }
2431
2484
 
2432
2485
  const maxWidth = Math.max(1, width);
@@ -2479,24 +2532,32 @@ export default function prettyTui(pi: ExtensionAPI) {
2479
2532
  (showCopyButton ? this.theme.codeBlockBorder(` ${copyLabel} `) : "") + frameBorder("╮");
2480
2533
  const framedCodeWidth = Math.max(1, frameWidth - 4);
2481
2534
  const lines = [
2482
- styledTopRule,
2483
- ...codeLines.map((line) =>
2484
- frameBorder("│ ") +
2485
- line +
2486
- " ".repeat(Math.max(0, framedCodeWidth - visibleWidth(line))) +
2487
- frameBorder(" │")
2488
- ),
2489
- frameBorder(`╰${"─".repeat(Math.max(0, frameWidth - 2))}╯`),
2535
+ selectableRow(styledTopRule, frameWidth, []),
2536
+ ...codeLines.map((line) => selectableRow(
2537
+ frameBorder("│ ") + line +
2538
+ " ".repeat(Math.max(0, framedCodeWidth - visibleWidth(line))) + frameBorder(" │"),
2539
+ frameWidth, [[2, frameWidth - 2]])),
2540
+ selectableRow(frameBorder(`╰${"─".repeat(Math.max(0, frameWidth - 2))}╯`), frameWidth, []),
2490
2541
  ];
2491
2542
 
2492
2543
  if (showCopyButton && Array.isArray(this[codeBlockCollectionKey])) {
2493
- this[codeBlockCollectionKey].push({
2544
+ const descriptor = {
2494
2545
  buttonStart,
2495
2546
  buttonWidth: visibleWidth(copyLabel),
2496
2547
  code,
2497
2548
  styledTopRule,
2498
2549
  topRule,
2499
- });
2550
+ };
2551
+ const collection = this[codeBlockCollectionKey] as any[] & { generation: number };
2552
+ collection.push(descriptor);
2553
+ if (this.blockHighlightedCode) {
2554
+ let cached = blockCopyDescriptors.get(this.blockHighlightedCode);
2555
+ if (!cached || cached.generation !== collection.generation) {
2556
+ cached = { generation: collection.generation, blocks: [] };
2557
+ blockCopyDescriptors.set(this.blockHighlightedCode, cached);
2558
+ }
2559
+ cached.blocks.push(descriptor);
2560
+ }
2500
2561
  }
2501
2562
  if (nextTokenType && nextTokenType !== "space") lines.push("");
2502
2563
  return lines;
@@ -2581,7 +2642,11 @@ export default function prettyTui(pi: ExtensionAPI) {
2581
2642
  this.theme.listBullet = (marker: string) =>
2582
2643
  originalListBullet(marker.replace(/^- /, "• "));
2583
2644
  try {
2584
- return originalRenderList.call(this, token, depth, width, styleContext);
2645
+ if (!transcriptMarkdownActive()) {
2646
+ return originalRenderList.call(this, token, depth, width, styleContext);
2647
+ }
2648
+ return renderSpacedList(token, (singleItemList) =>
2649
+ originalRenderList.call(this, singleItemList, depth, width, styleContext));
2585
2650
  } finally {
2586
2651
  this.theme.listBullet = originalListBullet;
2587
2652
  }
@@ -2599,6 +2664,37 @@ export default function prettyTui(pi: ExtensionAPI) {
2599
2664
  });
2600
2665
  }
2601
2666
 
2667
+ const inlineEmphasisPatchKey = Symbol.for("pretty-tui.inline-emphasis");
2668
+ if (config.markdownEmphasis !== "native" && !markdownPrototype[inlineEmphasisPatchKey]) {
2669
+ const originalRenderInlineTokens = markdownPrototype.renderInlineTokens;
2670
+ const patchedRenderInlineTokens = function (
2671
+ this: any,
2672
+ tokens: InlineToken[],
2673
+ styleContext?: InlineStyleContext,
2674
+ ): string {
2675
+ const theme = currentExtensionUi?.theme;
2676
+ if (!transcriptMarkdownActive() || !assistantBodyMarkdown.has(this) || !theme?.fg) {
2677
+ return originalRenderInlineTokens.call(this, tokens, styleContext);
2678
+ }
2679
+ return originalRenderInlineTokens.call(
2680
+ this,
2681
+ tokens,
2682
+ themedInlineContext(tokens, styleContext ?? this.getDefaultInlineStyleContext(), theme),
2683
+ );
2684
+ };
2685
+
2686
+ markdownPrototype[inlineEmphasisPatchKey] = { originalRenderInlineTokens, patchedRenderInlineTokens };
2687
+ markdownPrototype.renderInlineTokens = patchedRenderInlineTokens;
2688
+
2689
+ pi.on("session_shutdown", () => {
2690
+ const patch = markdownPrototype[inlineEmphasisPatchKey];
2691
+ if (patch?.patchedRenderInlineTokens === markdownPrototype.renderInlineTokens) {
2692
+ markdownPrototype.renderInlineTokens = patch.originalRenderInlineTokens;
2693
+ }
2694
+ if (patch) delete markdownPrototype[inlineEmphasisPatchKey];
2695
+ });
2696
+ }
2697
+
2602
2698
  const textContent = (result: any): string =>
2603
2699
  result.content
2604
2700
  ?.filter((item: any) => item.type === "text")
@@ -3182,13 +3278,12 @@ export default function prettyTui(pi: ExtensionAPI) {
3182
3278
  if (lines.length === 0) return lines;
3183
3279
  contentLines = lines[0] === "" ? lines.slice(1) : lines;
3184
3280
  }
3185
- decoratedContent = contentLines.map((line: string, index: number) =>
3186
- truncateToWidth(
3187
- (index === 0 ? childPrefix : continuation) + line,
3188
- Math.max(1, width),
3189
- "",
3190
- )
3191
- );
3281
+ decoratedContent = contentLines.map((line: string, index: number) => {
3282
+ const prefix = index === 0 ? childPrefix : continuation;
3283
+ const rendered = truncateToWidth(prefix + line, Math.max(1, width), "");
3284
+ return selectionMetadataActive()
3285
+ ? selectableRow(rendered, visibleWidth(rendered), [[visibleWidth(prefix), visibleWidth(rendered)]]) : rendered;
3286
+ });
3192
3287
  if (cacheable) {
3193
3288
  this[cleanChildRenderCacheKey] = {
3194
3289
  width,
@@ -4333,21 +4428,31 @@ export default function prettyTui(pi: ExtensionAPI) {
4333
4428
  grep: createGrepToolDefinition(cwd), find: createFindToolDefinition(cwd), ls: createLsToolDefinition(cwd),
4334
4429
  };
4335
4430
  const registerStyledTool = (definition: any) => {
4336
- const native = nativeDefinitions[definition.name];
4337
- const select = (kind: "renderCall" | "renderResult", args: any[]) => {
4338
- const context = args.at(-1);
4339
- const useNative = renderMode === "full" || renderMode === "compact" && context.expanded;
4340
- const namespace = useNative ? "nativeFull" : "pretty";
4341
- const state = context.state[namespace] ??= { state: {} };
4342
- const scoped = { ...context, state: state.state, lastComponent: state[kind] };
4343
- const renderer = useNative ? native[kind] : definition[kind];
4344
- const component = renderer?.(...args.slice(0, -1), scoped);
4345
- state[kind] = component;
4346
- return component;
4347
- };
4348
- pi.registerTool({ ...definition,
4349
- renderCall: (...args: any[]) => select("renderCall", args),
4350
- renderResult: (...args: any[]) => select("renderResult", args),
4431
+ pi.registerToolRenderer((name, next) => {
4432
+ const downstream = next();
4433
+ if (name !== definition.name) return downstream;
4434
+ // A same-named extension/SDK tool owns its own rendering and parameters.
4435
+ // In particular, do not apply built-in summaries to an unrelated schema.
4436
+ const source = pi.getAllTools().find((tool) => tool.name === name)?.sourceInfo;
4437
+ if (source && source.path !== `builtin:${name}`) {
4438
+ return downstream ? { ...downstream, [THIRD_PARTY_TOOL]: true } : downstream;
4439
+ }
4440
+ const native = nativeDefinitions[name];
4441
+ const select = (kind: "renderCall" | "renderResult", args: any[]) => {
4442
+ const context = args.at(-1);
4443
+ const useNative = renderMode === "full" || renderMode === "compact" && context.expanded;
4444
+ const namespace = useNative ? "nativeFull" : "pretty";
4445
+ const state = context.state[namespace] ??= { state: {} };
4446
+ const scoped = { ...context, state: state.state, lastComponent: state[kind] };
4447
+ const renderer = useNative ? downstream?.[kind] ?? native[kind] : definition[kind];
4448
+ const component = renderer?.(...args.slice(0, -1), scoped);
4449
+ state[kind] = component;
4450
+ return component;
4451
+ };
4452
+ return { ...downstream, renderShell: "self",
4453
+ renderCall: (...args: any[]) => select("renderCall", args),
4454
+ renderResult: (...args: any[]) => select("renderResult", args),
4455
+ };
4351
4456
  });
4352
4457
  };
4353
4458
 
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Foreground colors for Markdown strong and emphasis in the assistant's answer.
3
+ *
4
+ * Pi has no strong or emphasis color token, so these reuse existing ones. Each is
5
+ * read from the active theme on every render, so dark, light, custom themes and
6
+ * `/theme` all apply. `syntaxType` looks like an option for emphasis, but it equals
7
+ * `mdCode` in Pi's built-in themes, which would make emphasis look like inline code.
8
+ */
9
+ export const MARKDOWN_EMPHASIS_COLORS = {
10
+ strong: "syntaxNumber",
11
+ em: "warning",
12
+ } as const;
13
+
14
+ type EmphasisType = keyof typeof MARKDOWN_EMPHASIS_COLORS;
15
+ type EmphasisColor = (typeof MARKDOWN_EMPHASIS_COLORS)[EmphasisType];
16
+ type ChildOwner = EmphasisType | "link";
17
+
18
+ export interface EmphasisTheme {
19
+ fg(color: EmphasisColor, text: string): string;
20
+ }
21
+
22
+ /** Pi's inline style context: how text is styled, and what restores it after a nested span closes. */
23
+ export interface InlineStyleContext {
24
+ applyText: (text: string) => string;
25
+ stylePrefix: string;
26
+ }
27
+
28
+ export interface InlineToken {
29
+ type: string;
30
+ tokens?: InlineToken[];
31
+ }
32
+
33
+ const SENTINEL = "\0";
34
+
35
+ const isChildOwner = (type: string): type is ChildOwner =>
36
+ type === "strong" || type === "em" || type === "link";
37
+
38
+ // Pi passes a token's children to renderInlineTokens with the parent's context and
39
+ // does not say who the parent was. Keyed by array, so Pi's cached tokens stay unmodified.
40
+ const childOwners = new WeakMap<readonly InlineToken[], ChildOwner>();
41
+ const nativeContexts = new WeakSet<InlineStyleContext>();
42
+ const surroundingContexts = new WeakMap<InlineStyleContext, InlineStyleContext>();
43
+
44
+ /** Marks a context whose text keeps its own foreground, such as a heading title. */
45
+ export function withNativeEmphasis(context: InlineStyleContext): InlineStyleContext {
46
+ nativeContexts.add(context);
47
+ return context;
48
+ }
49
+
50
+ function emphasisContext(
51
+ surrounding: InlineStyleContext,
52
+ color: EmphasisColor,
53
+ theme: EmphasisTheme,
54
+ ): InlineStyleContext {
55
+ const context: InlineStyleContext = {
56
+ // The inner color wins, so a surrounding default style cannot hide the emphasis color.
57
+ applyText: (text) => surrounding.applyText(theme.fg(color, text)),
58
+ // A nested span's foreground close falls back to the terminal default, so
59
+ // Pi's stylePrefix has to reopen the emphasis color for the text after it.
60
+ stylePrefix: surrounding.stylePrefix + theme.fg(color, SENTINEL).split(SENTINEL)[0],
61
+ };
62
+ surroundingContexts.set(context, surrounding);
63
+ return context;
64
+ }
65
+
66
+ /**
67
+ * Returns the context Pi should use for these tokens. Only the context changes:
68
+ * Pi's own recursion still emits every flag and every nested span.
69
+ *
70
+ * The innermost strong or emphasis sets the foreground. A link label uses the
71
+ * context outside all emphasis, so it keeps Pi's link color, and emphasis inside
72
+ * it stays flags-only for the same reason. Inline code is untouched by contexts.
73
+ */
74
+ export function themedInlineContext(
75
+ tokens: readonly InlineToken[],
76
+ context: InlineStyleContext,
77
+ theme: EmphasisTheme,
78
+ ): InlineStyleContext {
79
+ const owner = childOwners.get(tokens);
80
+ for (const token of tokens) {
81
+ if (token.tokens && isChildOwner(token.type)) childOwners.set(token.tokens, token.type);
82
+ }
83
+ if (!owner || nativeContexts.has(context)) return context;
84
+ const surrounding = surroundingContexts.get(context) ?? context;
85
+ if (owner === "link") return withNativeEmphasis({ ...surrounding });
86
+ return emphasisContext(surrounding, MARKDOWN_EMPHASIS_COLORS[owner], theme);
87
+ }