@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.
- package/CHANGELOG.md +22 -83
- package/README.md +36 -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/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/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/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 +4 -3
- package/src/status-line/types.ts +1 -0
- package/src/terminal-capabilities.ts +26 -2
- package/src/terminal-multiplexer.ts +14 -0
- package/src/terminal.ts +11 -2
- 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 +9 -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
|
@@ -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.#
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
1902
|
+
? linkedImageText(placeholder, { data: img.data, mimeType }, readMediaPath(this.#rawDetails))
|
|
1886
1903
|
: placeholder;
|
|
1887
1904
|
})
|
|
1888
1905
|
.join("\n");
|
package/src/components/image.ts
CHANGED
|
@@ -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:
|
|
667
|
-
#sixel?:
|
|
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
|
|
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
|
|
715
|
-
|
|
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 (
|
|
723
|
-
const request:
|
|
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(
|
|
733
|
-
settle
|
|
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 (!/^
|
|
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.
|
|
246
|
+
* visual lines.
|
|
242
247
|
*/
|
|
243
248
|
export function previewLine(text: string, maxWidth: number, ellipsis?: Ellipsis): string {
|
|
244
|
-
return truncateToWidth(text
|
|
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
|
|
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;
|
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
|
}
|
|
@@ -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/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("-")) {
|