feat: enabled text-sizing and fixed OSC66 and DECCARA rendering behavior
- Added `textSizing` terminal capability and `tui.textSizing` setting for Kitty OSC66 scaling. - Updated OSC66 range and visible-width handling to preserve grapheme slice behavior. - Updated markdown rendering to apply heading text sizing only when enabled. - Fixed DECCARA trailing-background fill tracking and DECRPM status 3/4 recognition.
This commit is contained in:
+109
-22
@@ -366,6 +366,7 @@ fn is_sgr_u16(seq: &[u16]) -> bool {
|
||||
|
||||
struct Osc66Info<'a> {
|
||||
payload: &'a [u16],
|
||||
scale: usize,
|
||||
width: usize,
|
||||
}
|
||||
|
||||
@@ -380,7 +381,9 @@ fn parse_ascii_usize_u16(data: &[u16]) -> Option<usize> {
|
||||
if !(b'0' as u16..=b'9' as u16).contains(&u) {
|
||||
return None;
|
||||
}
|
||||
value = value.saturating_mul(10).saturating_add((u - b'0' as u16) as usize);
|
||||
value = value
|
||||
.saturating_mul(10)
|
||||
.saturating_add((u - b'0' as u16) as usize);
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
@@ -399,10 +402,7 @@ fn osc66_meta_payload_u16(seq: &[u16]) -> Option<(&[u16], &[u16])> {
|
||||
|
||||
let payload_end = if *seq.last()? == 0x07 {
|
||||
seq.len() - 1
|
||||
} else if seq.len() >= 8
|
||||
&& seq[seq.len() - 2] == ESC
|
||||
&& seq[seq.len() - 1] == b'\\' as u16
|
||||
{
|
||||
} else if seq.len() >= 8 && seq[seq.len() - 2] == ESC && seq[seq.len() - 1] == b'\\' as u16 {
|
||||
seq.len() - 2
|
||||
} else {
|
||||
return None;
|
||||
@@ -463,7 +463,7 @@ fn osc66_info_u16(seq: &[u16], tab_width: usize) -> Option<Osc66Info<'_>> {
|
||||
let (meta, payload) = osc66_meta_payload_u16(seq)?;
|
||||
let (scale, explicit_width) = parse_osc66_meta_u16(meta);
|
||||
let base_width = explicit_width.unwrap_or_else(|| visible_width_u16(payload, tab_width));
|
||||
Some(Osc66Info { payload, width: scale.saturating_mul(base_width) })
|
||||
Some(Osc66Info { payload, scale, width: scale.saturating_mul(base_width) })
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -471,6 +471,45 @@ fn osc66_visible_width_u16(seq: &[u16], tab_width: usize) -> Option<usize> {
|
||||
Some(osc66_info_u16(seq, tab_width)?.width)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
const fn div_ceil_usize(n: usize, d: usize) -> usize {
|
||||
if n == 0 { 0 } else { 1 + (n - 1) / d }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
const fn osc66_payload_range(
|
||||
visual_start: usize,
|
||||
visual_len: usize,
|
||||
scale: usize,
|
||||
strict: bool,
|
||||
) -> (usize, usize) {
|
||||
let visual_end = visual_start.saturating_add(visual_len);
|
||||
let payload_start = if strict {
|
||||
div_ceil_usize(visual_start, scale)
|
||||
} else {
|
||||
visual_start / scale
|
||||
};
|
||||
let payload_end = if strict {
|
||||
visual_end / scale
|
||||
} else {
|
||||
div_ceil_usize(visual_end, scale)
|
||||
};
|
||||
(payload_start, payload_end.saturating_sub(payload_start))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
const fn is_ascii_grapheme_extender_u16(u: u16) -> bool {
|
||||
matches!(
|
||||
u,
|
||||
0x0300..=0x036f
|
||||
| 0x1ab0..=0x1aff
|
||||
| 0x1dc0..=0x1dff
|
||||
| 0x200d
|
||||
| 0x20d0..=0x20ff
|
||||
| 0xfe00..=0xfe0f
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Grapheme / Width
|
||||
// ============================================================================
|
||||
@@ -666,12 +705,26 @@ where
|
||||
|
||||
while i < data.len() && current_col < end_col {
|
||||
let start = i;
|
||||
let mut is_ascii = true;
|
||||
while i < data.len() {
|
||||
if data[i] > 0x7f {
|
||||
is_ascii = false;
|
||||
let mut is_ascii = data[i] <= 0x7f;
|
||||
i += 1;
|
||||
if is_ascii {
|
||||
while i < data.len() && data[i] <= 0x7f {
|
||||
i += 1;
|
||||
}
|
||||
if i < data.len() && is_ascii_grapheme_extender_u16(data[i]) {
|
||||
let safe_end = i.saturating_sub(1);
|
||||
if safe_end > start {
|
||||
i = safe_end;
|
||||
} else {
|
||||
is_ascii = false;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !is_ascii {
|
||||
while i < data.len() && data[i] > 0x7f {
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let seg = &data[start..i];
|
||||
|
||||
@@ -1281,11 +1334,13 @@ fn slice_with_width_impl(
|
||||
let overlap_start = start_col.saturating_sub(span_start);
|
||||
let overlap_end = span_end.min(end_col) - span_start;
|
||||
let overlap_len = overlap_end.saturating_sub(overlap_start);
|
||||
let (payload_start, payload_len) =
|
||||
osc66_payload_range(overlap_start, overlap_len, osc66.scale, strict);
|
||||
let (payload_w, _) = append_visible_range_plain_u16(
|
||||
&mut out,
|
||||
osc66.payload,
|
||||
overlap_start,
|
||||
overlap_len,
|
||||
payload_start,
|
||||
payload_len,
|
||||
strict,
|
||||
tab_width,
|
||||
|out| flush_pending_ansi(out, line, &mut pending_ansi),
|
||||
@@ -1455,11 +1510,13 @@ fn extract_segments_impl(
|
||||
before_w = before_w.saturating_add(osc66.width);
|
||||
} else {
|
||||
let overlap_len = before_end - span_start;
|
||||
let (payload_start, payload_len) =
|
||||
osc66_payload_range(0, overlap_len, osc66.scale, true);
|
||||
let (payload_w, _) = append_visible_range_plain_u16(
|
||||
&mut before,
|
||||
osc66.payload,
|
||||
0,
|
||||
overlap_len,
|
||||
payload_start,
|
||||
payload_len,
|
||||
true,
|
||||
tab_width,
|
||||
|out| flush_pending_ansi(out, line, &mut pending_before_ansi),
|
||||
@@ -1481,11 +1538,13 @@ fn extract_segments_impl(
|
||||
after.extend_from_slice(seq);
|
||||
after_w = after_w.saturating_add(osc66.width);
|
||||
} else {
|
||||
let (payload_start, payload_len) =
|
||||
osc66_payload_range(overlap_start, overlap_len, osc66.scale, strict_after);
|
||||
let (payload_w, wrote_payload) = append_visible_range_plain_u16(
|
||||
&mut after,
|
||||
osc66.payload,
|
||||
overlap_start,
|
||||
overlap_len,
|
||||
payload_start,
|
||||
payload_len,
|
||||
strict_after,
|
||||
tab_width,
|
||||
|out| {
|
||||
@@ -1678,7 +1737,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_visible_width_jamo_correction_inside_combining_cluster() {
|
||||
let jamo_cells = if cfg!(target_os = "macos") { 1 } else { 2 };
|
||||
let filler_cells = if cfg!(target_os = "macos") { 1 } else { 0 };
|
||||
let filler_cells = usize::from(cfg!(target_os = "macos"));
|
||||
assert_eq!(visible_width_u16(&to_u16("\u{3141}\u{0301}"), DEFAULT_TAB_WIDTH), jamo_cells);
|
||||
assert_eq!(visible_width_u16(&to_u16("\u{3164}\u{0301}"), DEFAULT_TAB_WIDTH), filler_cells);
|
||||
}
|
||||
@@ -1711,10 +1770,7 @@ mod tests {
|
||||
fn test_visible_width_counts_osc66_lines() {
|
||||
assert_eq!(visible_width_u16(&to_u16("\x1b]66;s=2;Hi\x1b\\"), DEFAULT_TAB_WIDTH), 4);
|
||||
assert_eq!(visible_width_u16(&to_u16("\x1b]66;w=5;Hi\x1b\\"), DEFAULT_TAB_WIDTH), 5);
|
||||
assert_eq!(
|
||||
visible_width_u16(&to_u16("\x1b]66;s=3:w=4;X\x1b\\"), DEFAULT_TAB_WIDTH),
|
||||
12
|
||||
);
|
||||
assert_eq!(visible_width_u16(&to_u16("\x1b]66;s=3:w=4;X\x1b\\"), DEFAULT_TAB_WIDTH), 12);
|
||||
assert_eq!(visible_width_u16(&to_u16("\x1b]66;;abc\x1b\\"), DEFAULT_TAB_WIDTH), 3);
|
||||
assert_eq!(
|
||||
visible_width_u16(&to_u16("A\x1b]66;s=2;Hi\x1b\\Z"), DEFAULT_TAB_WIDTH),
|
||||
@@ -1726,6 +1782,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_osc66_scaled_partial_slices_map_to_payload_cells() {
|
||||
let data = to_u16("\x1b]66;s=2;Hi\x1b\\");
|
||||
|
||||
let (head, head_w) = slice_with_width_impl(&data, 0, 2, true, DEFAULT_TAB_WIDTH);
|
||||
assert_eq!(String::from_utf16_lossy(&head), "H");
|
||||
assert_eq!(head_w, 1);
|
||||
|
||||
let (tail, tail_w) = slice_with_width_impl(&data, 2, 2, true, DEFAULT_TAB_WIDTH);
|
||||
assert_eq!(String::from_utf16_lossy(&tail), "i");
|
||||
assert_eq!(tail_w, 1);
|
||||
|
||||
let (before, before_w, after, after_w) =
|
||||
extract_segments_impl(&to_u16("A\x1b]66;s=2;Hi\x1b\\Z"), 1, 3, 2, true, DEFAULT_TAB_WIDTH);
|
||||
assert_eq!(String::from_utf16_lossy(&before), "A");
|
||||
assert_eq!(before_w, 1);
|
||||
assert_eq!(String::from_utf16_lossy(&after), "i");
|
||||
assert_eq!(after_w, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plain_range_keeps_ascii_base_with_combining_mark() {
|
||||
let mut out = Vec::new();
|
||||
let data = to_u16("ab\u{0301}c界");
|
||||
let (width, wrote) =
|
||||
append_visible_range_plain_u16(&mut out, &data, 1, 1, true, DEFAULT_TAB_WIDTH, |_| {});
|
||||
assert!(wrote);
|
||||
assert_eq!(width, 1);
|
||||
assert_eq!(String::from_utf16_lossy(&out), "b\u{0301}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ansi_detection() {
|
||||
let data = to_u16("\x1b[31mred\x1b[0m");
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- Added the `tui.maxInlineImages` setting (default `8`) capping how many inline images render as live terminal graphics. Once a new image pushes the count past the cap, the oldest images are hidden via a full redraw — replaced by their `[Image: …]` text placeholder and purged from the terminal's graphics store — so long sessions with many screenshots/diagrams stop piling up images (and, on Kitty, stop leaving scrollback ghosts). Set to `0` to keep every image inline.
|
||||
- Added a "View: terminal state" item to the `/debug` menu that prints the detected terminal, live geometry and cell size, multiplexer, and the negotiated subprotocols actually in use — graphics (Kitty/iTerm2/Sixel), desktop notifications (BEL/OSC 9/OSC 99, plus whether OSC 99 was confirmed via a device-attributes probe), OSC 8 hyperlinks, 24-bit color, DECCARA rectangular-SGR background fills, and DEC 2026 synchronized output — alongside the scrollback-clear strategy (`CSI 22 J` vs `CSI 2 J` redraw / ED3 eager-erase risk) and the raw `TERM`/`TERM_PROGRAM`/`COLORTERM` detection signals.
|
||||
- Added a "Test: terminal protocols" item to the `/debug` menu that renders one live sample of every special escape protocol the renderer can emit — SGR text attributes (bold/italic/underline/strikethrough/inverse/dim), themed and 24-bit truecolor, OSC 8 hyperlinks, OSC 66 text sizing (large text), and an inline graphics swatch via the active image protocol (Kitty/iTerm2/Sixel, with a text fallback) — and fires a desktop notification, so you can eyeball which protocols the current terminal actually honors. The sample image is a gradient PNG generated in-process, so the graphics test needs no asset on disk.
|
||||
- Added the `tui.textSizing` setting (default off) that renders Markdown H1 headings at 2x scale via Kitty's OSC 66 text-sizing protocol. It replaces the undocumented `PI_TUI_TEXT_SIZING` env var with a real setting, and only takes effect on Kitty terminals (where OSC 66 is implemented) — it is ignored everywhere else so headings never emit raw escape bytes.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -15,6 +16,8 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the terminal protocol debug probe reusing one stable Kitty graphics id across repeated panels, which could move/replace an earlier swatch instead of rendering a new one.
|
||||
|
||||
- Fixed selector dialogs (the `ask` tool, hook prompts) collapsing to a single visible option on shorter terminals when options carried long descriptions: the highlighted option's wrapped description consumed the entire row budget, hiding every other option and making the menu feel unnavigable (down moved the lone visible entry, left/right did nothing). When the fully-expanded list overflows, `HookSelectorComponent` now renders a compact list — every option label stays on screen and only the highlighted option expands its description, truncated to the remaining rows — so the whole menu is always visible and the detail pane follows the cursor.
|
||||
- Fixed `read` failing with "Path not found" on web URLs whose scheme `//` collapsed to a single `/` (e.g. `https:/github.com/...`), which happens when a URL is routed through Node's `path.normalize`/`path.resolve`. The fetch URL recognizer now accepts a single-slash scheme and repairs it back to `//` before fetching, so collapsed URLs resolve instead of falling through to filesystem lookup.
|
||||
|
||||
|
||||
@@ -615,6 +615,17 @@ export const SETTINGS_SCHEMA = {
|
||||
"Maximum number of inline images kept as live terminal graphics (default 8). Older images fall back to a text placeholder via a full redraw once the limit is exceeded. Set to 0 to keep every image (no limit).",
|
||||
},
|
||||
|
||||
"tui.textSizing": {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
ui: {
|
||||
tab: "appearance",
|
||||
label: "Large Headings (Kitty)",
|
||||
description:
|
||||
"Render Markdown H1 headings at 2x scale using Kitty's OSC 66 text-sizing protocol. Only takes effect on Kitty terminals; ignored everywhere else. Off by default.",
|
||||
},
|
||||
},
|
||||
|
||||
"tui.hyperlinks": {
|
||||
type: "enum",
|
||||
values: ["off", "auto", "always"] as const,
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type Component,
|
||||
Container,
|
||||
encodeTextSized,
|
||||
getTextSizing,
|
||||
Image,
|
||||
type ImageBudget,
|
||||
ImageProtocol,
|
||||
@@ -197,7 +196,7 @@ export class ProtocolProbeComponent extends Container {
|
||||
constructor(options: ProtocolProbeOptions) {
|
||||
super();
|
||||
const hyperlinksOn = TERMINAL.hyperlinks;
|
||||
const sizingOn = getTextSizing();
|
||||
const sizingOn = TERMINAL.textSizing;
|
||||
const yesNo = (on: boolean) => (on ? theme.fg("success", "supported") : theme.fg("muted", "unsupported"));
|
||||
|
||||
this.addChild(new DynamicBorder());
|
||||
@@ -232,7 +231,7 @@ export class ProtocolProbeComponent extends Container {
|
||||
this.addChild(new RawLines(buildLargeTextLines()));
|
||||
} else {
|
||||
this.addChild(
|
||||
new Text(theme.fg("dim", " (enable with PI_TUI_TEXT_SIZING=1 on a supporting terminal)"), 1, 0),
|
||||
new Text(theme.fg("dim", " (enable via the tui.textSizing setting on a Kitty terminal)"), 1, 0),
|
||||
);
|
||||
}
|
||||
this.addChild(new Spacer(1));
|
||||
@@ -246,7 +245,7 @@ export class ProtocolProbeComponent extends Container {
|
||||
{ fallbackColor: (text: string) => theme.fg("toolOutput", text) },
|
||||
// Fixed modest caps (not the user's inline-image setting) keep the
|
||||
// swatch a crisp, bounded preview rather than an upscaled wall.
|
||||
{ maxWidthCells: 20, maxHeightCells: 16, budget: options.imageBudget, imageKey: "debug:protocol-probe" },
|
||||
{ maxWidthCells: 20, maxHeightCells: 16, budget: options.imageBudget },
|
||||
options.image.dimensions,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
Markdown,
|
||||
ProcessTerminal,
|
||||
Spacer,
|
||||
setTerminalTextSizing,
|
||||
TERMINAL,
|
||||
Text,
|
||||
TUI,
|
||||
visibleWidth,
|
||||
@@ -391,6 +393,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
|
||||
this.ui.setClearOnShrink(settings.get("clearOnShrink"));
|
||||
this.ui.setMaxInlineImages(settings.get("tui.maxInlineImages"));
|
||||
// OSC 66 text-sizing is Kitty-only; resolve the setting against the terminal's
|
||||
// capability (`TERMINAL.textSizing` defaults on for Kitty) so it stays off
|
||||
// unless the user opts in, and never emits raw escapes on other terminals.
|
||||
setTerminalTextSizing(settings.get("tui.textSizing") && TERMINAL.textSizing);
|
||||
this.chatContainer = new Container();
|
||||
this.pendingMessagesContainer = new Container();
|
||||
this.statusContainer = new Container();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { beforeAll, describe, expect, it } from "bun:test";
|
||||
import { getImageDimensions } from "@oh-my-pi/pi-tui";
|
||||
import { buildLargeTextLines, buildSampleImage, encodeRgbPng } from "../../src/debug/protocol-probe";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { getImageDimensions, ImageBudget, ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui";
|
||||
import {
|
||||
buildLargeTextLines,
|
||||
buildSampleImage,
|
||||
encodeRgbPng,
|
||||
ProtocolProbeComponent,
|
||||
} from "../../src/debug/protocol-probe";
|
||||
import { initTheme } from "../../src/modes/theme/theme";
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -8,6 +13,14 @@ beforeAll(async () => {
|
||||
await initTheme();
|
||||
});
|
||||
|
||||
type MutableTerminalInfo = { imageProtocol: ImageProtocol | null };
|
||||
const terminal = TERMINAL as unknown as MutableTerminalInfo;
|
||||
const originalImageProtocol = TERMINAL.imageProtocol;
|
||||
|
||||
afterEach(() => {
|
||||
terminal.imageProtocol = originalImageProtocol;
|
||||
});
|
||||
|
||||
describe("encodeRgbPng / buildSampleImage", () => {
|
||||
it("emits a PNG whose IHDR dimensions decode back to the requested size", () => {
|
||||
// The graphics test relies on producing a *valid* PNG: Kitty/iTerm2 hand
|
||||
@@ -30,6 +43,22 @@ describe("encodeRgbPng / buildSampleImage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses independent graphics ids for repeated probe panels", () => {
|
||||
terminal.imageProtocol = ImageProtocol.Kitty;
|
||||
const budget = new ImageBudget(8, () => {});
|
||||
const image = buildSampleImage(8, 8);
|
||||
const first = new ProtocolProbeComponent({ image, imageBudget: budget, notificationSuppressed: true });
|
||||
const second = new ProtocolProbeComponent({ image, imageBudget: budget, notificationSuppressed: true });
|
||||
|
||||
budget.beginPass();
|
||||
const firstBytes = first.render(80).join("\n");
|
||||
const secondBytes = second.render(80).join("\n");
|
||||
budget.endPass();
|
||||
|
||||
expect(firstBytes).toContain("i=1");
|
||||
expect(secondBytes).toContain("i=2");
|
||||
});
|
||||
|
||||
describe("buildLargeTextLines", () => {
|
||||
it("encodes each scale as an OSC 66 span and reserves scale-1 blank rows below it", () => {
|
||||
const lines = buildLargeTextLines([2, 3]);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
- Added Kitty `CSI 22 J` screen-to-scrollback clears for non-destructive full paints, while keeping ED3 for destructive history/session rebuilds.
|
||||
- Added Kitty OSC 99 rich notification formatting and startup capability probing.
|
||||
- Added gated Kitty OSC 66 text-sized Markdown H1 headings plus native text-width support for OSC 66 spans.
|
||||
- Added Kitty OSC 66 text-sized Markdown H1 headings (2x scale) plus native text-width support for OSC 66 spans. Off by default and gated to Kitty (the only terminal implementing OSC 66) via the `TERMINAL.textSizing` capability; hosts enable it through `setTextSizing`.
|
||||
- Added Kitty Unicode placeholder image rendering (`U=1` + U+10EEEE with explicit row/column diacritics): inline images are drawn as real text cells that carry the image id in their foreground color, so they survive horizontal slicing, reflow, and overlapping draws instead of relying on cursor-positioned `a=p` placements. Enabled by default on Kitty-family terminals; opt out with `PI_NO_KITTY_PLACEHOLDERS=1`, and falls back to direct placement when a grid exceeds the diacritic table's addressable range.
|
||||
- Added Kitty temp-file image transmission (`t=t`): on local sessions, decoded PNG bytes are written to a `tty-graphics-protocol` temp file and the path is sent instead of in-band base64, gated behind a startup `a=q,t=t` support probe. Controlled by `PI_KITTY_IMAGE_TRANSMISSION=direct|temp-file|auto`; disabled over SSH unless explicitly forced.
|
||||
- Added DECRQM capability detection for DEC private modes 2026 (synchronized output) and 2048 (in-band resize). Synchronized-output paint wrappers are dropped when the terminal reports 2026 unsupported (preserving the `PI_NO_SYNC_OUTPUT` override), and DEC 2048 in-band resize is enabled when supported — reported geometry and cell pixel size are updated from `CSI 48 ; rows ; cols ; yPx ; xPx t` reports, with SIGWINCH and `CSI 16 t` kept as fallbacks.
|
||||
@@ -20,6 +20,10 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the DECCARA background-fill optimizer rejecting or repainting the wrong cells when a trailing fill crossed from default-background spaces into colored spaces.
|
||||
- Fixed DEC private-mode reports with DECRPM status 3/4 being treated as unsupported, so permanent 2026/2048 reports stay recognized.
|
||||
- Fixed OSC 66 text-sizing width and slicing edge cases, including ZWJ emoji payloads and partial slices through scaled spans.
|
||||
|
||||
- Fixed the DECCARA background-fill optimizer painting fills on the wrong rows ("split into unaligned halves") in the differential repaint path. When a diff grew the transcript past the viewport, writing the rewritten rows scrolled the terminal, but the absolute DECCARA rectangle coordinates were derived from the pre-scroll viewport top, so every fill landed `scrollAmount` rows too low while the relatively-positioned text settled correctly; rows scrolled into history were also shortened, dropping their background padding from native scrollback. Rectangles now target the post-scroll rows and only rows remaining in the final viewport are optimized.
|
||||
- Fixed native scrollback desynchronization after terminal width or height changes reflowed overflowing content while the viewport was not at the bottom
|
||||
- Fixed a notification chip (or any injected block) rendering on top of an actively streaming tool render on ED3-risk terminals (Ghostty/kitty/Alacritty/iTerm2). While a foreground tool streams, its header's elapsed-time counter ticks every frame; once output scrolls the header above the viewport top, each tick is an offscreen edit that — because the eager scrollback-rebuild opt-in is gated off on these terminals — repaints the viewport in place and advances the rendered line count without committing the new overflow to native history. `#scrollbackHighWater` then lagged the logical viewport top, so a later content shrink whose changes landed in the visible region slipped past the shrink-across-boundary guard and reached the differential emitter, which is anchored to `#maxLinesRendered - height`: it rewrote only the suffix, dropped the newly exposed top row, and left a blank at the bottom, drifting every row below the edit one line up so it painted over the rows above. Such shrinks now re-anchor the bottom of the viewport with a non-destructive repaint, and the foreground-streaming shrink-across-boundary case repaints the live tail instead of padding and pinning the pre-shrink viewport.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LRUCache } from "lru-cache/raw";
|
||||
import { Marked, marked, type Token, Tokenizer, type Tokens } from "marked";
|
||||
import type { SymbolTheme } from "../symbols";
|
||||
import { getTextSizing, TERMINAL } from "../terminal-capabilities";
|
||||
import { TERMINAL } from "../terminal-capabilities";
|
||||
import type { Component } from "../tui";
|
||||
import {
|
||||
applyBackgroundToLine,
|
||||
@@ -357,7 +357,7 @@ export class Markdown implements Component {
|
||||
// by MarkdownTheme and is one of the most styling-sensitive entries.
|
||||
const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
|
||||
const headingProbe = this.#theme.heading("");
|
||||
const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${getTextSizing() ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
|
||||
const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${TERMINAL.textSizing ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
|
||||
const cached = renderCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
|
||||
@@ -550,16 +550,14 @@ export class Markdown implements Component {
|
||||
const headingText = this.#renderInlineTokens(token.tokens || [], styleContext);
|
||||
const headingPlainText = plainInlineTokens(token.tokens || []);
|
||||
let styledHeading: string;
|
||||
if (headingLevel === 1 && getTextSizing()) {
|
||||
if (headingLevel === 1 && TERMINAL.textSizing) {
|
||||
const plainWidth = visibleWidth(headingPlainText);
|
||||
if (plainWidth > 0 && 2 * plainWidth <= width) {
|
||||
const sizedHeading = encodeTextSizedHeading(headingPlainText, 2);
|
||||
lines.push(this.#theme.heading(this.#theme.bold(this.#theme.underline(sizedHeading))));
|
||||
if (nextTokenType) {
|
||||
lines.push(""); // reserve the heading's second visual row
|
||||
if (nextTokenType !== "space") {
|
||||
lines.push(""); // Add spacing after headings (unless space token follows)
|
||||
}
|
||||
lines.push(""); // reserve the heading's second visual row
|
||||
if (nextTokenType && nextTokenType !== "space") {
|
||||
lines.push(""); // Add spacing after headings (unless space token follows)
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -142,10 +142,11 @@ export function analyzeBgFillLine(line: string, width: number): BgFillAnalysis |
|
||||
// Byte index / column immediately after the last non-space printable glyph.
|
||||
let nonSpaceEndByte = 0;
|
||||
let nonSpaceEndCol = 0;
|
||||
// Background covering the current trailing run of spaces, and whether it has
|
||||
// stayed constant across that run. `trailBg` is captured the moment a space
|
||||
// run is seen and reset whenever a non-space glyph restarts the trailing run.
|
||||
// Background covering the current trailing run of spaces, and whether that
|
||||
// trailing run has started. `null` is a real "default background" value, so
|
||||
// it cannot double as the uninitialized sentinel.
|
||||
let trailBg: BgState = null;
|
||||
let trailStarted = false;
|
||||
let trailConsistent = true;
|
||||
|
||||
while (i < line.length) {
|
||||
@@ -180,13 +181,22 @@ export function analyzeBgFillLine(line: string, width: number): BgFillAnalysis |
|
||||
const nonSpaceWidth = visibleWidth(text.slice(0, nonSpaceLen));
|
||||
nonSpaceEndByte = i + nonSpaceLen;
|
||||
nonSpaceEndCol = col + nonSpaceWidth;
|
||||
// Any trailing spaces in this same run sit under the current bg.
|
||||
trailBg = bg;
|
||||
// Spaces after the last non-space glyph in this same printable run sit
|
||||
// under the current bg. If there are none, the trailing region has not
|
||||
// started yet; a later SGR can still begin a uniform fill safely.
|
||||
if (nonSpaceLen < text.length) {
|
||||
trailBg = bg;
|
||||
trailStarted = true;
|
||||
} else {
|
||||
trailBg = null;
|
||||
trailStarted = false;
|
||||
}
|
||||
trailConsistent = true;
|
||||
} else if (text.length > 0) {
|
||||
// Whole run is spaces: it extends the trailing region. Track bg drift.
|
||||
if (trailBg === null) {
|
||||
if (!trailStarted) {
|
||||
trailBg = bg;
|
||||
trailStarted = true;
|
||||
} else if (bg !== trailBg) {
|
||||
trailConsistent = false;
|
||||
}
|
||||
@@ -197,7 +207,7 @@ export function analyzeBgFillLine(line: string, width: number): BgFillAnalysis |
|
||||
|
||||
if (col !== width) return null; // not a full-width fill
|
||||
if (nonSpaceEndCol >= width) return null; // no trailing padding to drop
|
||||
if (trailBg === null || !trailConsistent) return null; // default/mixed bg — nothing safe to paint
|
||||
if (!trailStarted || trailBg === null || !trailConsistent) return null; // default/mixed bg — nothing safe to paint
|
||||
return { cut: nonSpaceEndByte, leftCol: nonSpaceEndCol, bg: trailBg };
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ export class TerminalInfo {
|
||||
public readonly eagerEraseScrollbackRisk: boolean = false,
|
||||
public readonly deccara: boolean = false,
|
||||
readonly supportsScreenToScrollback: boolean = false,
|
||||
/** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
|
||||
public readonly textSizing: boolean = false,
|
||||
) {}
|
||||
|
||||
isImageLine(line: string): boolean {
|
||||
@@ -208,7 +210,7 @@ const KNOWN_TERMINALS = Object.freeze({
|
||||
base: new TerminalInfo("base", null, false, false, NotifyProtocol.Bell),
|
||||
trueColor: new TerminalInfo("trueColor", null, true, false, NotifyProtocol.Bell),
|
||||
// Recognized terminals
|
||||
kitty: new TerminalInfo("kitty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99, true, true, true),
|
||||
kitty: new TerminalInfo("kitty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc99, true, true, true, true),
|
||||
ghostty: new TerminalInfo("ghostty", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9, true),
|
||||
wezterm: new TerminalInfo("wezterm", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9, true),
|
||||
iterm2: new TerminalInfo("iterm2", ImageProtocol.Iterm2, true, true, NotifyProtocol.Osc9, true),
|
||||
@@ -323,6 +325,7 @@ type MutableTerminalInfo = {
|
||||
imageProtocol: ImageProtocol | null;
|
||||
deccara: boolean;
|
||||
supportsScreenToScrollback: boolean;
|
||||
textSizing: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -346,6 +349,15 @@ export function setTerminalScreenToScrollback(enabled: boolean): void {
|
||||
(TERMINAL as unknown as MutableTerminalInfo).supportsScreenToScrollback = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable OSC 66 text-sizing at runtime. The coding-agent calls this from
|
||||
* the `tui.textSizing` setting (gated on the terminal's static `textSizing`
|
||||
* capability); tests flip it directly to exercise the scaled-heading path.
|
||||
*/
|
||||
export function setTerminalTextSizing(enabled: boolean): void {
|
||||
(TERMINAL as unknown as MutableTerminalInfo).textSizing = enabled;
|
||||
}
|
||||
|
||||
export function getTerminalInfo(terminalId: TerminalId): TerminalInfo {
|
||||
return KNOWN_TERMINALS[terminalId];
|
||||
}
|
||||
@@ -931,23 +943,3 @@ function formatOsc99Notification(n: TerminalNotification): string {
|
||||
}
|
||||
return osc99Payload(meta, title, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the terminal supports the OSC 66 text-sizing protocol. The CPR probe
|
||||
* is deliberately not run in normal startup because it moves the cursor; the
|
||||
* initial value is an explicit opt-in for Kitty only, and tests can override it.
|
||||
*/
|
||||
export function detectTextSizingSupport(terminalId: TerminalId, env: NodeJS.ProcessEnv = Bun.env): boolean {
|
||||
const optIn = env.PI_TUI_TEXT_SIZING?.toLowerCase();
|
||||
return terminalId === "kitty" && (optIn === "1" || optIn === "true" || optIn === "on");
|
||||
}
|
||||
|
||||
let textSizing = detectTextSizingSupport(TERMINAL_ID, Bun.env);
|
||||
|
||||
export function getTextSizing(): boolean {
|
||||
return textSizing;
|
||||
}
|
||||
|
||||
export function setTextSizing(enabled: boolean): void {
|
||||
textSizing = enabled;
|
||||
}
|
||||
|
||||
@@ -434,10 +434,11 @@ export class ProcessTerminal implements Terminal {
|
||||
|
||||
// DECRPM private-mode report. Resolves the matching probe by mode; the
|
||||
// owner stays in the FIFO and is drained by its DA1 sentinel (a no-op
|
||||
// once resolved). Per spec status 1 (set) / 2 (reset) = recognized.
|
||||
// once resolved). Per DECRPM, status 0 = unrecognized; 1/2 = set/reset,
|
||||
// and 3/4 = permanently set/reset, still recognized.
|
||||
const decrpmMatch = sequence.match(decrpmResponsePattern);
|
||||
if (decrpmMatch) {
|
||||
this.#resolvePrivateMode(parseInt(decrpmMatch[1]!, 10), decrpmMatch[2] === "1" || decrpmMatch[2] === "2");
|
||||
this.#resolvePrivateMode(parseInt(decrpmMatch[1]!, 10), decrpmMatch[2] !== "0");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -202,7 +202,9 @@ export function visibleWidthRaw(str: string): number {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
if (code < 0x20 || code > 0x7e) {
|
||||
return str.includes("\u200d") ? visibleWidthByGrapheme(str) : nativeVisibleWidth(str, getDefaultTabWidth());
|
||||
const tabWidth = getDefaultTabWidth();
|
||||
if (str.includes("\x1b]66;")) return nativeVisibleWidth(str, tabWidth);
|
||||
return str.includes("\u200d") ? visibleWidthByGrapheme(str) : nativeVisibleWidth(str, tabWidth);
|
||||
}
|
||||
}
|
||||
return str.length;
|
||||
|
||||
@@ -147,6 +147,19 @@ describe("analyzeBgFillLine", () => {
|
||||
expect(analyzeBgFillLine(`hello${" ".repeat(5)}`, 10)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects colored trailing fills after default-background gap cells", () => {
|
||||
expect(analyzeBgFillLine(`${" ".repeat(2)}${BG_OPEN}${" ".repeat(8)}${close}`, 10)).toBeNull();
|
||||
expect(analyzeBgFillLine(`X ${BG_OPEN}${" ".repeat(8)}${close}`, 10)).toBeNull();
|
||||
});
|
||||
|
||||
it("allows a colored trailing fill that starts immediately after default content", () => {
|
||||
expect(analyzeBgFillLine(`X${BG_OPEN}${" ".repeat(9)}${close}`, 10)).toEqual({
|
||||
cut: 1,
|
||||
leftCol: 1,
|
||||
bg: BG_SGR,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects rows narrower than the full width", () => {
|
||||
const line = `\x1b[41mab${" ".repeat(3)}${close}`;
|
||||
expect(analyzeBgFillLine(line, 10)).toBeNull();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { stripVTControlCharacters } from "node:util";
|
||||
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
||||
import { Chalk } from "chalk";
|
||||
import { Markdown, renderInlineMarkdown } from "../src/components/markdown.js";
|
||||
import { getTextSizing, setTextSizing, TERMINAL } from "../src/terminal-capabilities.js";
|
||||
import { setTerminalTextSizing, TERMINAL } from "../src/terminal-capabilities.js";
|
||||
import { type Component, TUI } from "../src/tui.js";
|
||||
import { visibleWidth } from "../src/utils.js";
|
||||
import { defaultMarkdownTheme } from "./test-themes.js";
|
||||
@@ -1254,34 +1254,36 @@ describe("OSC 66 text-sizing headings", () => {
|
||||
|
||||
afterEach(() => {
|
||||
// The capability gate is process-global; never let it leak into other suites.
|
||||
setTextSizing(false);
|
||||
setTerminalTextSizing(false);
|
||||
});
|
||||
|
||||
it("keeps H1 as plain ANSI when text-sizing is disabled (default)", () => {
|
||||
expect(getTextSizing()).toBe(false);
|
||||
expect(TERMINAL.textSizing).toBe(false);
|
||||
const lines = new Markdown("# Hello", 0, 0, defaultMarkdownTheme).render(80);
|
||||
expect(lines.some(line => line.includes(OSC66_INTRO))).toBe(false);
|
||||
expect(lines.some(line => stripVTControlCharacters(line).includes("Hello"))).toBe(true);
|
||||
});
|
||||
|
||||
it("emits a scale-2 OSC 66 span for H1 when text-sizing is enabled and width allows", () => {
|
||||
setTextSizing(true);
|
||||
it("emits a scale-2 OSC 66 span for H1 and reserves its second visual row", () => {
|
||||
setTerminalTextSizing(true);
|
||||
const lines = new Markdown("# Hello", 0, 0, defaultMarkdownTheme).render(80);
|
||||
|
||||
const oscLine = lines.find(line => line.includes(OSC66_INTRO));
|
||||
expect(oscLine).toBeTruthy();
|
||||
expect(oscLine!).toContain("s=2");
|
||||
const oscIndex = lines.findIndex(line => line.includes(OSC66_INTRO));
|
||||
expect(oscIndex).toBeGreaterThanOrEqual(0);
|
||||
const oscLine = lines[oscIndex]!;
|
||||
expect(oscLine).toContain("s=2");
|
||||
// The heading text rides inside the OSC 66 payload, so it survives in the
|
||||
// raw bytes (stripVTControlCharacters would drop the whole OSC span).
|
||||
expect(oscLine!.includes("Hello")).toBe(true);
|
||||
expect(oscLine.includes("Hello")).toBe(true);
|
||||
expect(lines[oscIndex + 1]).toBe("");
|
||||
|
||||
// Native + emit agree: a scale-2 span measures exactly twice the plain
|
||||
// heading width regardless of how the span is internally encoded.
|
||||
expect(visibleWidth(oscLine!)).toBe(2 * visibleWidth("Hello"));
|
||||
expect(visibleWidth(oscLine)).toBe(2 * visibleWidth("Hello"));
|
||||
});
|
||||
|
||||
it("leaves the reserved row after a scale-2 H1 as a cursor-only blank", () => {
|
||||
setTextSizing(true);
|
||||
setTerminalTextSizing(true);
|
||||
const lines = new Markdown("# Hello\n\nBody", 0, 0, defaultMarkdownTheme).render(80);
|
||||
const oscIndex = lines.findIndex(line => line.includes(OSC66_INTRO));
|
||||
expect(oscIndex).toBeGreaterThanOrEqual(0);
|
||||
@@ -1290,7 +1292,7 @@ describe("OSC 66 text-sizing headings", () => {
|
||||
});
|
||||
|
||||
it("doubles the measured width for wide/emoji H1 glyphs", () => {
|
||||
setTextSizing(true);
|
||||
setTerminalTextSizing(true);
|
||||
const lines = new Markdown("# 🚀 Hi", 0, 0, defaultMarkdownTheme).render(80);
|
||||
|
||||
const oscLine = lines.find(line => line.includes(OSC66_INTRO));
|
||||
@@ -1299,7 +1301,7 @@ describe("OSC 66 text-sizing headings", () => {
|
||||
});
|
||||
|
||||
it("falls back to ANSI when the doubled H1 width would overflow the render width", () => {
|
||||
setTextSizing(true);
|
||||
setTerminalTextSizing(true);
|
||||
// "Hello" is 5 cells; 2*5 = 10 > 8 render columns, so the OSC path is skipped.
|
||||
const lines = new Markdown("# Hello", 0, 0, defaultMarkdownTheme).render(8);
|
||||
expect(lines.some(line => line.includes(OSC66_INTRO))).toBe(false);
|
||||
@@ -1307,7 +1309,7 @@ describe("OSC 66 text-sizing headings", () => {
|
||||
});
|
||||
|
||||
it("keeps H2 as plain ANSI even when text-sizing is enabled", () => {
|
||||
setTextSizing(true);
|
||||
setTerminalTextSizing(true);
|
||||
const lines = new Markdown("## Sub", 0, 0, defaultMarkdownTheme).render(80);
|
||||
expect(lines.some(line => line.includes(OSC66_INTRO))).toBe(false);
|
||||
expect(lines.some(line => stripVTControlCharacters(line).includes("Sub"))).toBe(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ProcessTerminal } from "@oh-my-pi/pi-tui/terminal";
|
||||
import {
|
||||
type CellDimensions,
|
||||
getCellDimensions,
|
||||
getTerminalInfo,
|
||||
ImageProtocol,
|
||||
setCellDimensions,
|
||||
setTerminalImageProtocol,
|
||||
@@ -468,6 +469,15 @@ describe("ProcessTerminal DECRQM + in-band resize (DEC 2026/2048)", () => {
|
||||
terminal.stop();
|
||||
});
|
||||
|
||||
it("reports permanent DECRPM statuses 3 and 4 as recognized private modes", () => {
|
||||
const { terminal, reports } = setup();
|
||||
process.stdin.emit("data", "\x1b[?2026;3$y");
|
||||
process.stdin.emit("data", "\x1b[?2048;4$y");
|
||||
expect(reports).toContainEqual({ mode: 2026, supported: true });
|
||||
expect(reports).toContainEqual({ mode: 2048, supported: true });
|
||||
terminal.stop();
|
||||
});
|
||||
|
||||
it("reports a private mode unsupported when DECRPM status is 0", () => {
|
||||
const { terminal, reports } = setup();
|
||||
process.stdin.emit("data", "\x1b[?2026;0$y");
|
||||
@@ -592,3 +602,14 @@ describe("ProcessTerminal Kitty graphics temp-file probe", () => {
|
||||
terminal.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OSC 66 text-sizing capability", () => {
|
||||
it("advertises text sizing only for Kitty", () => {
|
||||
// OSC 66 is a Kitty-only protocol; any other terminal must report the
|
||||
// capability as false so the renderer never emits raw escape bytes there.
|
||||
expect(getTerminalInfo("kitty").textSizing).toBe(true);
|
||||
for (const id of ["ghostty", "wezterm", "iterm2", "vscode", "alacritty", "base", "trueColor"] as const) {
|
||||
expect(getTerminalInfo(id).textSizing).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,6 +102,8 @@ describe("text utils", () => {
|
||||
expect(visibleWidth("\x1b]66;s=3:w=4;X\x1b\\")).toBe(12);
|
||||
expect(visibleWidth("\x1b]66;;abc\x1b\\")).toBe(3);
|
||||
expect(visibleWidth(`A${"\x1b]66;s=2;Hi\x1b\\"}Z`)).toBe(1 + 4 + 1);
|
||||
const family = "👨👩👧👦";
|
||||
expect(visibleWidth(encodeTextSized(family, { scale: 2, widthCells: 2 }))).toBe(4);
|
||||
});
|
||||
|
||||
it("slices and truncates OSC 66 spans atomically", () => {
|
||||
@@ -117,10 +119,15 @@ describe("text utils", () => {
|
||||
expect(visibleWidth(fullTruncate)).toBe(4);
|
||||
|
||||
const partialSlice = sliceWithWidth(osc66, 0, 2, true);
|
||||
expect(partialSlice.text).toBe("Hi");
|
||||
expect(partialSlice.width).toBe(2);
|
||||
expect(partialSlice.text).toBe("H");
|
||||
expect(partialSlice.width).toBe(1);
|
||||
expect(partialSlice.text.includes("\x1b]66")).toBe(false);
|
||||
|
||||
const trailingSlice = sliceWithWidth(osc66, 2, 2, true);
|
||||
expect(trailingSlice.text).toBe("i");
|
||||
expect(trailingSlice.width).toBe(1);
|
||||
expect(trailingSlice.text.includes("\x1b]66")).toBe(false);
|
||||
|
||||
const partialTruncate = truncateToWidth(osc66, 3);
|
||||
expect(partialTruncate.includes("\x1b]66")).toBe(false);
|
||||
expect(partialTruncate.includes("\x1b]")).toBe(false);
|
||||
@@ -133,8 +140,8 @@ describe("text utils", () => {
|
||||
|
||||
expect(result.before).toBe("A");
|
||||
expect(result.beforeWidth).toBe(1);
|
||||
expect(result.after).toBe("Hi");
|
||||
expect(result.afterWidth).toBe(2);
|
||||
expect(result.after).toBe("H");
|
||||
expect(result.afterWidth).toBe(1);
|
||||
expect(result.after.includes("\x1b]66")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user