@oh-my-pi/pi-tui 18.11.3 → 18.12.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +22 -83
  2. package/README.md +36 -0
  3. package/dist/types/render/hanging-wrap.d.ts +2 -0
  4. package/dist/types/render/render-utils.d.ts +9 -1
  5. package/dist/types/status-line/types.d.ts +3 -0
  6. package/dist/types/terminal-capabilities.d.ts +8 -1
  7. package/dist/types/terminal-multiplexer.d.ts +6 -0
  8. package/dist/types/theme/theme.d.ts +11 -0
  9. package/dist/types/tools/ast-edit.d.ts +2 -2
  10. package/dist/types/tools/ast-grep.d.ts +2 -2
  11. package/dist/types/tools/edit.d.ts +2 -0
  12. package/dist/types/tools/find.d.ts +14 -4
  13. package/dist/types/tools/glob.d.ts +3 -3
  14. package/dist/types/tools/grep.d.ts +2 -2
  15. package/dist/types/tools/read.d.ts +13 -5
  16. package/dist/types/tools/write.d.ts +2 -0
  17. package/package.json +9 -9
  18. package/src/chat/assistant-message.ts +3 -1
  19. package/src/chat/read-tool-group.ts +17 -9
  20. package/src/chat/tool-execution.ts +23 -6
  21. package/src/components/image.ts +31 -14
  22. package/src/render/hanging-wrap.ts +28 -1
  23. package/src/render/render-utils.ts +16 -2
  24. package/src/status-line/component.ts +1 -0
  25. package/src/status-line/segments.ts +4 -3
  26. package/src/status-line/types.ts +1 -0
  27. package/src/terminal-capabilities.ts +26 -2
  28. package/src/terminal-multiplexer.ts +14 -0
  29. package/src/terminal.ts +11 -2
  30. package/src/theme/theme.ts +46 -14
  31. package/src/tmux.ts +7 -1
  32. package/src/tools/ast-edit.ts +28 -4
  33. package/src/tools/ast-grep.ts +24 -4
  34. package/src/tools/debug.ts +2 -1
  35. package/src/tools/default-renderer.ts +3 -2
  36. package/src/tools/edit.ts +77 -16
  37. package/src/tools/find.ts +65 -15
  38. package/src/tools/glob.ts +51 -17
  39. package/src/tools/grep.ts +94 -11
  40. package/src/tools/lsp.ts +23 -18
  41. package/src/tools/mcp.ts +2 -2
  42. package/src/tools/read.ts +52 -22
  43. package/src/tools/task.ts +9 -7
  44. package/src/tools/web-search.ts +2 -1
  45. package/src/tools/write.ts +17 -5
  46. package/src/tools/yield.ts +2 -2
  47. package/src/tui.ts +36 -0
@@ -8,7 +8,7 @@ import { Text } from "../components/text";
8
8
  import { getImageDimensions, ImageProtocol, imageFallback, TERMINAL } from "../terminal-capabilities";
9
9
  import { type Component, Container, type TUI } from "../tui";
10
10
  import { truncateToWidth } from "../utils";
11
- import { getProjectDir, isRecord, logger, sanitizeText } from "@oh-my-pi/pi-utils";
11
+ import { getProjectDir, isRecord, logger, sanitizeText, sanitizeTextDeep } from "@oh-my-pi/pi-utils";
12
12
  import type { Theme } from "../theme/theme";
13
13
  import { ensureThemeSync, getThemeEpoch, theme } from "../theme/theme";
14
14
  import {
@@ -420,6 +420,8 @@ export class ToolExecutionComponent extends Container {
420
420
  isError?: boolean;
421
421
  details?: unknown;
422
422
  };
423
+ // Machine paths remain verbatim; only #result.details is handed to display renderers.
424
+ #rawDetails: unknown;
423
425
  // Edit preview state
424
426
  #editMode?: EditMode;
425
427
  #editDiffPreview?: PerFileDiffPreview[];
@@ -632,7 +634,11 @@ export class ToolExecutionComponent extends Container {
632
634
  const partialResultPainted = this.#partialResultShapePainted;
633
635
  this.#firstResultViewportRepaintShapePainted = false;
634
636
  this.#partialResultShapePainted = false;
635
- this.#result = result;
637
+ this.#rawDetails = result.details;
638
+ // Renderers receive sanitized details for display; raw paths are available
639
+ // separately for links and media through the built-in render context.
640
+ const details = sanitizeTextDeep(result.details);
641
+ this.#result = details === result.details ? result : { ...result, details };
636
642
  this.#resultVersion++;
637
643
  this.#blockVersion++;
638
644
  this.#isPartial = isPartial;
@@ -1251,7 +1257,7 @@ export class ToolExecutionComponent extends Container {
1251
1257
  #nativeResultImages(body: readonly NativeChild[] | undefined): NativeNode[] {
1252
1258
  if (!this.#result || !this.#showImages) return [];
1253
1259
  const shown = shownMedia(body);
1254
- const mediaPath = readMediaPath(this.#result.details);
1260
+ const mediaPath = readMediaPath(this.#rawDetails);
1255
1261
  if (mediaPath && shown.paths.has(mediaPath)) return [];
1256
1262
  const images: NativeNode[] = [];
1257
1263
  this.#getAllImageBlocks().forEach((image, index) => {
@@ -1558,6 +1564,10 @@ export class ToolExecutionComponent extends Container {
1558
1564
  this.#contentBox.clear();
1559
1565
 
1560
1566
  const renderContext = this.#buildRenderContext();
1567
+ const rawPerFileResults =
1568
+ isRecord(this.#rawDetails) && Array.isArray(this.#rawDetails.perFileResults)
1569
+ ? this.#rawDetails.perFileResults
1570
+ : undefined;
1561
1571
  this.#renderState.renderContext = renderContext;
1562
1572
 
1563
1573
  for (let i = 0; i < perFileResults.length; i++) {
@@ -1569,9 +1579,13 @@ export class ToolExecutionComponent extends Container {
1569
1579
  }
1570
1580
  const fileBox = new Box(0, 0);
1571
1581
  try {
1582
+ const fileOptions = {
1583
+ ...this.#renderState,
1584
+ renderContext: { ...renderContext, rawDetails: rawPerFileResults?.[i] },
1585
+ };
1572
1586
  const resultComponent = renderer.renderResult(
1573
1587
  { content: [], details: fileResult, isError: fileResult.isError },
1574
- this.#renderState,
1588
+ fileOptions,
1575
1589
  theme,
1576
1590
  );
1577
1591
  if (resultComponent) {
@@ -1695,7 +1709,7 @@ export class ToolExecutionComponent extends Container {
1695
1709
 
1696
1710
  if (this.#result) {
1697
1711
  const imageBlocks = this.#getAllImageBlocks();
1698
- const sourcePath = readMediaPath(this.#result.details);
1712
+ const sourcePath = readMediaPath(this.#rawDetails);
1699
1713
 
1700
1714
  for (let i = 0; i < imageBlocks.length; i++) {
1701
1715
  const img = imageBlocks[i];
@@ -1802,6 +1816,9 @@ export class ToolExecutionComponent extends Container {
1802
1816
  return Math.max(1, Math.min(maxSeconds, value));
1803
1817
  };
1804
1818
 
1819
+ if (this.#renderer && !this.#tool?.renderResult && this.#rawDetails !== undefined) {
1820
+ context.rawDetails = this.#rawDetails;
1821
+ }
1805
1822
  if (this.#toolName === "bash") {
1806
1823
  // Bash needs render context even before a result exists. The renderer uses the pending-call args
1807
1824
  // plus this context to keep the inline command preview visible while tool-call JSON is still streaming.
@@ -1882,7 +1899,7 @@ export class ToolExecutionComponent extends Container {
1882
1899
  const dims = img.data ? (getImageDimensions(img.data, mimeType) ?? undefined) : undefined;
1883
1900
  const placeholder = imageFallback(mimeType, dims);
1884
1901
  return img.data
1885
- ? linkedImageText(placeholder, { data: img.data, mimeType }, readMediaPath(this.#result?.details))
1902
+ ? linkedImageText(placeholder, { data: img.data, mimeType }, readMediaPath(this.#rawDetails))
1886
1903
  : placeholder;
1887
1904
  })
1888
1905
  .join("\n");
@@ -4,6 +4,7 @@ import {
4
4
  encodeSixelNow,
5
5
  getCellDimensions,
6
6
  getImageDimensions,
7
+ getTerminalBackgroundColor,
7
8
  type ImageDimensions,
8
9
  ImageProtocol,
9
10
  imageFallback,
@@ -41,6 +42,13 @@ export interface ImageOptions {
41
42
  requestRender?: () => void;
42
43
  }
43
44
 
45
+ interface SixelEncode {
46
+ widthPx: number;
47
+ heightPx: number;
48
+ backgroundRgb: number;
49
+ sequence?: string | null;
50
+ }
51
+
44
52
  /** Renders in progress whose rows are bound for native scrollback. */
45
53
  let scrollbackRenderDepth = 0;
46
54
 
@@ -655,6 +663,7 @@ export class Image implements Component {
655
663
  #cachedImageProtocol: typeof TERMINAL.imageProtocol = null;
656
664
  #cachedCellWidthPx = 0;
657
665
  #cachedCellHeightPx = 0;
666
+ #cachedSixelBackground: number | undefined;
658
667
  #cachedKittyUnicodePlaceholders = false;
659
668
  #cachedLinked = false;
660
669
  // Tallest graphic placement this image has rendered. The text fallback
@@ -663,8 +672,8 @@ export class Image implements Component {
663
672
  #renderedGraphicRows = 0;
664
673
  /** Described node and the link policy it was built under (an `off` switch drops its `href`). */
665
674
  #native?: { linked: boolean; node: NativeNode };
666
- /** Newest SIXEL encode: its target size and, once settled, the sequence (`null` on failure). */
667
- #sixel?: { widthPx: number; heightPx: number; sequence?: string | null };
675
+ /** Newest SIXEL encode: target size, compositing background, and settled sequence. */
676
+ #sixel?: SixelEncode;
668
677
 
669
678
  constructor(
670
679
  base64Data: string,
@@ -704,33 +713,38 @@ export class Image implements Component {
704
713
  }
705
714
 
706
715
  /**
707
- * SIXEL sequence for a target size. A new size starts the encode off the JS
716
+ * SIXEL sequence for a target size and background. A change starts the encode off the JS
708
717
  * thread and answers `undefined` until it settles; the settled encode
709
718
  * invalidates the cached lines and requests a repaint. A render bound for
710
719
  * scrollback cannot wait, so it encodes synchronously.
711
720
  */
712
- #sixelSequence(widthPx: number, heightPx: number): string | null | undefined {
721
+ #sixelSequence(widthPx: number, heightPx: number, backgroundRgb: number): string | null | undefined {
713
722
  const current = this.#sixel;
714
- const sameSize = current !== undefined && current.widthPx === widthPx && current.heightPx === heightPx;
715
- if (sameSize && current.sequence !== undefined) return current.sequence;
723
+ const sameInput =
724
+ current !== undefined &&
725
+ current.widthPx === widthPx &&
726
+ current.heightPx === heightPx &&
727
+ current.backgroundRgb === backgroundRgb;
728
+ if (sameInput && current.sequence !== undefined) return current.sequence;
716
729
  if (scrollbackRenderDepth > 0) {
717
730
  // Replacing the entry makes a still-pending async encode settle as stale.
718
- const sequence = encodeSixelNow(this.#base64Data, widthPx, heightPx);
719
- this.#sixel = { widthPx, heightPx, sequence };
731
+ const sequence = encodeSixelNow(this.#base64Data, widthPx, heightPx, backgroundRgb);
732
+ this.#sixel = { widthPx, heightPx, backgroundRgb, sequence };
720
733
  return sequence;
721
734
  }
722
- if (sameSize) return undefined;
723
- const request: { widthPx: number; heightPx: number; sequence?: string | null } = { widthPx, heightPx };
735
+ if (sameInput) return undefined;
736
+ const request: SixelEncode = { widthPx, heightPx, backgroundRgb };
724
737
  this.#sixel = request;
725
738
  const settle = (sequence: string | null): void => {
726
739
  request.sequence = sequence;
727
- if (this.#sixel !== request) return;
740
+ if (this.#sixel !== request || getTerminalBackgroundColor() !== backgroundRgb) return;
728
741
  this.invalidate();
729
742
  if (this.#options.requestRender) this.#options.requestRender();
730
743
  else this.#budget?.requestRender();
731
744
  };
732
- encodeSixelAsync(new Uint8Array(Buffer.from(this.#base64Data, "base64")), widthPx, heightPx).then(settle, () =>
733
- settle(null),
745
+ encodeSixelAsync(new Uint8Array(Buffer.from(this.#base64Data, "base64")), widthPx, heightPx, backgroundRgb).then(
746
+ settle,
747
+ () => settle(null),
734
748
  );
735
749
  return undefined;
736
750
  }
@@ -768,6 +782,7 @@ export class Image implements Component {
768
782
  const hasProtocol = imageProtocol != null;
769
783
  const cellDimensions = getCellDimensions();
770
784
  const kittyUnicodePlaceholders = getKittyGraphics().unicodePlaceholders;
785
+ const sixelBackground = imageProtocol === ImageProtocol.Sixel ? getTerminalBackgroundColor() : undefined;
771
786
  // observe() must run on every pass — even a cache hit — so the image keeps
772
787
  // its display-order slot in the budget. Only graphics-capable frames count
773
788
  // toward (and are demoted by) the budget; without a protocol every image is
@@ -789,6 +804,7 @@ export class Image implements Component {
789
804
  this.#cachedImageProtocol === imageProtocol &&
790
805
  this.#cachedCellWidthPx === cellDimensions.widthPx &&
791
806
  this.#cachedCellHeightPx === cellDimensions.heightPx &&
807
+ this.#cachedSixelBackground === sixelBackground &&
792
808
  this.#cachedKittyUnicodePlaceholders === kittyUnicodePlaceholders &&
793
809
  // The text fallback embeds a link only under an enabled hyperlink policy.
794
810
  this.#cachedLinked === linked &&
@@ -813,7 +829,7 @@ export class Image implements Component {
813
829
  maxHeightCells: this.#options.maxHeightCells,
814
830
  imageId: this.#imageId,
815
831
  includeTransmit: needsTransmit,
816
- sixel: (widthPx, heightPx) => this.#sixelSequence(widthPx, heightPx),
832
+ sixel: (widthPx, heightPx) => this.#sixelSequence(widthPx, heightPx, sixelBackground!),
817
833
  });
818
834
 
819
835
  if (result?.transmit && this.#imageId != null && this.#budget !== undefined) {
@@ -861,6 +877,7 @@ export class Image implements Component {
861
877
  this.#cachedImageProtocol = imageProtocol;
862
878
  this.#cachedCellWidthPx = cellDimensions.widthPx;
863
879
  this.#cachedCellHeightPx = cellDimensions.heightPx;
880
+ this.#cachedSixelBackground = sixelBackground;
864
881
  this.#cachedKittyUnicodePlaceholders = kittyUnicodePlaceholders;
865
882
  this.#cachedLinked = linked;
866
883
 
@@ -17,7 +17,7 @@ export function wrapTreeLine(line: string, width: number, theme: Theme): string[
17
17
  const index = line.indexOf(marker);
18
18
  if (index < 0) continue;
19
19
  const before = line.slice(0, index);
20
- if (!/^[ │]*$/.test(Bun.stripANSI(before))) continue;
20
+ if (!/^ *$/.test(Bun.stripANSI(before).replaceAll(theme.tree.vertical, ""))) continue;
21
21
  const end = index + marker.length + 1;
22
22
  if (line[end - 1] !== " ") continue;
23
23
  const connectorWidth = visibleWidth(connector);
@@ -29,3 +29,30 @@ export function wrapTreeLine(line: string, width: number, theme: Theme): string[
29
29
  }
30
30
  return undefined;
31
31
  }
32
+
33
+ const SGR = /\x1b\[[0-9;]*m/y;
34
+
35
+ /** End of the leading run of spaces, `vertical` guides and SGR escapes that a tree detail row hangs beneath. */
36
+ function guideRunEnd(line: string, vertical: string): number {
37
+ let index = 0;
38
+ while (index < line.length) {
39
+ if (line[index] === " ") {
40
+ index++;
41
+ } else if (vertical && line.startsWith(vertical, index)) {
42
+ index += vertical.length;
43
+ } else {
44
+ SGR.lastIndex = index;
45
+ if (!SGR.test(line)) break;
46
+ index = SGR.lastIndex;
47
+ }
48
+ }
49
+ return index;
50
+ }
51
+
52
+ /** Hang a tree row beneath its connector, or a connector-less detail row beneath its guide rails. */
53
+ export function wrapGuidedLine(line: string, width: number, theme: Theme): string[] {
54
+ const tree = wrapTreeLine(line, width, theme);
55
+ if (tree) return tree;
56
+ const prefix = line.slice(0, guideRunEnd(line, theme.tree.vertical));
57
+ return wrapPrefixedLine(prefix, prefix, line.slice(prefix.length), width);
58
+ }
@@ -233,15 +233,29 @@ export function getPreviewLines(text: string, maxLines: number, maxLineLen: numb
233
233
  return lines.slice(0, maxLines).map(l => truncateToWidth(l.trim(), maxLineLen, ellipsis));
234
234
  }
235
235
 
236
+ /** Collapse a possibly multi-line string into one line; whitespace runs (tabs included) become one space. */
237
+ export function flattenLine(text: string): string {
238
+ return text.replace(/\s+/g, " ").trim();
239
+ }
240
+
236
241
  /**
237
242
  * Collapse a possibly multi-line string into a single line, then truncate it to
238
243
  * `maxWidth` display cells. {@link truncateToWidth} alone caps width but
239
244
  * newlines are zero-width, so multi-line content (markdown briefs, tool args,
240
245
  * provider errors) would otherwise spill a single status row across several
241
- * visual lines. Whitespace runs collapse to one space, so tabs are handled too.
246
+ * visual lines.
242
247
  */
243
248
  export function previewLine(text: string, maxWidth: number, ellipsis?: Ellipsis): string {
244
- return truncateToWidth(text.replace(/\s+/g, " ").trim(), maxWidth, ellipsis);
249
+ return truncateToWidth(flattenLine(text), maxWidth, ellipsis);
250
+ }
251
+
252
+ /**
253
+ * Clip a result row to `maxWidth` only while collapsed. Expanded rows stay whole
254
+ * so the frame wraps them: the expand key ({@link formatExpandHint}) must reveal
255
+ * text, not re-clip it.
256
+ */
257
+ export function clipCollapsed(text: string, maxWidth: number, expanded: boolean | undefined): string {
258
+ return expanded ? text : truncateToWidth(text, maxWidth);
245
259
  }
246
260
 
247
261
  // =============================================================================
@@ -1977,6 +1977,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1977
1977
  * cadence while a late successful fetch can still refresh the cached segment.
1978
1978
  */
1979
1979
  refreshUsageInBackground(): void {
1980
+ if (this.#disposed) return;
1980
1981
  const now = Date.now();
1981
1982
  const session = this.session;
1982
1983
  const usageContextKey = this.#getUsageContextKey(session);
@@ -216,7 +216,7 @@ const piSegment: StatusLineSegment = {
216
216
  // Brand fg fades between dim gray (idle) and the accent (working) across
217
217
  // turn edges; the component samples the tween into `brandFgAnsi`.
218
218
  const fgAnsi = ctx.brandFgAnsi ?? theme.getFgAnsi("dim");
219
- const profile = getActiveProfile();
219
+ const profile = ctx.options.pi?.showProfile === false ? undefined : getActiveProfile();
220
220
  // While a turn runs the brand icon becomes a braille spinner plus a
221
221
  // whole-unit turn timer (port of rust omp's status-band active brand).
222
222
  // No trailing pad: the group renderer owns inter-segment spacing, so a
@@ -230,14 +230,15 @@ const piSegment: StatusLineSegment = {
230
230
  describe(ctx) {
231
231
  if (ctx.focusedAgentId) return segView([span(ctx.focusedAgentId, "warning")], "ghost", "warning");
232
232
  // The dock's working row owns activity natively: the brand stays still.
233
- const profile = getActiveProfile();
233
+ const profile = ctx.options.pi?.showProfile === false ? undefined : getActiveProfile();
234
234
  return segView(profile ? [span(profile, "muted")] : [], "omp", "muted");
235
235
  },
236
236
  };
237
237
  /**
238
238
  * Brand glyph tagged with the active named profile (`π-work`) so sessions from
239
239
  * different profiles are distinguishable at a glance; the bare glyph for the
240
- * default profile. The tag stays put while the glyph slot animates.
240
+ * default profile or with `segmentOptions.pi.showProfile: false`. The tag
241
+ * stays put while the glyph slot animates.
241
242
  */
242
243
  function brandMark(glyph: string, profile: string | undefined): string {
243
244
  if (!profile) return glyph;
@@ -36,6 +36,7 @@ export interface CollabStatus {
36
36
  }
37
37
 
38
38
  export interface StatusLineSegmentOptions {
39
+ pi?: { showProfile?: boolean };
39
40
  model?: { showThinkingLevel?: boolean };
40
41
  path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean };
41
42
  git?: { showBranch?: boolean; showStaged?: boolean; showUnstaged?: boolean; showUntracked?: boolean };
@@ -12,16 +12,35 @@ import {
12
12
  } from "./kitty-graphics";
13
13
  import {
14
14
  isInsideTerminalMultiplexer,
15
+ isTerminalBackgroundQueryTrusted,
15
16
  routeTerminalMultiplexerNotification,
16
17
  terminalMultiplexerForTerm,
17
18
  terminalMultiplexerSessions,
18
19
  } from "./terminal-multiplexer";
19
20
  import { resolveTmuxClientTerminalNames, wrapTmuxPassthroughIfNeeded } from "./tmux";
21
+ import { detectTerminalBackground } from "./theme/theme";
20
22
  import type { HangulCompatibilityJamoWidth } from "./utils";
21
23
 
22
24
  export * from "./terminal-multiplexer";
23
25
  export { wrapTmuxPassthrough } from "./tmux";
24
26
 
27
+ /** Last valid OSC 11 background, packed as 0xRRGGBB. */
28
+ let terminalBackgroundColor: number | undefined;
29
+
30
+ /**
31
+ * Trusted terminal background when reported; otherwise black for detected dark
32
+ * appearance or white for detected light appearance, using theme auto-detection.
33
+ */
34
+ export function getTerminalBackgroundColor(): number {
35
+ if (isTerminalBackgroundQueryTrusted() && terminalBackgroundColor !== undefined) return terminalBackgroundColor;
36
+ return detectTerminalBackground() === "light" ? 0xffffff : 0x000000;
37
+ }
38
+
39
+ /** Store an OSC 11 background, or reset to appearance detection (also for tests). */
40
+ export function setTerminalBackgroundColor(color: number | undefined): void {
41
+ terminalBackgroundColor = color;
42
+ }
43
+
25
44
  export enum ImageProtocol {
26
45
  Kitty = "\x1b_G",
27
46
  Iterm2 = "\x1b]1337;File=",
@@ -1249,9 +1268,14 @@ export function getImageDimensions(base64Data: string, mimeType: string): ImageD
1249
1268
  * thread; `null` when the encode fails. For output that cannot wait for an
1250
1269
  * off-thread encode.
1251
1270
  */
1252
- export function encodeSixelNow(base64Data: string, widthPx: number, heightPx: number): string | null {
1271
+ export function encodeSixelNow(
1272
+ base64Data: string,
1273
+ widthPx: number,
1274
+ heightPx: number,
1275
+ backgroundRgb = getTerminalBackgroundColor(),
1276
+ ): string | null {
1253
1277
  try {
1254
- return encodeSixel(new Uint8Array(Buffer.from(base64Data, "base64")), widthPx, heightPx);
1278
+ return encodeSixel(new Uint8Array(Buffer.from(base64Data, "base64")), widthPx, heightPx, backgroundRgb);
1255
1279
  } catch {
1256
1280
  return null;
1257
1281
  }
@@ -67,6 +67,20 @@ export function terminalMultiplexerSessions(
67
67
  return TERMINAL_MULTIPLEXERS.filter(multiplexer => multiplexer.isInside(env));
68
68
  }
69
69
 
70
+ /**
71
+ * Whether OSC 11 background replies are trusted on this platform. Any active
72
+ * session with `backgroundQueryBrokenOn` matching the platform makes the entire
73
+ * path untrusted; TERM-only classification does not identify an active session.
74
+ */
75
+ export function isTerminalBackgroundQueryTrusted(
76
+ env: NodeJS.ProcessEnv = Bun.env,
77
+ platform: NodeJS.Platform = process.platform,
78
+ ): boolean {
79
+ return !TERMINAL_MULTIPLEXERS.some(
80
+ multiplexer => multiplexer.isInside(env) && multiplexer.backgroundQueryBrokenOn?.includes(platform),
81
+ );
82
+ }
83
+
70
84
  /**
71
85
  * The multiplexer TERM names, whether or not its session markers survived.
72
86
  * This is classification's TERM fallback; session markers are not consulted.
package/src/terminal.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  NotifyProtocol,
26
26
  setCellDimensions,
27
27
  setOsc99Supported,
28
+ setTerminalBackgroundColor,
28
29
  setTerminalGlyphProtocol,
29
30
  TERMINAL,
30
31
  } from "./terminal-capabilities";
@@ -1125,6 +1126,8 @@ export class ProcessTerminal implements Terminal {
1125
1126
  // Own stdout: out-of-band writers route through this terminal, and the
1126
1127
  // emergency restore finds it on crash.
1127
1128
  setActiveTerminal(this);
1129
+ // RGB belongs to this ownership session, including deferred-input prepaint.
1130
+ setTerminalBackgroundColor(undefined);
1128
1131
  terminalEverStarted = true;
1129
1132
  // Own the blocking write(2) on a pump thread (unix TTYs only). A stale
1130
1133
  // prebuilt natives module without the export falls back to direct writes.
@@ -2041,7 +2044,11 @@ export class ProcessTerminal implements Terminal {
2041
2044
  const max = 16 ** hex.length - 1;
2042
2045
  return max > 0 ? value / max : 0;
2043
2046
  };
2044
- const luminance = 0.299 * normalize(rHex) + 0.587 * normalize(gHex) + 0.114 * normalize(bHex);
2047
+ const r = normalize(rHex);
2048
+ const g = normalize(gHex);
2049
+ const b = normalize(bHex);
2050
+ setTerminalBackgroundColor((Math.round(r * 255) << 16) | (Math.round(g * 255) << 8) | Math.round(b * 255));
2051
+ const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
2045
2052
  const mode: TerminalAppearance = luminance < 0.5 ? "dark" : "light";
2046
2053
  const changed = mode !== this.#appearance;
2047
2054
  this.#appearance = mode;
@@ -2313,11 +2320,13 @@ export class ProcessTerminal implements Terminal {
2313
2320
  // Suppress observer/timer callbacks before any teardown can yield or throw.
2314
2321
  this.#active = false;
2315
2322
  this.#inputDeferred = false;
2316
- if (this.#headless) return;
2317
2323
  // Release stdout ownership (out-of-band writers and emergency cleanup)
2324
+ // before headless/teardown exits, without disturbing another owner's RGB.
2318
2325
  if (getActiveTerminal() === this) {
2319
2326
  setActiveTerminal(null);
2327
+ setTerminalBackgroundColor(undefined);
2320
2328
  }
2329
+ if (this.#headless) return;
2321
2330
 
2322
2331
  // Release terminal ownership of fd 2 first so external programs,
2323
2332
  // suspend, and shutdown see the real stderr even if a later teardown
@@ -5,7 +5,7 @@ import type { Terminal, TerminalAppearance } from "../terminal";
5
5
  import { colorLuma } from "@oh-my-pi/pi-utils/color";
6
6
  import { getCustomThemesDir } from "@oh-my-pi/pi-utils/dirs";
7
7
  import * as logger from "@oh-my-pi/pi-utils/logger";
8
- import { terminalMultiplexerSessions } from "../terminal-multiplexer";
8
+ import { isTerminalBackgroundQueryTrusted } from "../terminal-multiplexer";
9
9
  import { setActiveSymbolTheme } from "./active-symbols";
10
10
  import { ansi256ToHex, resolveThemeColors, resolveVarRefs } from "./color";
11
11
  import {
@@ -50,18 +50,28 @@ var terminalReportedAppearance: "dark" | "light" | undefined;
50
50
  /** Appearance reported by the macOS fallback observer, or undefined if not yet available. */
51
51
  var macOSReportedAppearance: "dark" | "light" | undefined;
52
52
 
53
- function shouldUseMacOSAppearanceFallback(): boolean {
54
- // Some multiplexers break OSC 11 passthrough on some platforms (Zellij on
55
- // macOS), so terminal-derived appearance cannot be trusted there. Fall back
56
- // to host macOS appearance without letting it override valid terminal
57
- // signals elsewhere.
58
- const platform = process.platform;
59
- return terminalMultiplexerSessions().some(multiplexer => multiplexer.backgroundQueryBrokenOn?.includes(platform));
53
+ const backgroundFallbackListeners = new Set<() => void>();
54
+
55
+ /**
56
+ * Subscribe to native fallback appearance changes independently of theme changes.
57
+ * Consumers must re-check the effective background: COLORFGBG can override it.
58
+ */
59
+ export function onTerminalBackgroundFallbackChange(callback: () => void): () => void {
60
+ backgroundFallbackListeners.add(callback);
61
+ return () => {
62
+ backgroundFallbackListeners.delete(callback);
63
+ };
60
64
  }
61
65
 
62
- function detectTerminalBackground(): "dark" | "light" {
66
+ /**
67
+ * Detect appearance from trusted OSC 11 reports, then COLORFGBG, then native
68
+ * macOS appearance only on known-broken multiplexer background-query paths.
69
+ * Defaults to dark when none of those signals is available.
70
+ */
71
+ export function detectTerminalBackground(): "dark" | "light" {
72
+ const backgroundQueryTrusted = isTerminalBackgroundQueryTrusted();
63
73
  // Tier 1: terminal-reported appearance from OSC 11 luminance.
64
- if (!shouldUseMacOSAppearanceFallback() && terminalReportedAppearance) {
74
+ if (backgroundQueryTrusted && terminalReportedAppearance) {
65
75
  return terminalReportedAppearance;
66
76
  }
67
77
 
@@ -76,7 +86,7 @@ function detectTerminalBackground(): "dark" | "light" {
76
86
  }
77
87
 
78
88
  // Tier 3: host macOS appearance for known-broken terminal paths only.
79
- if (shouldUseMacOSAppearanceFallback()) {
89
+ if (!backgroundQueryTrusted) {
80
90
  const macAppearance = macOSReportedAppearance ?? detectMacOSAppearance();
81
91
  if (macAppearance) return macAppearance;
82
92
  }
@@ -214,14 +224,14 @@ export async function initTheme(
214
224
  assignTheme(await loadTheme(name, getCurrentThemeOptions()));
215
225
  if (enableWatcher) {
216
226
  await startThemeWatcher();
217
- startSigwinchListener();
218
227
  }
219
228
  } catch (err) {
220
229
  logger.debug("Theme loading failed, falling back to dark theme", { error: String(err) });
221
230
  currentThemeName = "dark";
222
231
  assignTheme(await loadTheme("dark", getCurrentThemeOptions()));
223
- // Don't start watcher for fallback theme
232
+ // Skip file watching for the fallback theme, not appearance tracking.
224
233
  }
234
+ if (enableWatcher) startSigwinchListener();
225
235
  }
226
236
 
227
237
  export async function setTheme(
@@ -239,6 +249,14 @@ export async function setTheme(
239
249
  assignTheme(loadedTheme);
240
250
  if (enableWatcher) {
241
251
  await startThemeWatcher();
252
+ // startThemeWatcher() calls stopThemeWatcher(), which tears down
253
+ // the SIGWINCH handler and the macOS appearance observer that
254
+ // feeds the SIXEL background fallback. Restart it like initTheme()
255
+ // does so a runtime theme change does not silence fallback
256
+ // background repaints. Without this, `setTheme(name, true)` after
257
+ // startup would leave the SIXEL repaint path dead until the next
258
+ // process restart.
259
+ startSigwinchListener();
242
260
  }
243
261
  notifyThemeChange();
244
262
  return { success: true };
@@ -667,12 +685,26 @@ var macObserver: { stop(): void } | undefined;
667
685
 
668
686
  function startMacAppearanceObserver(): void {
669
687
  stopMacAppearanceObserver();
670
- if (!shouldUseMacOSAppearanceFallback()) return;
688
+ if (isTerminalBackgroundQueryTrusted()) return;
671
689
  try {
672
690
  macOSReportedAppearance = detectMacOSAppearance() ?? undefined;
673
691
  macObserver = MacAppearanceObserver.start((err, appearance) => {
674
692
  if (!err && (appearance === "dark" || appearance === "light")) {
693
+ const changed = appearance !== macOSReportedAppearance;
675
694
  macOSReportedAppearance = appearance;
695
+ if (changed) {
696
+ // oxlint-disable-next-line unicorn/no-useless-spread -- listeners may unsubscribe while reporting
697
+ for (const listener of [...backgroundFallbackListeners]) {
698
+ try {
699
+ listener();
700
+ } catch (err) {
701
+ // Debug-level: a listener failure must not break sibling
702
+ // listeners or the appearance observer. Visible only in
703
+ // local debug logs, never in normal runs.
704
+ logger.debug("macOS appearance fallback listener failed", { err });
705
+ }
706
+ }
707
+ }
676
708
  reevaluateAutoThemeForAppearance("macOS fallback");
677
709
  }
678
710
  });
package/src/tmux.ts CHANGED
@@ -10,7 +10,13 @@ export function wrapTmuxPassthroughIfNeeded(payload: string, env: NodeJS.Process
10
10
  }
11
11
 
12
12
  const CLIENT_TERMTYPE_NAME = /^([A-Za-z][A-Za-z0-9._+-]*)(?=\s|\(|$)/u;
13
- const CLIENT_TERMINAL_TIMEOUT_MS = 500;
13
+ // One `tmux display-message` round trip over the server socket, which answers in
14
+ // single-digit milliseconds when healthy. 500ms kept losing the race on a loaded
15
+ // machine (fork/exec of the tmux client alone can exceed it on a saturated box),
16
+ // and a lost race is indistinguishable from "not tmux": the process then falls
17
+ // back to the true-color profile for the whole session. 2s bounds the startup
18
+ // stall only when tmux is genuinely wedged.
19
+ const CLIENT_TERMINAL_TIMEOUT_MS = 2_000;
14
20
  /** Client `TERM` values that name their emulator; tmux records this on every version. */
15
21
  const CLIENT_TERMNAME_PROGRAMS: ReadonlyArray<readonly [RegExp, string]> = [
16
22
  [/^xterm-kitty$/u, "kitty"],
@@ -1,3 +1,4 @@
1
+ import { isRecord, sanitizeText } from "@oh-my-pi/pi-utils";
1
2
  import type { Component } from "../tui";
2
3
  import { Text } from "../components/text";
3
4
  import { replaceTabs } from "../utils";
@@ -14,7 +15,13 @@ import {
14
15
  } from "../render/render-utils";
15
16
  import { classifyGroupedLines, groupLineIndicesByBlank } from "./grouped-file-output";
16
17
  import type { OutputMeta } from "./output-meta";
17
- import type { NativeToolHead, NativeToolView, RenderResultOptions, ToolRenderer } from "./renderer";
18
+ import type {
19
+ NativeToolHead,
20
+ NativeToolView,
21
+ RenderResultContextOptions,
22
+ RenderResultOptions,
23
+ ToolRenderer,
24
+ } from "./renderer";
18
25
  import { code, compact, diff, node, span, text } from "../native/describe";
19
26
  import type { NativeChild, NativeNode } from "../native/node";
20
27
  import { diffStatsMeta, displayPath, errorText, fileDiffSection, noteText, resultText, statsText } from "./native-view";
@@ -190,7 +197,7 @@ export const astEditToolRenderer = {
190
197
 
191
198
  renderResult(
192
199
  result: { content: Array<{ type: string; text?: string }>; details?: AstEditToolDetails; isError?: boolean },
193
- options: RenderResultOptions,
200
+ options: RenderResultContextOptions,
194
201
  uiTheme: Theme,
195
202
  args?: AstEditRenderArgs,
196
203
  ): Component {
@@ -245,6 +252,19 @@ export const astEditToolRenderer = {
245
252
  // Resolve hyperlinks over the whole output so nested directory headers
246
253
  // reconstruct across the blank-line groups the tree list collapses by.
247
254
  const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath);
255
+ // The display lines are sanitized; reconstruct link targets from the original
256
+ // grouped headers only when they still align with those display lines.
257
+ const rawDetails = options.renderContext?.rawDetails;
258
+ const rawContent =
259
+ isRecord(rawDetails) && typeof rawDetails.displayContent === "string" ? rawDetails.displayContent : undefined;
260
+ const rawLines = rawContent?.split("\n");
261
+ const rawCwd = isRecord(rawDetails) && typeof rawDetails.cwd === "string" ? rawDetails.cwd : undefined;
262
+ const rawSearchPath =
263
+ isRecord(rawDetails) && typeof rawDetails.searchPath === "string" ? rawDetails.searchPath : undefined;
264
+ const rawContexts =
265
+ rawContent !== undefined && rawLines?.length === allLines.length && sanitizeText(rawContent) === textContent
266
+ ? classifyGroupedLines(rawLines, rawCwd ?? rawSearchPath, rawSearchPath)
267
+ : undefined;
248
268
  // Style lazily: collapsed bodies show only the first groups.
249
269
  const styledLines: (string | undefined)[] = Array.from({ length: allLines.length }, () => undefined);
250
270
  const styleLine = (index: number): string => {
@@ -255,12 +275,16 @@ export const astEditToolRenderer = {
255
275
  // second vertical bar inside the frame border.
256
276
  const display = replaceTabs(allLines[index]!.replace("│", " "));
257
277
  let styled: string;
278
+ const linkPath =
279
+ ctx.headerPath && rawContexts?.[index]?.kind === ctx.kind && rawContexts[index]?.depth === ctx.depth
280
+ ? (rawContexts[index]?.headerPath ?? ctx.headerPath)
281
+ : ctx.headerPath;
258
282
  if (ctx.kind === "dir") {
259
283
  const accent = uiTheme.fg("accent", display);
260
- styled = ctx.headerPath ? fileHyperlink(ctx.headerPath, accent) : accent;
284
+ styled = linkPath ? fileHyperlink(linkPath, accent) : accent;
261
285
  } else if (ctx.kind === "file") {
262
286
  const tinted = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", display);
263
- styled = ctx.headerPath ? fileHyperlink(ctx.headerPath, tinted) : tinted;
287
+ styled = linkPath ? fileHyperlink(linkPath, tinted) : tinted;
264
288
  } else if (display.startsWith("+")) {
265
289
  styled = uiTheme.fg("toolDiffAdded", display);
266
290
  } else if (display.startsWith("-")) {