diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 83b0dfe30..e0375deaa 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Changed the `showHardwareCursor` setting to use only the terminal cursor instead of also drawing Pi's reverse-video cursor + ## [1.1.0] - 2026-10-07 ### New Features diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index e7063179e..b36f1b3c5 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -99,7 +99,7 @@ See [Compaction Reference](compaction.md) for trigger, summarization, and valida | `editorPaddingX` | number | `0` | Horizontal editor padding from 0 to 3 cells. | | `outputPad` | `0 \| 1` | `1` | Horizontal transcript padding for messages, tool output, `!` command output, and summary blocks. | | `autocompleteMaxVisible` | number | `5` | Visible autocomplete entries, from 3 to 20. | -| `showHardwareCursor` | boolean | `false` | Show the terminal cursor while Pi positions it for input methods. | +| `showHardwareCursor` | boolean | `false` | Use the terminal cursor instead of Pi's drawn cursor. Pi still positions it for input methods. | | `terminal.showImages` | boolean | `true` | Display inline images when supported. | | `terminal.imageWidthCells` | number | `60` | Preferred inline image width in terminal cells. | | `terminal.clearOnShrink` | boolean | `false` | Clear empty rows when rendered content shrinks. | diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index 4a2ff2c56..693e3a4e7 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -45,7 +45,7 @@ Prefer these components over rebuilding selection, scrolling, text editing, or w Use `matchesKey()` and `Key` for terminal keyboard input. The parser accounts for supported terminal protocols and key modifiers. Extension components should use the injected `KeybindingsManager` for configurable application actions. -A component that displays a text cursor should implement `Focusable` and place `CURSOR_MARKER` immediately before its visual cursor. The TUI uses that marker to position the hardware cursor for input method editors. +A component that displays a text cursor should implement `Focusable` and place `CURSOR_MARKER` immediately before its visual cursor. Wrap the cursor cell in `renderFakeCursor()` so the TUI draws it in reverse video, or hides it after `CURSOR_MARKER` when the hardware cursor is shown. The TUI uses the marker to position the hardware cursor for input method editors. Containers that wrap an `Input` or `Editor` must propagate their `focused` state to that child. Without propagation, Chinese, Japanese, Korean, and other IME candidate windows can appear at the wrong screen position. diff --git a/packages/coding-agent/examples/extensions/overlay-test.ts b/packages/coding-agent/examples/extensions/overlay-test.ts index c51cde1ac..cf3066dfb 100644 --- a/packages/coding-agent/examples/extensions/overlay-test.ts +++ b/packages/coding-agent/examples/extensions/overlay-test.ts @@ -9,7 +9,7 @@ */ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent"; -import { CURSOR_MARKER, type Focusable, matchesKey, visibleWidth } from "@earendil-works/pi-tui"; +import { CURSOR_MARKER, type Focusable, matchesKey, renderFakeCursor, visibleWidth } from "@earendil-works/pi-tui"; export default function (pi: ExtensionAPI) { pi.registerCommand("overlay-test", { @@ -131,7 +131,7 @@ class OverlayTestComponent implements Focusable { const after = inputDisplay.slice(item.cursor + 1); // Emit hardware cursor marker for IME support when focused const marker = this.focused ? CURSOR_MARKER : ""; - inputDisplay = `${before}${marker}\x1b[7m${cursorChar}\x1b[27m${after}`; + inputDisplay = `${before}${marker}${renderFakeCursor(cursorChar)}${after}`; } content = `${prefix + label} ${inputDisplay}`; } else { diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index af42ab631..ae28c57fb 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -807,7 +807,7 @@ export class SettingsSelectorComponent extends Container { items.splice(skillCommandsIndex + 1, 0, { id: "show-hardware-cursor", label: "Show hardware cursor", - description: "Show the terminal cursor while still positioning it for IME support", + description: "Use the terminal cursor instead of Pi's drawn cursor", currentValue: config.showHardwareCursor ? "true" : "false", values: ["true", "false"], }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index fad55bf3c..c47c0bcc0 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +### Breaking Changes + +- `Editor.render()` and `Input.render()` now wrap the drawn cursor in zero-width APC markers instead of emitting `\x1b[7m` directly. TUI renderers resolve them; code that renders these components outside a TUI must strip them, for example with `stripTerminalSequences()` + +### Added + +- Added `renderFakeCursor()`. Components wrap their drawn cursor with it, and the TUI renders it in reverse video, or omits it after `CURSOR_MARKER` when `showHardwareCursor` is enabled + +### Changed + +- Changed `showHardwareCursor` to show only the terminal cursor: focused `Editor` and `Input` components no longer draw a reverse-video cursor when it is enabled. This avoids terminals that extend edge cell colors into the window padding stretching the drawn cursor at the first or last column + +### Fixed + +- Fixed overlay compositing dropping `CURSOR_MARKER` when an overlay ends directly left of the cursor, which lost the hardware cursor position + ## [1.1.0] - 2026-10-07 ### Breaking Changes diff --git a/packages/tui/README.md b/packages/tui/README.md index 04407be06..1150ebcf5 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -308,7 +308,7 @@ const collapsible = new MouseRegion(content, (event) => { Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface: ```typescript -import { CURSOR_MARKER, type Component, type Focusable } from "@earendil-works/pi-tui"; +import { CURSOR_MARKER, type Component, type Focusable, renderFakeCursor } from "@earendil-works/pi-tui"; class MyInput implements Component, Focusable { focused: boolean = false; // Set by TUI when focus changes @@ -316,7 +316,7 @@ class MyInput implements Component, Focusable { render(width: number): string[] { const marker = this.focused ? CURSOR_MARKER : ""; // Emit marker right before the fake cursor - return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`]; + return [`> ${beforeCursor}${marker}${renderFakeCursor(atCursor)}${afterCursor}`]; } invalidate(): void {} @@ -329,7 +329,9 @@ When a `Focusable` component has focus, TUI: 3. Positions the hardware terminal cursor at that location 4. Shows the hardware cursor only when `showHardwareCursor` is enabled -The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the renderer constructor's `showHardwareCursor` argument or `setShowHardwareCursor(true)`. The `Editor` and `Input` built-in components already implement this interface. +`renderFakeCursor()` wraps the cursor cell in zero-width markers. TUI renders them as reverse video. When `showHardwareCursor` is enabled, TUI removes the fake cursor that directly follows `CURSOR_MARKER`, so the focused component shows only the terminal cursor. Fake cursors without a preceding `CURSOR_MARKER` stay visible. + +The hardware cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the renderer constructor's `showHardwareCursor` argument or `setShowHardwareCursor(true)`. The `Editor` and `Input` built-in components already implement this interface. **Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child: diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index c7fda44eb..5d453e3cd 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -6,6 +6,7 @@ import { type Component, CURSOR_MARKER, type Focusable, + renderFakeCursor, type TUI, type TuiMouseEvent, type TuiMouseEventResult, @@ -587,12 +588,12 @@ export class Editor implements Component, Focusable { const afterGraphemes = [...this.segment(after, "grapheme")]; const firstGrapheme = afterGraphemes[0]?.segment || ""; const restAfter = after.slice(firstGrapheme.length); - const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`; + const cursor = renderFakeCursor(firstGrapheme); displayText = before + marker + cursor + restAfter; // lineVisibleWidth stays the same - we're replacing, not adding } else { // Cursor is at the end - add highlighted space - const cursor = "\x1b[7m \x1b[0m"; + const cursor = renderFakeCursor(" "); displayText = before + marker + cursor; lineVisibleWidth = lineVisibleWidth + 1; // If cursor overflows content width into the padding, flag it diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 1bc264530..48aacbff6 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,7 +1,14 @@ import { getKeybindings } from "../keybindings.ts"; import { decodeKittyPrintable } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; -import { type Component, CURSOR_MARKER, type Focusable, type TuiMouseEvent, type TuiMouseEventResult } from "../tui.ts"; +import { + type Component, + CURSOR_MARKER, + type Focusable, + renderFakeCursor, + type TuiMouseEvent, + type TuiMouseEventResult, +} from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, truncateToWidth, visibleWidth } from "../utils.ts"; import { findWordBackward, findWordForward } from "../word-navigation.ts"; @@ -423,7 +430,7 @@ export class Input implements Component, Focusable { const atCursor = graphemes[0]?.segment ?? " "; const afterCursor = placeholder.slice(atCursor.length); const marker = this.focused ? CURSOR_MARKER : ""; - const cursorChar = `\x1b[7m${this.placeholderStyle(atCursor)}\x1b[27m`; + const cursorChar = renderFakeCursor(this.placeholderStyle(atCursor)); const textWithCursor = marker + cursorChar + this.placeholderStyle(afterCursor); const padding = " ".repeat(Math.max(0, availableWidth - visibleWidth(textWithCursor))); return [this.prompt + textWithCursor + padding]; @@ -480,8 +487,8 @@ export class Input implements Component, Focusable { // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning) const marker = this.focused ? CURSOR_MARKER : ""; - // Use inverse video to show cursor - const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal + // TUI draws the fake cursor in reverse video, or omits it when the hardware cursor is shown + const cursorChar = renderFakeCursor(atCursor); const textWithCursor = beforeCursor + marker + cursorChar + afterCursor; // Calculate visual width diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index a7aa6215c..eb962609f 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -168,6 +168,7 @@ export { type OverlayMargin, type OverlayOptions, type OverlayUnfocusOptions, + renderFakeCursor, type SizeValue, type TUI, type TuiInputListener, diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 8bcac9682..a0ffeb89a 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -407,7 +407,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.terminal.write(`${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`); } else { const width = Math.max(1, this.terminal.columns); - const documentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + const documentLines = this.resolveFakeCursors(this.render(width)).map((line) => + line.replace(OSC133_ZONE_PREFIX, ""), + ); this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map( (line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)), ); @@ -1684,7 +1686,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if (this.refreshSearch(nextLayout)) { nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); } - let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + // Resolve fake cursors before highlighting and compositing, which only track SGR codes + let screen = this.resolveFakeCursors(nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, ""))); screen = this.applySearchHighlights(screen, nextLayout); screen = this.compositeScrollToEndIndicator(screen, nextLayout, width); screen = this.compositeOverlays(screen, width, height); diff --git a/packages/tui/src/tui-main-screen.ts b/packages/tui/src/tui-main-screen.ts index c079dd776..c497acdbf 100644 --- a/packages/tui/src/tui-main-screen.ts +++ b/packages/tui/src/tui-main-screen.ts @@ -261,7 +261,8 @@ export class TuiMainScreen extends TuiBase implements TUI { }; // Render all components to get new lines - let newLines = this.render(width); + // Resolve fake cursors before compositing so overlays and line slicing see plain SGR codes + let newLines = this.resolveFakeCursors(this.render(width)); // Composite overlays into the rendered lines (before differential compare) if (this.hasOverlayEntries) { diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 7968a9e79..56a983b17 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -195,6 +195,17 @@ export function isFocusable(component: Component | null): component is Component */ export const CURSOR_MARKER = "\x1b_pi:c\x07"; +/** Fake cursor markers - zero-width APC sequences around the text drawn as the cursor. See resolveFakeCursors(). */ +export const FAKE_CURSOR_START = "\x1b_pi:fc\x07"; +export const FAKE_CURSOR_END = "\x1b_pi:/fc\x07"; +// CURSOR_MARKER + FAKE_CURSOR_START + text + FAKE_CURSOR_END. Truncating the line right after the cursor cell drops the end. +const FOCUSED_FAKE_CURSOR = /(\x1b_pi:c\x07)\x1b_pi:fc\x07(.*?)(?:\x1b_pi:\/fc\x07|$)/s; + +/** Wrap text in fake cursor markers. */ +export function renderFakeCursor(text: string): string { + return `${FAKE_CURSOR_START}${text}${FAKE_CURSOR_END}`; +} + export { visibleWidth }; /** @@ -1359,7 +1370,7 @@ export abstract class TuiBase extends Container implements TUI { const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight); // Render component at calculated width - let overlayLines = component.render(width); + let overlayLines = this.resolveFakeCursors(component.render(width)); // Apply maxHeight if specified if (maxHeight !== undefined && overlayLines.length > maxHeight) { @@ -1459,6 +1470,19 @@ export abstract class TuiBase extends Container implements TUI { return null; } + /** + * Resolve fake cursor markers to reverse video. When the hardware cursor is shown, a fake cursor + * directly after CURSOR_MARKER is dropped because the terminal cursor is drawn there. Fake cursors + * of components without focus have no CURSOR_MARKER and stay visible. + */ + protected resolveFakeCursors(lines: string[]): string[] { + return lines.map((line) => { + if (!line.includes(FAKE_CURSOR_START)) return line; + const resolved = this.showHardwareCursor ? line.replace(FOCUSED_FAKE_CURSOR, "$1$2") : line; + return resolved.replaceAll(FAKE_CURSOR_START, "\x1b[7m").replaceAll(FAKE_CURSOR_END, "\x1b[27m"); + }); + } + /** * Query the terminal's theme colors: the default foreground (OSC 10), the default background * (OSC 11), and ANSI colors 0-15 (OSC 4), followed by a DA1 request that marks the end of the diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index be852a32d..272366899 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -1351,8 +1351,13 @@ export function extractSegments( // Include ANSI codes in their respective segments if (currentCol < beforeEnd) { pendingAnsiBefore += ansi.code; - } else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) { - // Only include after we've started "after" (styling already prepended) + } else if ( + currentCol >= afterStart && + currentCol < afterEnd && + (afterStarted || ansi.code.startsWith("\x1b_")) + ) { + // SGR codes are only included after "after" started (styling already prepended). Zero-width APC + // markers like CURSOR_MARKER are not tracked styling, so keep them at the boundary too. after += ansi.code; } i += ansi.length; diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index e20b1310e..552c654b4 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -6,9 +6,9 @@ import { describe, it } from "node:test"; import { stripVTControlCharacters } from "node:util"; import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts"; import { Editor, wordWrapLine } from "../src/components/editor.ts"; -import type { TUI } from "../src/tui.ts"; +import { FAKE_CURSOR_START, renderFakeCursor, type TUI } from "../src/tui.ts"; import { TuiMainScreen } from "../src/tui-main-screen.ts"; -import { visibleWidth } from "../src/utils.ts"; +import { stripTerminalSequences, visibleWidth } from "../src/utils.ts"; import { defaultEditorTheme } from "./test-themes.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; @@ -800,7 +800,7 @@ describe("Editor component", () => { } // Verify content split correctly - const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim()); + const contentLines = lines.slice(1, -1).map((l) => stripTerminalSequences(l).trim()); assert.strictEqual(contentLines.length, 2); assert.strictEqual(contentLines[0], "日本語テス"); // 5 chars = 10 columns assert.strictEqual(contentLines[1], "ト"); // 1 char = 2 columns (+ padding) @@ -832,7 +832,7 @@ describe("Editor component", () => { // The cursor (reverse video space) should be visible const contentLine = lines[1]!; - assert.ok(contentLine.includes("\x1b[7m"), "Should have reverse video cursor"); + assert.ok(contentLine.includes(FAKE_CURSOR_START), "Should have fake cursor"); // Line should still be correct width assert.strictEqual(visibleWidth(contentLine), width); @@ -863,7 +863,7 @@ describe("Editor component", () => { let lines = editor.render(width + paddingX); let contentLines = lines.slice(1, -1); assert.strictEqual(contentLines.length, 1, "Should be 1 content line before wrap"); - assert.ok(contentLines[0]!.endsWith("\x1b[7m \x1b[0m"), "Cursor should be at end of line"); + assert.ok(contentLines[0]!.endsWith(renderFakeCursor(" ")), "Cursor should be at end of line"); // Type 1 more → text wraps to second line editor.handleInput("a"); @@ -883,7 +883,7 @@ describe("Editor component", () => { const lines = editor.render(width); // Get content lines (between borders) - const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim()); + const contentLines = lines.slice(1, -1).map((l) => stripTerminalSequences(l).trim()); // Should NOT break mid-word // Line 1 should end with a complete word diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 7b4ffffc0..34a9b2e0b 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -20,7 +20,7 @@ import { resetCapabilitiesCache, setCapabilities, } from "../src/terminal-image.ts"; -import type { TuiMouseEvent } from "../src/tui.ts"; +import { renderFakeCursor, type TuiMouseEvent } from "../src/tui.ts"; import { TuiAltScreen } from "../src/tui-alt-screen.ts"; import { stripTerminalSequences, visibleWidth } from "../src/utils.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; @@ -1362,6 +1362,26 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("keeps the selection highlight after a fake cursor cell", async () => { + const terminal = new RecordingTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text(`> ${renderFakeCursor("a")}bcd`, 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;6;1M"); + terminal.sendInput("\x1b[<3;6;1m"); + await terminal.waitForRender(); + + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[27m\x1b[7mbcd")), + "selection inverse must be reapplied after the fake cursor ends", + ); + + tui.stop(); + }); + it("uses an injected copySelection handler instead of OSC 52 and reports success", async () => { const terminal = new RecordingTerminal(20, 4); const copied: string[] = []; diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index ae6e7bb68..3749eac76 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Image } from "../src/components/image.ts"; +import { Input } from "../src/components/input.ts"; import type { Terminal } from "../src/terminal.ts"; import { deleteKittyImage, @@ -13,8 +14,9 @@ import { setCapabilities, setCellDimensions, } from "../src/terminal-image.ts"; -import type { Component, TUI } from "../src/tui.ts"; +import { type Component, CURSOR_MARKER, renderFakeCursor, type TUI } from "../src/tui.ts"; import { TuiMainScreen } from "../src/tui-main-screen.ts"; +import { sliceByColumn } from "../src/utils.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class TestComponent implements Component { @@ -951,3 +953,72 @@ describe("TUI differential rendering", () => { tui.stop(); }); }); + +describe("TUI fake cursor", () => { + it("draws the focused fake cursor in reverse video and omits it when the hardware cursor is shown", async () => { + const terminal = new LoggingVirtualTerminal(20, 5); + const tui: TUI = new TuiMainScreen(terminal); + const input = new Input(); + input.setValue("abc"); + tui.addChild(input); + tui.setFocus(input); + tui.start(); + await terminal.waitForRender(); + assert.ok(terminal.getWrites().includes("> \x1b[7ma\x1b[27mbc")); + + terminal.clearWrites(); + tui.setShowHardwareCursor(true); + await terminal.waitForRender(); + assert.ok(!terminal.getWrites().includes("\x1b[7m")); + assert.ok(!terminal.getWrites().includes("\x1b_pi:")); + + tui.stop(); + }); + + it("keeps the fake cursor of an unfocused input when the hardware cursor is shown", async () => { + const terminal = new LoggingVirtualTerminal(20, 5); + const tui: TUI = new TuiMainScreen(terminal, true); + const input = new Input(); + input.setValue("abc"); + tui.addChild(input); + tui.start(); + await terminal.waitForRender(); + assert.ok(terminal.getWrites().includes("> \x1b[7ma\x1b[27mbc")); + tui.stop(); + }); + + it("omits the focused fake cursor when truncation dropped its end marker", async () => { + const terminal = new LoggingVirtualTerminal(20, 5); + const tui: TUI = new TuiMainScreen(terminal, true); + const base = new TestComponent(); + base.lines = [sliceByColumn(`ab${CURSOR_MARKER}${renderFakeCursor("c")}def`, 0, 3, true)]; + tui.addChild(base); + tui.start(); + await terminal.waitForRender(); + assert.ok(!terminal.getWrites().includes("\x1b[7m")); + assert.ok(terminal.getWrites().endsWith("\x1b[3G")); + tui.stop(); + }); + + it("keeps the cursor when an overlay ends directly left of it", async () => { + for (const showHardwareCursor of [false, true]) { + const terminal = new LoggingVirtualTerminal(20, 5); + const tui: TUI = new TuiMainScreen(terminal, showHardwareCursor); + const base = new TestComponent(); + base.lines = [`abc${CURSOR_MARKER}${renderFakeCursor("d")}ef`]; + tui.addChild(base); + const overlay = new TestComponent(); + overlay.lines = ["XXX"]; + tui.showOverlay(overlay, { row: 0, col: 0, width: 3 }); + tui.start(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(writes.includes("XXX")); + assert.strictEqual(writes.includes("\x1b[7md"), !showHardwareCursor); + // The hardware cursor is positioned at column 4 in both modes + assert.ok(writes.endsWith("\x1b[4G")); + tui.stop(); + } + }); +});