@oh-my-pi/pi-tui 18.11.2 → 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 (61) hide show
  1. package/CHANGELOG.md +37 -100
  2. package/README.md +36 -0
  3. package/dist/types/components/markdown.d.ts +4 -3
  4. package/dist/types/overlays/approval-dialog.d.ts +25 -15
  5. package/dist/types/overlays/model-picker.d.ts +2 -0
  6. package/dist/types/overlays/session-selector.d.ts +2 -0
  7. package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
  8. package/dist/types/render/hanging-wrap.d.ts +2 -0
  9. package/dist/types/render/render-utils.d.ts +9 -1
  10. package/dist/types/status-line/types.d.ts +3 -0
  11. package/dist/types/terminal-capabilities.d.ts +8 -1
  12. package/dist/types/terminal-multiplexer.d.ts +6 -0
  13. package/dist/types/theme/symbols.d.ts +1 -1
  14. package/dist/types/theme/theme.d.ts +11 -0
  15. package/dist/types/tools/ast-edit.d.ts +2 -2
  16. package/dist/types/tools/ast-grep.d.ts +2 -2
  17. package/dist/types/tools/edit.d.ts +2 -0
  18. package/dist/types/tools/find.d.ts +14 -4
  19. package/dist/types/tools/glob.d.ts +3 -3
  20. package/dist/types/tools/grep.d.ts +2 -2
  21. package/dist/types/tools/read.d.ts +13 -5
  22. package/dist/types/tools/task.d.ts +8 -0
  23. package/dist/types/tools/write.d.ts +2 -0
  24. package/package.json +9 -9
  25. package/src/chat/assistant-message.ts +3 -1
  26. package/src/chat/read-tool-group.ts +17 -9
  27. package/src/chat/tool-execution.ts +23 -6
  28. package/src/components/image.ts +31 -14
  29. package/src/components/markdown.ts +74 -5
  30. package/src/overlays/approval-dialog.ts +104 -36
  31. package/src/overlays/model-picker.ts +58 -21
  32. package/src/overlays/session-selector.ts +14 -9
  33. package/src/prompt/internal-url-autocomplete.ts +1 -0
  34. package/src/render/hanging-wrap.ts +28 -1
  35. package/src/render/render-utils.ts +16 -2
  36. package/src/status-line/component.ts +1 -0
  37. package/src/status-line/segments.ts +16 -5
  38. package/src/status-line/types.ts +1 -0
  39. package/src/terminal-capabilities.ts +35 -21
  40. package/src/terminal-multiplexer.ts +14 -0
  41. package/src/terminal.ts +11 -2
  42. package/src/theme/symbols.ts +1 -0
  43. package/src/theme/theme-class.ts +1 -0
  44. package/src/theme/theme.ts +46 -14
  45. package/src/tmux.ts +7 -1
  46. package/src/tools/ast-edit.ts +28 -4
  47. package/src/tools/ast-grep.ts +24 -4
  48. package/src/tools/debug.ts +2 -1
  49. package/src/tools/default-renderer.ts +3 -2
  50. package/src/tools/edit.ts +77 -16
  51. package/src/tools/find.ts +65 -15
  52. package/src/tools/glob.ts +51 -17
  53. package/src/tools/grep.ts +94 -11
  54. package/src/tools/lsp.ts +23 -18
  55. package/src/tools/mcp.ts +2 -2
  56. package/src/tools/read.ts +52 -22
  57. package/src/tools/task.ts +17 -7
  58. package/src/tools/web-search.ts +2 -1
  59. package/src/tools/write.ts +17 -5
  60. package/src/tools/yield.ts +2 -2
  61. package/src/tui.ts +36 -0
@@ -6,6 +6,7 @@ import { SPINNER_ADVANCE_MS, TERMINAL } from "../index";
6
6
  import {
7
7
  formatDuration,
8
8
  formatNumber,
9
+ getActiveProfile,
9
10
  getProjectDir,
10
11
  normalizePathForComparison,
11
12
  relativePathWithinNormalizedRoot,
@@ -215,24 +216,34 @@ const piSegment: StatusLineSegment = {
215
216
  // Brand fg fades between dim gray (idle) and the accent (working) across
216
217
  // turn edges; the component samples the tween into `brandFgAnsi`.
217
218
  const fgAnsi = ctx.brandFgAnsi ?? theme.getFgAnsi("dim");
219
+ const profile = ctx.options.pi?.showProfile === false ? undefined : getActiveProfile();
218
220
  // While a turn runs the brand icon becomes a braille spinner plus a
219
221
  // whole-unit turn timer (port of rust omp's status-band active brand).
220
222
  // No trailing pad: the group renderer owns inter-segment spacing, so a
221
223
  // trailing space here would double the gap at the first separator (#11103).
222
224
  const content =
223
225
  ctx.turnElapsedMs != null
224
- ? `${brandSpinnerFrame(ctx.now?.getTime())} ${brandTimer(ctx.turnElapsedMs)}`
225
- : theme.icon.omp
226
- ? theme.icon.omp
227
- : "";
226
+ ? `${brandMark(brandSpinnerFrame(ctx.now?.getTime()), profile)} ${brandTimer(ctx.turnElapsedMs)}`
227
+ : brandMark(theme.icon.omp, profile);
228
228
  return { content: `${fgAnsi}${content}\x1b[39m`, visible: true };
229
229
  },
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
- return segView([], "omp", "muted");
233
+ const profile = ctx.options.pi?.showProfile === false ? undefined : getActiveProfile();
234
+ return segView(profile ? [span(profile, "muted")] : [], "omp", "muted");
234
235
  },
235
236
  };
237
+ /**
238
+ * Brand glyph tagged with the active named profile (`π-work`) so sessions from
239
+ * different profiles are distinguishable at a glance; the bare glyph for the
240
+ * default profile or with `segmentOptions.pi.showProfile: false`. The tag
241
+ * stays put while the glyph slot animates.
242
+ */
243
+ function brandMark(glyph: string, profile: string | undefined): string {
244
+ if (!profile) return glyph;
245
+ return glyph ? `${glyph}-${profile}` : profile;
246
+ }
236
247
  /** Current braille-spinner glyph on the shared clock, at the Loader's 80ms cadence. */
237
248
  function brandSpinnerFrame(nowMs = Date.now()): string {
238
249
  const frames = theme.getSpinnerFrames("activity");
@@ -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
  }
@@ -1310,25 +1334,15 @@ export function renderImage(
1310
1334
  }
1311
1335
 
1312
1336
  if (TERMINAL.imageProtocol === ImageProtocol.Sixel) {
1313
- // SIXEL encodes in 6-pixel vertical bands. A height that is not a
1314
- // multiple of 6 is padded with transparent rows, but the terminal
1315
- // still allocates cell rows for the padded height. When the padded
1316
- // height crosses a cell boundary the terminal uses one more row
1317
- // than fit.rows, so the next line of content overwrites the bottom
1318
- // of the image — a visible slice stripped from the image. Round the
1319
- // encode height DOWN to the largest multiple of 6 that fits within
1320
- // the requested row budget, so the band boundary aligns without
1321
- // padding and the reserved row count never exceeds fit.rows. Scale
1322
- // the width by the same ratio so resize_exact preserves the aspect
1323
- // ratio instead of squashing the image vertically.
1324
- const rawHeightPx = Math.max(1, fit.rows * cellDims.heightPx);
1325
- const targetHeightPx = Math.max(6, Math.floor(rawHeightPx / 6) * 6);
1326
- const heightScale = targetHeightPx / rawHeightPx;
1327
- const targetWidthPx = Math.max(1, Math.round(fit.columns * cellDims.widthPx * heightScale));
1328
- const rows = Math.max(1, Math.ceil(targetHeightPx / cellDims.heightPx));
1329
- const sequence = options.sixel
1330
- ? options.sixel(targetWidthPx, targetHeightPx)
1331
- : encodeSixelNow(base64Data, targetWidthPx, targetHeightPx);
1337
+ // Encode the cell box itself, so a raster drawn at the cell size
1338
+ // (SvgFigure) is placed 1:1 instead of resampled. The encoder drops a
1339
+ // partial last 6px band rather than letting the terminal pad it into
1340
+ // the next row; only a box under one band (sub-6px cells) still takes
1341
+ // the rows of a whole one.
1342
+ const heightPx = fit.rows * cellDims.heightPx;
1343
+ const widthPx = fit.columns * cellDims.widthPx;
1344
+ const rows = heightPx < 6 ? Math.ceil(6 / cellDims.heightPx) : fit.rows;
1345
+ const sequence = options.sixel ? options.sixel(widthPx, heightPx) : encodeSixelNow(base64Data, widthPx, heightPx);
1332
1346
  if (sequence === null) return null;
1333
1347
  // Undefined while the provider's encode is pending: the rows stay reserved.
1334
1348
  return { sequence, rows };
@@ -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
@@ -371,6 +371,7 @@ export type SlashCommandIconName =
371
371
  | "jobs"
372
372
  | "gauge"
373
373
  | "context"
374
+ | "ghost"
374
375
  | "agents"
375
376
  | "branch"
376
377
  | "tree"
@@ -729,6 +729,7 @@ export class Theme {
729
729
  jobs: this.#symbols["icon.job"],
730
730
  gauge: this.#symbols["icon.throughput"],
731
731
  context: this.#symbols["icon.context"],
732
+ ghost: this.#symbols["icon.ghost"],
732
733
  agents: this.#symbols["icon.agents"],
733
734
  branch: this.#symbols["icon.branch"],
734
735
  tree: this.#symbols["icon.worktree"],
@@ -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("-")) {
@@ -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 type { Theme } from "../theme/theme";
@@ -18,7 +19,7 @@ import { code, compact, node, span } from "../native/describe";
18
19
  import type { NativeChild } from "../native/node";
19
20
  import { errorText, noteText, resultText, toolHead } from "./native-view";
20
21
  import type { OutputMeta } from "./output-meta";
21
- import type { NativeToolView, RenderResultOptions, ToolRenderer } from "./renderer";
22
+ import type { NativeToolView, RenderResultContextOptions, RenderResultOptions, ToolRenderer } from "./renderer";
22
23
 
23
24
  /** Display metadata returned by ast-grep. */
24
25
  export interface AstGrepToolDetails {
@@ -70,7 +71,7 @@ export const astGrepToolRenderer = {
70
71
 
71
72
  renderResult(
72
73
  result: { content: Array<{ type: string; text?: string }>; details?: AstGrepToolDetails; isError?: boolean },
73
- options: RenderResultOptions,
74
+ options: RenderResultContextOptions,
74
75
  uiTheme: Theme,
75
76
  args?: AstGrepRenderArgs,
76
77
  ): Component {
@@ -123,6 +124,20 @@ export const astGrepToolRenderer = {
123
124
  // Resolve hyperlinks over the whole output so nested directory headers
124
125
  // reconstruct across the blank-line groups the tree list collapses by.
125
126
  const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath);
127
+ // The displayed lines are sanitized, but OSC 8 destinations must retain the
128
+ // original path bytes. Only substitute targets for headers already linked
129
+ // by the sanitized output; never let raw text create additional links.
130
+ const rawDetails = options.renderContext?.rawDetails;
131
+ const rawContent =
132
+ isRecord(rawDetails) && typeof rawDetails.displayContent === "string" ? rawDetails.displayContent : undefined;
133
+ const rawLines = rawContent?.split("\n");
134
+ const rawCwd = isRecord(rawDetails) && typeof rawDetails.cwd === "string" ? rawDetails.cwd : undefined;
135
+ const rawSearchPath =
136
+ isRecord(rawDetails) && typeof rawDetails.searchPath === "string" ? rawDetails.searchPath : undefined;
137
+ const rawContexts =
138
+ rawContent !== undefined && rawLines?.length === allLines.length && sanitizeText(rawContent) === textContent
139
+ ? classifyGroupedLines(rawLines, rawCwd ?? rawSearchPath, rawSearchPath)
140
+ : undefined;
126
141
  // Style lazily: the collapsed tree list only renders the first few groups,
127
142
  // so a large result never styles/hyperlinks rows nobody sees.
128
143
  const styledLines: (string | undefined)[] = Array.from({ length: allLines.length }, () => undefined);
@@ -131,13 +146,18 @@ export const astGrepToolRenderer = {
131
146
  if (cached !== undefined) return cached;
132
147
  const line = allLines[index]!;
133
148
  const ctx = contexts[index]!;
149
+ const rawContext = rawContexts?.[index];
150
+ const target =
151
+ ctx.headerPath && rawContext?.kind === ctx.kind && rawContext.depth === ctx.depth
152
+ ? (rawContext.headerPath ?? ctx.headerPath)
153
+ : ctx.headerPath;
134
154
  let styled: string;
135
155
  if (ctx.kind === "dir") {
136
156
  const accent = uiTheme.fg("accent", line);
137
- styled = ctx.headerPath ? fileHyperlink(ctx.headerPath, accent) : accent;
157
+ styled = target ? fileHyperlink(target, accent) : accent;
138
158
  } else if (ctx.kind === "file") {
139
159
  const tinted = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", line);
140
- styled = ctx.headerPath ? fileHyperlink(ctx.headerPath, tinted) : tinted;
160
+ styled = target ? fileHyperlink(target, tinted) : tinted;
141
161
  } else {
142
162
  styled = uiTheme.fg(line.startsWith(" meta:") ? "dim" : "toolOutput", line);
143
163
  }
@@ -10,6 +10,7 @@ import {
10
10
  replaceTabs,
11
11
  TRUNCATE_LENGTHS,
12
12
  truncateToWidth,
13
+ clipCollapsed,
13
14
  } from "../render/render-utils";
14
15
  import type { NativeToolView, RenderResultOptions, ToolRenderer, ToolRenderResult } from "./renderer";
15
16
  import { ansi, kv } from "../native/describe";
@@ -210,7 +211,7 @@ export const debugToolRenderer = {
210
211
  const previewLimit = options.expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES;
211
212
  const displayedLines = rawLines
212
213
  .slice(0, previewLimit)
213
- .map(line => truncateToWidth(line, TRUNCATE_LENGTHS.LINE));
214
+ .map(line => clipCollapsed(line, TRUNCATE_LENGTHS.LINE, options.expanded));
214
215
  const remaining = rawLines.length - displayedLines.length;
215
216
  if (remaining > 0) {
216
217
  displayedLines.push(
@@ -7,7 +7,7 @@ import type { Theme } from "../theme/theme";
7
7
  import { formatOutputPaneLines, styleToolOutputLine } from "../render/output-pane";
8
8
  import { renderStatusLine, type StatusLineOptions } from "../render/status-line";
9
9
  import { plainToolCard, type ToolCardPhase } from "../render/tool-card";
10
- import { truncateToWidth } from "../render/render-utils";
10
+ import { clipCollapsed } from "../render/render-utils";
11
11
  import {
12
12
  describeJsonTree,
13
13
  formatArgsInline,
@@ -133,7 +133,8 @@ function buildDefaultToolBody(input: DefaultToolRenderInput, uiTheme: Theme, con
133
133
  expanded: options.expanded,
134
134
  collapsedMaxLines: 4,
135
135
  expandedMaxLines: 12,
136
- styleLine: line => truncateToWidth(styleToolOutputLine(line, uiTheme), contentWidth),
136
+ styleLine: line =>
137
+ clipCollapsed(styleToolOutputLine(line, uiTheme), contentWidth, options.expanded),
137
138
  showExpandHintWhenUncapped: true,
138
139
  },
139
140
  uiTheme,