@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.
- package/CHANGELOG.md +37 -100
- package/README.md +36 -0
- package/dist/types/components/markdown.d.ts +4 -3
- package/dist/types/overlays/approval-dialog.d.ts +25 -15
- package/dist/types/overlays/model-picker.d.ts +2 -0
- package/dist/types/overlays/session-selector.d.ts +2 -0
- package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
- package/dist/types/render/hanging-wrap.d.ts +2 -0
- package/dist/types/render/render-utils.d.ts +9 -1
- package/dist/types/status-line/types.d.ts +3 -0
- package/dist/types/terminal-capabilities.d.ts +8 -1
- package/dist/types/terminal-multiplexer.d.ts +6 -0
- package/dist/types/theme/symbols.d.ts +1 -1
- package/dist/types/theme/theme.d.ts +11 -0
- package/dist/types/tools/ast-edit.d.ts +2 -2
- package/dist/types/tools/ast-grep.d.ts +2 -2
- package/dist/types/tools/edit.d.ts +2 -0
- package/dist/types/tools/find.d.ts +14 -4
- package/dist/types/tools/glob.d.ts +3 -3
- package/dist/types/tools/grep.d.ts +2 -2
- package/dist/types/tools/read.d.ts +13 -5
- package/dist/types/tools/task.d.ts +8 -0
- package/dist/types/tools/write.d.ts +2 -0
- package/package.json +9 -9
- package/src/chat/assistant-message.ts +3 -1
- package/src/chat/read-tool-group.ts +17 -9
- package/src/chat/tool-execution.ts +23 -6
- package/src/components/image.ts +31 -14
- package/src/components/markdown.ts +74 -5
- package/src/overlays/approval-dialog.ts +104 -36
- package/src/overlays/model-picker.ts +58 -21
- package/src/overlays/session-selector.ts +14 -9
- package/src/prompt/internal-url-autocomplete.ts +1 -0
- package/src/render/hanging-wrap.ts +28 -1
- package/src/render/render-utils.ts +16 -2
- package/src/status-line/component.ts +1 -0
- package/src/status-line/segments.ts +16 -5
- package/src/status-line/types.ts +1 -0
- package/src/terminal-capabilities.ts +35 -21
- package/src/terminal-multiplexer.ts +14 -0
- package/src/terminal.ts +11 -2
- package/src/theme/symbols.ts +1 -0
- package/src/theme/theme-class.ts +1 -0
- package/src/theme/theme.ts +46 -14
- package/src/tmux.ts +7 -1
- package/src/tools/ast-edit.ts +28 -4
- package/src/tools/ast-grep.ts +24 -4
- package/src/tools/debug.ts +2 -1
- package/src/tools/default-renderer.ts +3 -2
- package/src/tools/edit.ts +77 -16
- package/src/tools/find.ts +65 -15
- package/src/tools/glob.ts +51 -17
- package/src/tools/grep.ts +94 -11
- package/src/tools/lsp.ts +23 -18
- package/src/tools/mcp.ts +2 -2
- package/src/tools/read.ts +52 -22
- package/src/tools/task.ts +17 -7
- package/src/tools/web-search.ts +2 -1
- package/src/tools/write.ts +17 -5
- package/src/tools/yield.ts +2 -2
- 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
|
-
|
|
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");
|
package/src/status-line/types.ts
CHANGED
|
@@ -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(
|
|
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
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
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
|
|
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
|
package/src/theme/symbols.ts
CHANGED
package/src/theme/theme-class.ts
CHANGED
|
@@ -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"],
|
package/src/theme/theme.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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"],
|
package/src/tools/ast-edit.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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 =
|
|
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 =
|
|
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("-")) {
|
package/src/tools/ast-grep.ts
CHANGED
|
@@ -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:
|
|
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 =
|
|
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 =
|
|
160
|
+
styled = target ? fileHyperlink(target, tinted) : tinted;
|
|
141
161
|
} else {
|
|
142
162
|
styled = uiTheme.fg(line.startsWith(" meta:") ? "dim" : "toolOutput", line);
|
|
143
163
|
}
|
package/src/tools/debug.ts
CHANGED
|
@@ -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 =>
|
|
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 {
|
|
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 =>
|
|
136
|
+
styleLine: line =>
|
|
137
|
+
clipCollapsed(styleToolOutputLine(line, uiTheme), contentWidth, options.expanded),
|
|
137
138
|
showExpandHintWhenUncapped: true,
|
|
138
139
|
},
|
|
139
140
|
uiTheme,
|