fix(tui): preserved visible ANSI content when truncating terminal rows

- Replaced the TUI row truncation path with per-character and ANSI-sequence iteration that tracks visible cells before clipping output.
- Added ANSI helpers to parse sequence boundaries and preserve OSC66 visible payloads while enforcing max source length during truncation.
- Added natives regression coverage for Ghostty super+alt backspace key matching and parsing.
This commit is contained in:
can1357
2026-06-08 01:22:26 +02:00
parent d930f4c82d
commit a89bb76444
3 changed files with 89 additions and 2 deletions
+13
View File
@@ -14,7 +14,9 @@ import {
invalidateFsScanCache,
listWorkspace,
MacOSPowerAssertion,
matchesKey,
PtySession,
parseKey,
summarizeCode,
truncateToWidth,
visibleWidth,
@@ -148,6 +150,17 @@ describe("pi-natives", () => {
expect(summarizeCode({ path: "fixture.ts", code, minBodyLines: 3 }).elided).toBe(true);
});
});
describe("keys", () => {
it("matches Ghostty's super+alt Backspace Kitty wire", () => {
const ghosttyOptionBackspace = "\x1b[127;11u";
expect(matchesKey(ghosttyOptionBackspace, "super+alt+backspace", true)).toBe(true);
expect(matchesKey(ghosttyOptionBackspace, "alt+super+backspace", true)).toBe(true);
expect(matchesKey(ghosttyOptionBackspace, "alt+backspace", true)).toBe(false);
expect(parseKey(ghosttyOptionBackspace, true)).toBe("alt+super+backspace");
});
});
describe("grep", () => {
it("should find patterns in files", async () => {
const result = await grep({
+1 -1
View File
@@ -9,7 +9,7 @@
### Fixed
- Fixed the kitty keyboard progressive-enhancement probe to honor the `CSI ? <flags> u` reply even when the terminal answers the DA1 sentinel first. Previously the kitty reply was discarded once the DA1-driven `modifyOtherKeys` fallback engaged, so terminals like Superset/xterm-on-Electron stayed on the fallback and delivered Shift+Enter as a bare `\r` ([#2042](https://github.com/can1357/oh-my-pi/issues/2042)).
- Bounded TUI line fitting for oversized raw rows so ANSI-heavy subagent output cannot grow render buffers independently of the viewport ([#2045](https://github.com/can1357/oh-my-pi/issues/2045)).
- Bounded TUI line fitting for oversized raw rows so ANSI-heavy subagent output and zero-width-heavy text cannot grow render buffers independently of the viewport or hide visible suffix text ([#2045](https://github.com/can1357/oh-my-pi/issues/2045)).
- Fixed tmux offscreen-shrink frames to skip repainting when the visible tail is unchanged, avoiding intermittent blank/refresh flashes in pane terminals ([#2046](https://github.com/can1357/oh-my-pi/issues/2046)).
- Fixed Windows ConPTY hosts (Windows Terminal, Tabby, Hyper, VS Code) parking the viewport at the top of a full paint after a `/resume` or any long-session repaint. `ProcessTerminal#safeWrite` now splits oversized writes into ≤ 8 KiB pieces at line boundaries on `win32` and inside WSL (where stdout still crosses ConPTY at the `wslhost` boundary) so each underlying `WriteFile` stays below the ~32 KiB threshold where ConPTY stops tracking the cursor; the data was always delivered, but the host UI's scroll position would not follow until any focus event forced a re-query. ([#2034](https://github.com/can1357/oh-my-pi/issues/2034))
+75 -1
View File
@@ -2515,7 +2515,81 @@ export class TUI extends Container {
);
if (raw.length <= maxSourceLength) return raw;
return truncateToWidth(raw, safeWidth, Ellipsis.Omit) + SEGMENT_RESET;
let output = "";
let cells = 0;
for (let i = 0; i < raw.length && cells < safeWidth; ) {
if (raw.charCodeAt(i) === 0x1b) {
const end = this.#ansiSequenceEnd(raw, i);
if (end < 0) break;
if (this.#ansiSequenceHasVisiblePayload(raw, i)) {
const sequence = raw.slice(i, end);
if (output.length + sequence.length <= maxSourceLength) {
output += sequence;
cells += visibleWidth(sequence);
}
}
i = end;
continue;
}
const code = raw.charCodeAt(i);
const next = code >= 0xd800 && code <= 0xdbff && i + 1 < raw.length ? i + 2 : i + 1;
const char = raw.slice(i, next);
const charWidth = visibleWidth(char);
if (charWidth > 0 && cells + charWidth > safeWidth) break;
if (output.length + char.length > maxSourceLength) {
if (charWidth > 0) break;
i = next;
continue;
}
if (charWidth === 0) {
const remainingVisibleCells = safeWidth - cells;
const reservedCodeUnits = remainingVisibleCells * 2;
if (output.length + char.length > maxSourceLength - reservedCodeUnits) {
i = next;
continue;
}
}
output += char;
cells += charWidth;
i = next;
}
return output + SEGMENT_RESET;
}
#ansiSequenceEnd(line: string, start: number): number {
const next = line.charCodeAt(start + 1);
if (next === 0x5b) {
let i = start + 2;
while (i < line.length) {
const final = line.charCodeAt(i);
if (final >= 0x40 && final <= 0x7e) return i + 1;
i++;
}
return -1;
}
if (next === 0x5d) {
let i = start + 2;
while (i < line.length) {
const osc = line.charCodeAt(i);
if (osc === 0x07) return i + 1;
if (osc === 0x1b && line.charCodeAt(i + 1) === 0x5c) return i + 2;
i++;
}
return -1;
}
return start + 2 <= line.length ? start + 2 : -1;
}
#ansiSequenceHasVisiblePayload(line: string, start: number): boolean {
// OSC 66 (`\x1b]66;META;TEXT\x1b\\`) carries visible cells inside the payload.
return (
line.charCodeAt(start + 1) === 0x5d &&
line.charCodeAt(start + 2) === 0x36 &&
line.charCodeAt(start + 3) === 0x36 &&
line.charCodeAt(start + 4) === 0x3b
);
}
#ansiAsciiLineWidth(line: string, maxWidth: number): number | undefined {