diff --git a/packages/natives/test/native.test.ts b/packages/natives/test/native.test.ts index b04dddce9..f659d765a 100644 --- a/packages/natives/test/native.test.ts +++ b/packages/natives/test/native.test.ts @@ -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({ diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 193e50368..7052f95bd 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -9,7 +9,7 @@ ### Fixed - Fixed the kitty keyboard progressive-enhancement probe to honor the `CSI ? 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)) diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index cad90d5fe..4d7f31e6b 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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 {