feat(tui,coding-agent): use only the terminal cursor when showHardwareCursor is enabled

Editor and Input wrap their drawn cursor in fake cursor markers. The TUI
resolves them to reverse video, or drops the focused one when the hardware
cursor is shown, so terminals that extend edge cell colors into the window
padding no longer stretch the drawn cursor at the first or last column.

Also keep CURSOR_MARKER when an overlay ends directly left of the cursor.
This commit is contained in:
Armin Ronacher 2026-10-08 00:22:25 +02:00
parent 75a99721d1
commit 1cedd32724
17 changed files with 183 additions and 28 deletions

View file

@ -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

View file

@ -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. |

View file

@ -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.

View file

@ -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 {

View file

@ -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"],
});

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -168,6 +168,7 @@ export {
type OverlayMargin,
type OverlayOptions,
type OverlayUnfocusOptions,
renderFakeCursor,
type SizeValue,
type TUI,
type TuiInputListener,

View file

@ -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);

View file

@ -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) {

View file

@ -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

View file

@ -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;

View file

@ -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

View file

@ -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[] = [];

View file

@ -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();
}
});
});