diff --git a/packages/core/src/examples/focus-restore-demo.ts b/packages/core/src/examples/focus-restore-demo.ts new file mode 100644 index 000000000..c342adcdd --- /dev/null +++ b/packages/core/src/examples/focus-restore-demo.ts @@ -0,0 +1,310 @@ +#!/usr/bin/env bun + +// Interactive demo to test the focus restore fix on Windows Terminal. +// +// How to test: +// 1. Run from the example selector, or: bun src/examples/focus-restore-demo.ts +// 2. Move the mouse around - you should see the mouse position update live +// 3. Alt-tab away from the terminal, then alt-tab back +// 4. Move the mouse again - if the fix works, mouse tracking resumes immediately +// 5. Try minimizing and restoring the window too +// 6. Press Escape to return to menu, Ctrl+C to exit + +import { + type CliRenderer, + createCliRenderer, + BoxRenderable, + TextRenderable, + RGBA, + TextAttributes, + type MouseEvent, +} from "../index" +import { setupCommonDemoKeys } from "./lib/standalone-keys" + +let container: BoxRenderable | null = null +let mouseArea: BoxRenderable | null = null + +let mouseX = 0 +let mouseY = 0 +let mouseEvents = 0 +let focusCount = 0 +let blurCount = 0 +let restoreCount = 0 +let lastFocusTime = "" +let lastBlurTime = "" +let lastMouseTime = "" +let focused = true +let originalRestore: any = null +let focusHandler: (() => void) | null = null +let blurHandler: (() => void) | null = null + +// Log storage +const logEntries: Array<{ text: string; color: RGBA }> = [] +const maxLogEntries = 20 + +// Renderable references for updates +let focusStatus: TextRenderable | null = null +let mouseStatus: TextRenderable | null = null +let countersStatus: TextRenderable | null = null +let timestampStatus: TextRenderable | null = null +let logBox: BoxRenderable | null = null +const logRenderables: TextRenderable[] = [] + +function ts(): string { + return new Date().toLocaleTimeString("en-US", { hour12: false }) +} + +function addLogLine(renderer: CliRenderer, text: string, color: RGBA) { + if (!logBox) return + + logEntries.push({ text, color }) + while (logEntries.length > maxLogEntries) { + logEntries.shift() + } + + // Remove old renderables + for (const r of logRenderables) { + logBox.remove(r.id) + r.destroy() + } + logRenderables.length = 0 + + // Rebuild from entries + for (let i = 0; i < logEntries.length; i++) { + const entry = logEntries[i] + const line = new TextRenderable(renderer, { + id: `focus-demo-log-${i}`, + content: entry.text, + fg: entry.color, + height: 1, + }) + logBox.add(line) + logRenderables.push(line) + } +} + +function updateDisplay() { + if (focusStatus) { + focusStatus.content = focused + ? "Focus: YES (terminal modes active)" + : "Focus: NO (modes may be stripped by terminal)" + focusStatus.fg = focused ? RGBA.fromInts(126, 231, 135) : RGBA.fromInts(255, 100, 100) + } + if (mouseStatus) { + mouseStatus.content = `Mouse: (${mouseX}, ${mouseY}) | Events: ${mouseEvents}` + } + if (countersStatus) { + countersStatus.content = `Focus-in: ${focusCount} | Focus-out: ${blurCount} | Mode restores: ${restoreCount}` + } + if (timestampStatus) { + timestampStatus.content = `Last focus: ${lastFocusTime || "--"} | Last blur: ${lastBlurTime || "--"} | Last mouse: ${lastMouseTime || "--"}` + } +} + +export function run(renderer: CliRenderer): void { + renderer.setBackgroundColor("#0D1117") + + // Reset state + mouseX = 0 + mouseY = 0 + mouseEvents = 0 + focusCount = 0 + blurCount = 0 + restoreCount = 0 + lastFocusTime = "" + lastBlurTime = "" + lastMouseTime = "" + focused = true + logEntries.length = 0 + + container = new BoxRenderable(renderer, { + id: "focus-demo-main", + flexDirection: "column", + padding: 1, + }) + renderer.root.add(container) + + // Title + const title = new TextRenderable(renderer, { + id: "focus-demo-title", + content: "Focus Restore Demo - Mouse Tracking + Terminal Mode Restore", + fg: RGBA.fromInts(72, 209, 204), + attributes: TextAttributes.BOLD, + height: 2, + }) + container.add(title) + + // Instructions + const instructions = new TextRenderable(renderer, { + id: "focus-demo-instructions", + content: + "Move mouse to see tracking. Alt-tab away and back. Mouse should resume.\n" + + "Minimize and restore. Try clicking after returning. Escape to return to menu.", + fg: RGBA.fromInts(160, 160, 180), + height: 3, + }) + container.add(instructions) + + // Status box + const statusBox = new BoxRenderable(renderer, { + id: "focus-demo-status-box", + border: true, + borderColor: "#4ECDC4", + borderStyle: "rounded", + title: "Terminal State", + titleAlignment: "center", + padding: 1, + flexDirection: "column", + marginTop: 1, + }) + container.add(statusBox) + + focusStatus = new TextRenderable(renderer, { + id: "focus-demo-focus-status", + content: "Focus: YES (terminal modes active)", + fg: RGBA.fromInts(126, 231, 135), + height: 1, + }) + statusBox.add(focusStatus) + + mouseStatus = new TextRenderable(renderer, { + id: "focus-demo-mouse-status", + content: "Mouse: (0, 0) | Events: 0", + fg: RGBA.fromInts(165, 214, 255), + height: 1, + }) + statusBox.add(mouseStatus) + + countersStatus = new TextRenderable(renderer, { + id: "focus-demo-counters", + content: "Focus-in: 0 | Focus-out: 0 | Mode restores: 0", + fg: RGBA.fromInts(210, 168, 255), + height: 1, + }) + statusBox.add(countersStatus) + + timestampStatus = new TextRenderable(renderer, { + id: "focus-demo-timestamps", + content: "Last focus: -- | Last blur: -- | Last mouse: --", + fg: RGBA.fromInts(139, 148, 158), + height: 1, + }) + statusBox.add(timestampStatus) + + // Event log box + logBox = new BoxRenderable(renderer, { + id: "focus-demo-log-box", + border: true, + borderColor: "#6BCF7F", + borderStyle: "rounded", + title: "Event Log (latest 20)", + titleAlignment: "center", + padding: 1, + flexDirection: "column", + marginTop: 1, + flexGrow: 1, + }) + container.add(logBox) + + // Mouse tracking area (covers whole screen, behind everything) + mouseArea = new BoxRenderable(renderer, { + id: "focus-demo-mouse-area", + position: "absolute", + left: 0, + top: 0, + width: "100%", + height: "100%", + zIndex: -1, + onMouse(event: MouseEvent) { + mouseX = event.x + mouseY = event.y + mouseEvents++ + lastMouseTime = ts() + updateDisplay() + }, + }) + renderer.root.add(mouseArea) + + // Spy on restoreTerminalModes to count restore calls + originalRestore = (renderer as any).lib.restoreTerminalModes + ;(renderer as any).lib.restoreTerminalModes = (...args: any[]) => { + restoreCount++ + return originalRestore.call((renderer as any).lib, ...args) + } + + // Focus/blur handlers + focusHandler = () => { + focused = true + focusCount++ + lastFocusTime = ts() + addLogLine( + renderer, + `[${ts()}] FOCUS IN - terminal modes restored (restore #${restoreCount})`, + RGBA.fromInts(126, 231, 135), + ) + updateDisplay() + } + + blurHandler = () => { + focused = false + blurCount++ + lastBlurTime = ts() + addLogLine(renderer, `[${ts()}] FOCUS OUT - terminal may strip escape codes`, RGBA.fromInts(255, 165, 0)) + updateDisplay() + } + + renderer.on("focus", focusHandler) + renderer.on("blur", blurHandler) + + addLogLine(renderer, `[${ts()}] Demo started. Move mouse, then alt-tab away and back.`, RGBA.fromInts(165, 214, 255)) + updateDisplay() + + renderer.requestRender() +} + +export function destroy(renderer: CliRenderer): void { + // Restore spy + if (originalRestore) { + ;(renderer as any).lib.restoreTerminalModes = originalRestore + originalRestore = null + } + + // Remove event listeners + if (focusHandler) { + renderer.off("focus", focusHandler) + focusHandler = null + } + if (blurHandler) { + renderer.off("blur", blurHandler) + blurHandler = null + } + + // Clean up renderables + if (mouseArea) { + renderer.root.remove(mouseArea.id) + mouseArea.destroy() + mouseArea = null + } + if (container) { + renderer.root.remove(container.id) + container.destroyRecursively() + container = null + } + + logRenderables.length = 0 + logEntries.length = 0 + focusStatus = null + mouseStatus = null + countersStatus = null + timestampStatus = null + logBox = null +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + enableMouseMovement: true, + }) + run(renderer) + setupCommonDemoKeys(renderer) +} diff --git a/packages/core/src/examples/index.ts b/packages/core/src/examples/index.ts index 99747910b..8d305c75f 100644 --- a/packages/core/src/examples/index.ts +++ b/packages/core/src/examples/index.ts @@ -66,6 +66,7 @@ import * as scrollboxOverlayHitTest from "./scrollbox-overlay-hit-test" import * as scrollboxMouseTest from "./scrollbox-mouse-test" import * as textTruncationDemo from "./text-truncation-demo" import * as grayscaleBufferDemo from "./grayscale-buffer-demo" +import * as focusRestoreDemo from "./focus-restore-demo" import { setupCommonDemoKeys } from "./lib/standalone-keys" interface Example { @@ -389,6 +390,12 @@ const examples: Example[] = [ run: grayscaleBufferDemo.run, destroy: grayscaleBufferDemo.destroy, }, + { + name: "Focus Restore Demo", + description: "Test focus restore - alt-tab away and back to verify mouse tracking resumes", + run: focusRestoreDemo.run, + destroy: focusRestoreDemo.destroy, + }, ] class ExampleSelector { diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index c8db3ea67..8116a65aa 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -1050,6 +1050,11 @@ export class CliRenderer extends EventEmitter implements RenderContext { private focusHandler: (sequence: string) => boolean = ((sequence: string) => { if (sequence === "\x1b[I") { + // When the terminal regains focus, some terminal emulators (notably + // Windows Terminal / ConPTY) may have stripped DEC private modes like + // mouse tracking, bracketed paste, and focus tracking itself while the + // window was unfocused. Re-send all active mode sequences unconditionally. + this.lib.restoreTerminalModes(this.rendererPtr) this.emit("focus") return true } diff --git a/packages/core/src/tests/renderer.focus-restore.test.ts b/packages/core/src/tests/renderer.focus-restore.test.ts new file mode 100644 index 000000000..c5c5f723f --- /dev/null +++ b/packages/core/src/tests/renderer.focus-restore.test.ts @@ -0,0 +1,200 @@ +import { test, expect, beforeEach, afterEach, describe, spyOn } from "bun:test" +import { Buffer } from "node:buffer" +import { createTestRenderer, type TestRenderer, type MockInput, type MockMouse } from "../testing/test-renderer" +import { Renderable } from "../Renderable" + +class TestRenderable extends Renderable { + constructor(renderer: TestRenderer, options: any) { + super(renderer, options) + } +} + +let renderer: TestRenderer +let mockInput: MockInput +let mockMouse: MockMouse +let renderOnce: () => Promise +let restoreSpy: ReturnType + +beforeEach(async () => { + await new Promise((resolve) => setTimeout(resolve, 15)) + ;({ renderer, mockInput, mockMouse, renderOnce } = await createTestRenderer({ + useMouse: true, + })) + + // Spy on restoreTerminalModes — spyOn wraps the real method, tracks calls, + // and mockRestore() in afterEach puts the original back on the singleton. + // No capability mocks are needed: the sequences under test (\x1b[I, \x1b[O) + // are not capability responses and never reach processCapabilityResponse. + restoreSpy = spyOn(renderer.lib, "restoreTerminalModes") +}) + +afterEach(() => { + restoreSpy.mockRestore() + renderer.destroy() +}) + +describe("focus restore - terminal mode re-enable on focus-in", () => { + test("restoreTerminalModes is called on focus-in event", async () => { + renderer.stdin.emit("data", Buffer.from("\x1b[I")) + await new Promise((resolve) => setTimeout(resolve, 15)) + + expect(restoreSpy).toHaveBeenCalledTimes(1) + }) + + test("restoreTerminalModes is NOT called on blur event", async () => { + renderer.stdin.emit("data", Buffer.from("\x1b[O")) + await new Promise((resolve) => setTimeout(resolve, 15)) + + expect(restoreSpy).toHaveBeenCalledTimes(0) + }) + + test("restoreTerminalModes is called before focus event is emitted", async () => { + const callOrder: string[] = [] + + restoreSpy.mockImplementation((...args: any[]) => { + callOrder.push("restoreTerminalModes") + }) + + renderer.on("focus", () => { + callOrder.push("focus-event") + }) + + renderer.stdin.emit("data", Buffer.from("\x1b[I")) + await new Promise((resolve) => setTimeout(resolve, 15)) + + expect(callOrder).toEqual(["restoreTerminalModes", "focus-event"]) + }) + + test("multiple focus-in events each trigger restoreTerminalModes", async () => { + // Simulate: focus lost -> focus gained -> focus lost -> focus gained + renderer.stdin.emit("data", Buffer.from("\x1b[O")) + await new Promise((resolve) => setTimeout(resolve, 15)) + + renderer.stdin.emit("data", Buffer.from("\x1b[I")) + await new Promise((resolve) => setTimeout(resolve, 15)) + + renderer.stdin.emit("data", Buffer.from("\x1b[O")) + await new Promise((resolve) => setTimeout(resolve, 15)) + + renderer.stdin.emit("data", Buffer.from("\x1b[I")) + await new Promise((resolve) => setTimeout(resolve, 15)) + + expect(restoreSpy).toHaveBeenCalledTimes(2) + }) + + test("focus-in emits focus event on the renderer", async () => { + const events: string[] = [] + + renderer.on("focus", () => { + events.push("focus") + }) + + renderer.on("blur", () => { + events.push("blur") + }) + + renderer.stdin.emit("data", Buffer.from("\x1b[I")) + await new Promise((resolve) => setTimeout(resolve, 15)) + + renderer.stdin.emit("data", Buffer.from("\x1b[O")) + await new Promise((resolve) => setTimeout(resolve, 15)) + + expect(events).toEqual(["focus", "blur"]) + }) + + test("focus events do not trigger keypress events", async () => { + const keypresses: any[] = [] + + renderer.keyInput.on("keypress", (event) => { + keypresses.push(event) + }) + + renderer.stdin.emit("data", Buffer.from("\x1b[I")) + await new Promise((resolve) => setTimeout(resolve, 15)) + renderer.stdin.emit("data", Buffer.from("\x1b[O")) + await new Promise((resolve) => setTimeout(resolve, 15)) + + expect(keypresses).toHaveLength(0) + }) + + test("mouse events work after focus restore cycle", async () => { + renderer.start() + + const target = new TestRenderable(renderer, { + position: "absolute", + left: 0, + top: 0, + width: renderer.width, + height: renderer.height, + }) + renderer.root.add(target) + await renderOnce() + + let mouseEventCount = 0 + target.onMouse = () => { + mouseEventCount++ + } + + // Verify mouse works initially + await mockMouse.click(5, 5) + expect(mouseEventCount).toBeGreaterThan(0) + + const countBefore = mouseEventCount + + // Simulate focus loss and regain + renderer.stdin.emit("data", Buffer.from("\x1b[O")) + await new Promise((resolve) => setTimeout(resolve, 15)) + renderer.stdin.emit("data", Buffer.from("\x1b[I")) + await new Promise((resolve) => setTimeout(resolve, 15)) + + // Verify restoreTerminalModes was called + expect(restoreSpy).toHaveBeenCalledTimes(1) + + // Verify mouse still works after focus restore + await mockMouse.click(5, 5) + expect(mouseEventCount).toBeGreaterThan(countBefore) + + renderer.root.remove(target.id) + }) + + test("keyboard input works after focus restore cycle", async () => { + renderer.start() + + let keyEventCount = 0 + const onKeypress = () => { + keyEventCount++ + } + renderer.keyInput.on("keypress", onKeypress) + + // Verify keyboard works initially + mockInput.pressKey("a") + await new Promise((resolve) => setTimeout(resolve, 15)) + expect(keyEventCount).toBeGreaterThan(0) + + const countBefore = keyEventCount + + // Simulate focus loss and regain + renderer.stdin.emit("data", Buffer.from("\x1b[O")) + await new Promise((resolve) => setTimeout(resolve, 15)) + renderer.stdin.emit("data", Buffer.from("\x1b[I")) + await new Promise((resolve) => setTimeout(resolve, 15)) + + // Verify keyboard still works after focus restore + mockInput.pressKey("b") + await new Promise((resolve) => setTimeout(resolve, 15)) + expect(keyEventCount).toBeGreaterThan(countBefore) + + renderer.keyInput.off("keypress", onKeypress) + }) + + test("rapid focus toggle does not cause issues", async () => { + // Simulate rapid alt-tab back and forth + for (let i = 0; i < 10; i++) { + renderer.stdin.emit("data", Buffer.from("\x1b[O")) + renderer.stdin.emit("data", Buffer.from("\x1b[I")) + } + await new Promise((resolve) => setTimeout(resolve, 15)) + + expect(restoreSpy).toHaveBeenCalledTimes(10) + }) +}) diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index a81dd0e85..25cc0ce14 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -380,6 +380,10 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "i64"], returns: "void", }, + restoreTerminalModes: { + args: ["ptr"], + returns: "void", + }, enableMouse: { args: ["ptr", "bool"], returns: "void", @@ -1414,6 +1418,7 @@ export interface RenderLib { dumpHitGrid: (renderer: Pointer) => void dumpBuffers: (renderer: Pointer, timestamp?: number) => void dumpStdoutBuffer: (renderer: Pointer, timestamp?: number) => void + restoreTerminalModes: (renderer: Pointer) => void enableMouse: (renderer: Pointer, enableMovement: boolean) => void disableMouse: (renderer: Pointer) => void enableKittyKeyboard: (renderer: Pointer, flags: number) => void @@ -2308,6 +2313,10 @@ class FFIRenderLib implements RenderLib { this.opentui.symbols.dumpStdoutBuffer(renderer, ts) } + public restoreTerminalModes(renderer: Pointer): void { + this.opentui.symbols.restoreTerminalModes(renderer) + } + public enableMouse(renderer: Pointer, enableMovement: boolean): void { this.opentui.symbols.enableMouse(renderer, enableMovement) } diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index a7d4d0fb3..377fe057f 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -530,6 +530,10 @@ export fn dumpStdoutBuffer(rendererPtr: *renderer.CliRenderer, timestamp: i64) v rendererPtr.dumpStdoutBuffer(timestamp); } +export fn restoreTerminalModes(rendererPtr: *renderer.CliRenderer) void { + rendererPtr.restoreTerminalModes(); +} + export fn enableMouse(rendererPtr: *renderer.CliRenderer, enableMovement: bool) void { rendererPtr.enableMouse(enableMovement); } diff --git a/packages/core/src/zig/renderer.zig b/packages/core/src/zig/renderer.zig index f8f9b5255..adefac024 100644 --- a/packages/core/src/zig/renderer.zig +++ b/packages/core/src/zig/renderer.zig @@ -1176,6 +1176,12 @@ pub const CliRenderer = struct { self.dumpStdoutBuffer(timestamp); } + pub fn restoreTerminalModes(self: *CliRenderer) void { + var stream = std.io.fixedBufferStream(&self.writeOutBuf); + self.terminal.restoreTerminalModes(stream.writer()) catch {}; + self.writeOut(stream.getWritten()); + } + pub fn enableMouse(self: *CliRenderer, enableMovement: bool) void { _ = enableMovement; var stream = std.io.fixedBufferStream(&self.writeOutBuf); diff --git a/packages/core/src/zig/terminal.zig b/packages/core/src/zig/terminal.zig index 6d08380aa..9467c852a 100644 --- a/packages/core/src/zig/terminal.zig +++ b/packages/core/src/zig/terminal.zig @@ -95,6 +95,7 @@ capability_queries_pending: bool = false, state: struct { alt_screen: bool = false, kitty_keyboard: bool = false, + kitty_keyboard_flags: u8 = 0, bracketed_paste: bool = false, mouse: bool = false, pixel_mouse: bool = false, @@ -473,11 +474,13 @@ pub fn setKittyKeyboard(self: *Terminal, tty: anytype, enable: bool, flags: u8) if (!self.state.kitty_keyboard) { try tty.print(ansi.ANSI.csiUPush, .{flags}); self.state.kitty_keyboard = true; + self.state.kitty_keyboard_flags = flags; } } else { if (self.state.kitty_keyboard) { try tty.writeAll(ansi.ANSI.csiUPop); self.state.kitty_keyboard = false; + self.state.kitty_keyboard_flags = 0; } } } @@ -488,6 +491,61 @@ pub fn setModifyOtherKeys(self: *Terminal, tty: anytype, enable: bool) !void { self.state.modify_other_keys = enable; } +/// Re-send all currently-active terminal mode escape sequences unconditionally. +/// +/// When the terminal loses and regains focus (e.g. alt-tab, tab switch, minimize), +/// some terminal emulators (notably Windows Terminal / ConPTY) strip or reset +/// DEC private modes like mouse tracking (?1000/?1002/?1003/?1006), focus +/// tracking (?1004), and bracketed paste (?2004). This function re-emits the +/// enable sequences for every mode that our state tracking says is currently on, +/// without checking whether the mode "should" already be enabled — because the +/// terminal may have silently disabled it. +/// +/// This should be called in response to the focus-in event (\x1b[I). +/// +/// Per the xterm ctlseqs spec (Patch #401, 2025/06/22) and the Microsoft +/// Console Virtual Terminal Sequences documentation, the relevant DECSET +/// private modes are: +/// ?1000h - Normal mouse tracking (sends button press/release) +/// ?1002h - Button-event tracking (adds drag reporting) +/// ?1003h - Any-event tracking (adds all motion reporting) +/// ?1006h - SGR extended mouse mode (extended coordinate encoding) +/// ?1004h - Focus event tracking (sends \x1b[I / \x1b[O) +/// ?2004h - Bracketed paste mode (wraps pasted text in markers) +/// Kitty keyboard protocol (CSI > flags u) - progressive enhancement +/// modifyOtherKeys (CSI > 4 ; 1 m) - xterm key modification +pub fn restoreTerminalModes(self: *Terminal, tty: anytype) !void { + // Re-enable mouse tracking modes if active + if (self.state.mouse) { + try tty.writeAll(ansi.ANSI.enableMouseTracking); + try tty.writeAll(ansi.ANSI.enableButtonEventTracking); + try tty.writeAll(ansi.ANSI.enableAnyEventTracking); + try tty.writeAll(ansi.ANSI.enableSGRMouseMode); + } + + // Re-enable focus tracking if active + if (self.state.focus_tracking) { + try tty.writeAll(ansi.ANSI.focusSet); + } + + // Re-enable bracketed paste if active + if (self.state.bracketed_paste) { + try tty.writeAll(ansi.ANSI.bracketedPasteSet); + } + + // Pop stale entry then re-push kitty keyboard protocol to avoid stack growth. + // Both sequences are in the same write buffer so the terminal processes them atomically. + if (self.state.kitty_keyboard) { + try tty.writeAll(ansi.ANSI.csiUPop); + try tty.print(ansi.ANSI.csiUPush, .{self.state.kitty_keyboard_flags}); + } + + // Re-enable modifyOtherKeys if active + if (self.state.modify_other_keys) { + try tty.writeAll(ansi.ANSI.modifyOtherKeysSet); + } +} + /// The responses look like these: /// kitty - '\x1B[?1016;2$y\x1B[?2027;0$y\x1B[?2031;2$y\x1B[?1004;1$y\x1B[?2026;2$y\x1B[1;2R\x1B[1;3R\x1BP>|kitty(0.40.1)\x1B\\\x1B[?0u\x1B_Gi=1;EINVAL:Zero width/height not allowed\x1B\\\x1B[?62;c' /// ghostty - '\x1B[?1016;1$y\x1B[?2027;1$y\x1B[?2031;2$y\x1B[?1004;1$y\x1B[?2004;2$y\x1B[?2026;2$y\x1B[1;1R\x1B[1;1R\x1BP>|ghostty 1.1.3\x1B\\\x1B[?0u\x1B_Gi=1;OK\x1B\\\x1B[?62;22c'