feat(tui): add native cursor mode

This commit is contained in:
Armin Ronacher 2026-06-21 23:51:20 +02:00
parent 542683b29a
commit 3d09963350
19 changed files with 334 additions and 45 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added native hardware cursor mode via `showHardwareCursor: "native"` or `PI_HARDWARE_CURSOR=native`, which shows the terminal cursor and strips pi's software cursor.
### Fixed
- Fixed the plan-mode example to preserve active custom tools, skip the action prompt when no plan is found, and queue refinement/execution follow-ups correctly from `agent_end` ([#5940](https://github.com/earendil-works/pi/issues/5940)).

View file

@ -61,7 +61,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
| `treeFilterMode` | string | `"default"` | Default filter for `/tree`: `"default"`, `"no-tools"`, `"user-only"`, `"labeled-only"`, `"all"` |
| `editorPaddingX` | number | `0` | Horizontal padding for input editor (0-3) |
| `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) |
| `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support |
| `showHardwareCursor` | boolean or `"native"` | `false` | Cursor mode: `false` keeps the software cursor only, `true` also shows the terminal cursor, `"native"` strips the software cursor and uses only the terminal cursor |
### Telemetry and update checks

View file

@ -66,7 +66,7 @@ return config
If you already have a `config.keys` table, add the entry to it.
On WSL, WezTerm may require a visible hardware cursor for IME candidate window positioning. If CJK IME candidates do not follow the text cursor, set `PI_HARDWARE_CURSOR=1` before running pi or set `showHardwareCursor` to `true` in settings.
On WSL, WezTerm may require a visible hardware cursor for IME candidate window positioning. If CJK IME candidates do not follow the text cursor, set `PI_HARDWARE_CURSOR=1` before running pi or set `showHardwareCursor` to `true` in settings. Use `PI_HARDWARE_CURSOR=native` or `showHardwareCursor: "native"` to use only the terminal cursor and strip pi's software cursor.
## Alacritty
@ -143,6 +143,6 @@ For the best experience, use a terminal that supports the Kitty keyboard protoco
The built-in terminal has limited escape sequence support. Shift+Enter cannot be distinguished from Enter in IntelliJ's terminal.
If you want the hardware cursor visible, set `PI_HARDWARE_CURSOR=1` before running pi (disabled by default for compatibility).
If you want the hardware cursor visible, set `PI_HARDWARE_CURSOR=1` before running pi (disabled by default for compatibility). Use `PI_HARDWARE_CURSOR=native` to use only the terminal cursor and strip pi's software cursor.
Consider using a dedicated terminal emulator for the best experience.

View file

@ -50,9 +50,9 @@ When a `Focusable` component has focus, TUI:
1. Sets `focused = true` on the component
2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence)
3. Positions the hardware terminal cursor at that location
4. Shows the hardware cursor only when `showHardwareCursor` is enabled
4. Shows the hardware cursor when `showHardwareCursor` is `true` or `"native"`
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 `showHardwareCursor`, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface.
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 `showHardwareCursor: true`, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. To use the terminal cursor only, set `showHardwareCursor: "native"`, call `setShowHardwareCursor("native")`, or set `PI_HARDWARE_CURSOR=native`; in that mode TUI strips the marker-adjacent reverse-video software cursor. The `Editor` and `Input` built-in components already implement this interface.
### Container Components with Embedded Inputs

View file

@ -21,7 +21,7 @@ export async function selectConfig(options: ConfigSelectorOptions): Promise<void
initTheme(options.settingsManager.getTheme(), true);
return new Promise((resolve) => {
const ui = new TUI(new ProcessTerminal());
const ui = new TUI(new ProcessTerminal({ showHardwareCursor: options.settingsManager.getShowHardwareCursor() }));
let resolved = false;
const selector = new ConfigSelectorComponent(

View file

@ -2,20 +2,29 @@
* TUI session selector for --resume flag
*/
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
import { ProcessTerminal, type ProcessTerminalOptions, setKeybindings, TUI } from "@earendil-works/pi-tui";
import { KeybindingsManager } from "../core/keybindings.ts";
import type { SessionInfo, SessionListProgress } from "../core/session-manager.ts";
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.ts";
type SessionsLoader = (onProgress?: SessionListProgress) => Promise<SessionInfo[]>;
export interface SessionPickerTuiOptions extends ProcessTerminalOptions {
clearOnShrink?: boolean;
}
/** Show TUI session selector and return selected session path or null if cancelled */
export async function selectSession(
currentSessionsLoader: SessionsLoader,
allSessionsLoader: SessionsLoader,
tuiOptions: SessionPickerTuiOptions = {},
): Promise<string | null> {
return new Promise((resolve) => {
const ui = new TUI(new ProcessTerminal());
const { clearOnShrink, ...terminalOptions } = tuiOptions;
const ui = new TUI(new ProcessTerminal(terminalOptions));
if (clearOnShrink !== undefined) {
ui.setClearOnShrink(clearOnShrink);
}
const keybindings = KeybindingsManager.create();
setKeybindings(keybindings);
let resolved = false;

View file

@ -33,7 +33,7 @@ function isOfficialDistribution({ packageName, appName, configDirName }: Distrib
function createStartupTui(settingsManager: SettingsManager): TUI {
initTheme(settingsManager.getTheme());
setKeybindings(KeybindingsManager.create());
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
const ui = new TUI(new ProcessTerminal({ showHardwareCursor: settingsManager.getShowHardwareCursor() }));
ui.setClearOnShrink(settingsManager.getClearOnShrink());
return ui;
}

View file

@ -1,4 +1,5 @@
import type { Transport } from "@earendil-works/pi-ai";
import type { HardwareCursorSetting } from "@earendil-works/pi-tui";
import { randomUUID } from "crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
@ -113,7 +114,7 @@ export interface Settings {
thinkingBudgets?: ThinkingBudgetsSettings; // Custom token budgets for thinking levels
editorPaddingX?: number; // Horizontal padding for input editor (default: 0)
autocompleteMaxVisible?: number; // Max visible items in autocomplete dropdown (default: 5)
showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME
showHardwareCursor?: HardwareCursorSetting; // Show terminal cursor; "native" hides the software cursor
markdown?: MarkdownSettings;
warnings?: WarningSettings;
sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag)
@ -1149,12 +1150,16 @@ export class SettingsManager {
this.save();
}
getShowHardwareCursor(): boolean {
return this.settings.showHardwareCursor ?? process.env.PI_HARDWARE_CURSOR === "1";
getShowHardwareCursor(): HardwareCursorSetting {
const setting = this.settings.showHardwareCursor;
if (setting === true || setting === false || setting === "native") return setting;
const env = process.env.PI_HARDWARE_CURSOR;
if (env === "native") return "native";
return env === "1" || env === "true";
}
setShowHardwareCursor(enabled: boolean): void {
this.globalSettings.showHardwareCursor = enabled;
setShowHardwareCursor(setting: HardwareCursorSetting): void {
this.globalSettings.showHardwareCursor = setting;
this.markModified("showHardwareCursor");
this.save();
}

View file

@ -313,6 +313,10 @@ async function createSessionManager(
const selectedPath = await selectSession(
(onProgress) => SessionManager.list(cwd, sessionDir, onProgress),
(onProgress) => SessionManager.listAll(sessionDir, onProgress),
{
showHardwareCursor: settingsManager.getShowHardwareCursor(),
clearOnShrink: settingsManager.getClearOnShrink(),
},
);
if (!selectedPath) {
console.log(chalk.dim("No session selected"));

View file

@ -4,6 +4,7 @@ import {
type Component,
Container,
getCapabilities,
type HardwareCursorSetting,
type SelectItem,
SelectList,
type SelectListLayoutOptions,
@ -69,7 +70,7 @@ export interface SettingsConfig {
enableInstallTelemetry: boolean;
doubleEscapeAction: "fork" | "tree" | "none";
treeFilterMode: "default" | "no-tools" | "user-only" | "labeled-only" | "all";
showHardwareCursor: boolean;
showHardwareCursor: HardwareCursorSetting;
editorPaddingX: number;
autocompleteMaxVisible: number;
quietStartup: boolean;
@ -98,7 +99,7 @@ export interface SettingsCallbacks {
onEnableInstallTelemetryChange: (enabled: boolean) => void;
onDoubleEscapeActionChange: (action: "fork" | "tree" | "none") => void;
onTreeFilterModeChange: (mode: "default" | "no-tools" | "user-only" | "labeled-only" | "all") => void;
onShowHardwareCursorChange: (enabled: boolean) => void;
onShowHardwareCursorChange: (setting: HardwareCursorSetting) => void;
onEditorPaddingXChange: (padding: number) => void;
onAutocompleteMaxVisibleChange: (maxVisible: number) => void;
onQuietStartupChange: (enabled: boolean) => void;
@ -656,14 +657,14 @@ export class SettingsSelectorComponent extends Container {
values: ["true", "false"],
});
// Hardware cursor toggle (insert after skill-commands)
// Hardware cursor mode (insert after skill-commands)
const skillCommandsIndex = items.findIndex((item) => item.id === "skill-commands");
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",
currentValue: config.showHardwareCursor ? "true" : "false",
values: ["true", "false"],
label: "Cursor mode",
description: "false: software cursor only; true: show hardware cursor too; native: hardware cursor only",
currentValue: String(config.showHardwareCursor),
values: ["false", "true", "native"],
});
// Editor padding toggle (insert after show-hardware-cursor)
@ -777,7 +778,7 @@ export class SettingsSelectorComponent extends Container {
);
break;
case "show-hardware-cursor":
callbacks.onShowHardwareCursorChange(newValue === "true");
callbacks.onShowHardwareCursorChange(newValue === "native" ? "native" : newValue === "true");
break;
case "editor-padding":
callbacks.onEditorPaddingXChange(parseInt(newValue, 10));

View file

@ -398,7 +398,7 @@ export class InteractiveMode {
await this.rebindCurrentSession();
});
this.version = VERSION;
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
this.ui = new TUI(new ProcessTerminal({ showHardwareCursor: this.settingsManager.getShowHardwareCursor() }));
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
this.headerContainer = new Container();
this.chatContainer = new Container();
@ -4075,9 +4075,9 @@ export class InteractiveMode {
onTreeFilterModeChange: (mode) => {
this.settingsManager.setTreeFilterMode(mode);
},
onShowHardwareCursorChange: (enabled) => {
this.settingsManager.setShowHardwareCursor(enabled);
this.ui.setShowHardwareCursor(enabled);
onShowHardwareCursorChange: (setting) => {
this.settingsManager.setShowHardwareCursor(setting);
this.ui.setShowHardwareCursor(setting);
},
onEditorPaddingXChange: (padding) => {
this.settingsManager.setEditorPaddingX(padding);

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { SettingsManager } from "../src/core/settings-manager.ts";
function withHardwareCursorEnv<T>(value: string | undefined, fn: () => T): T {
const previous = process.env.PI_HARDWARE_CURSOR;
if (value === undefined) delete process.env.PI_HARDWARE_CURSOR;
else process.env.PI_HARDWARE_CURSOR = value;
try {
return fn();
} finally {
if (previous === undefined) delete process.env.PI_HARDWARE_CURSOR;
else process.env.PI_HARDWARE_CURSOR = previous;
}
}
describe("hardware cursor setting", () => {
it("supports native mode from settings", () => {
const manager = SettingsManager.inMemory({ showHardwareCursor: "native" });
expect(manager.getShowHardwareCursor()).toBe("native");
});
it("supports native mode from PI_HARDWARE_CURSOR", () => {
withHardwareCursorEnv("native", () => {
const manager = SettingsManager.inMemory();
expect(manager.getShowHardwareCursor()).toBe("native");
});
});
it("preserves existing boolean settings", () => {
expect(SettingsManager.inMemory({ showHardwareCursor: true }).getShowHardwareCursor()).toBe(true);
expect(SettingsManager.inMemory({ showHardwareCursor: false }).getShowHardwareCursor()).toBe(false);
});
});

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added native hardware cursor mode via `PI_HARDWARE_CURSOR=native`, `new ProcessTerminal({ showHardwareCursor: "native" })`, `terminal.showHardwareCursor = "native"`, or `setShowHardwareCursor("native")`, which shows the terminal cursor and strips the marker-adjacent software cursor.
## [0.79.9] - 2026-06-20
### Fixed

View file

@ -188,9 +188,9 @@ When a `Focusable` component has focus, TUI:
1. Sets `focused = true` on the component
2. Scans rendered output for `CURSOR_MARKER` (a zero-width APC escape sequence)
3. Positions the hardware terminal cursor at that location
4. Shows the hardware cursor only when `showHardwareCursor` is enabled
4. Shows the hardware cursor when `showHardwareCursor` is `true` or `"native"`
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 `TUI` constructor option, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface.
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 `new ProcessTerminal({ showHardwareCursor: true })`, `terminal.showHardwareCursor = true`, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. To use the terminal cursor only, use `new ProcessTerminal({ showHardwareCursor: "native" })`, set `terminal.showHardwareCursor = "native"`, call `setShowHardwareCursor("native")`, or set `PI_HARDWARE_CURSOR=native`; in that mode TUI strips the marker-adjacent reverse-video software cursor. 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

@ -60,7 +60,7 @@ export {
// Input buffering for batch splitting
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts";
// Terminal interface and implementations
export { ProcessTerminal, type Terminal } from "./terminal.ts";
export { type HardwareCursorSetting, ProcessTerminal, type ProcessTerminalOptions, type Terminal } from "./terminal.ts";
// Terminal colors
export {
parseOsc11BackgroundColor,

View file

@ -16,6 +16,18 @@ const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7;
const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150;
const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`;
/** Controls hardware cursor visibility and software cursor rendering. */
export type HardwareCursorSetting = boolean | "native";
export interface ProcessTerminalOptions {
showHardwareCursor?: HardwareCursorSetting;
}
function parseHardwareCursorSetting(value: string | undefined): HardwareCursorSetting {
if (value === "native") return "native";
return value === "1" || value === "true";
}
export type KeyboardProtocolNegotiationSequence =
| { type: "kitty-flags"; flags: number }
| { type: "device-attributes" };
@ -50,6 +62,9 @@ export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boole
* Minimal terminal interface for TUI
*/
export interface Terminal {
/** Hardware cursor mode. `"native"` also strips pi's software cursor. */
showHardwareCursor?: HardwareCursorSetting;
// Start the terminal with input and resize handlers
start(onInput: (data: string) => void, onResize: () => void): void;
@ -97,6 +112,8 @@ export interface Terminal {
* Real terminal using process.stdin/stdout
*/
export class ProcessTerminal implements Terminal {
showHardwareCursor: HardwareCursorSetting;
private wasRaw = false;
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;
@ -123,6 +140,11 @@ export class ProcessTerminal implements Terminal {
return env;
})();
constructor(options: ProcessTerminalOptions = {}) {
this.showHardwareCursor =
options.showHardwareCursor ?? parseHardwareCursorSetting(process.env.PI_HARDWARE_CURSOR);
}
get kittyProtocolActive(): boolean {
return this._kittyProtocolActive;
}

View file

@ -7,7 +7,7 @@ import * as os from "node:os";
import * as path from "node:path";
import { performance } from "node:perf_hooks";
import { isKeyRelease, matchesKey } from "./keys.ts";
import type { Terminal } from "./terminal.ts";
import type { HardwareCursorSetting, Terminal } from "./terminal.ts";
import {
isOsc11BackgroundColorResponse,
parseOsc11BackgroundColor,
@ -119,6 +119,33 @@ export function isFocusable(component: Component | null): component is Component
*/
export const CURSOR_MARKER = "\x1b_pi:c\x07";
const REVERSE_VIDEO_ON = "\x1b[7m";
const SGR_RESET = "\x1b[0m";
const CURSOR_CELL_RESET = /\x1b\[(?:27|0)m/;
export function stripCursorMarker(line: string, stripSoftwareCursor: boolean): string {
const markerIndex = line.indexOf(CURSOR_MARKER);
if (markerIndex === -1) return line;
const beforeMarker = line.slice(0, markerIndex);
let afterMarker = line.slice(markerIndex + CURSOR_MARKER.length);
if (stripSoftwareCursor && afterMarker.startsWith(REVERSE_VIDEO_ON)) {
const afterReverseOn = afterMarker.slice(REVERSE_VIDEO_ON.length);
const reset = CURSOR_CELL_RESET.exec(afterReverseOn);
if (reset) {
const cursorCell = afterReverseOn.slice(0, reset.index);
const afterReset = afterReverseOn.slice(reset.index + reset[0].length);
const preservedReset = reset[0] === SGR_RESET ? SGR_RESET : "";
afterMarker = cursorCell + preservedReset + afterReset;
} else {
afterMarker = afterReverseOn;
}
}
return beforeMarker + afterMarker;
}
export { visibleWidth };
/**
@ -309,7 +336,6 @@ export class TUI extends Container {
private static readonly MIN_RENDER_INTERVAL_MS = 16;
private cursorRow = 0; // Logical cursor row (end of rendered content)
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
@ -325,26 +351,23 @@ export class TUI extends Container {
private overlayStack: OverlayStackEntry[] = [];
private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" };
constructor(terminal: Terminal, showHardwareCursor?: boolean) {
constructor(terminal: Terminal) {
super();
this.terminal = terminal;
if (showHardwareCursor !== undefined) {
this.showHardwareCursor = showHardwareCursor;
}
}
get fullRedraws(): number {
return this.fullRedrawCount;
}
getShowHardwareCursor(): boolean {
return this.showHardwareCursor;
getShowHardwareCursor(): HardwareCursorSetting {
return this.terminal.showHardwareCursor ?? false;
}
setShowHardwareCursor(enabled: boolean): void {
if (this.showHardwareCursor === enabled) return;
this.showHardwareCursor = enabled;
if (!enabled) {
setShowHardwareCursor(setting: HardwareCursorSetting): void {
if (this.getShowHardwareCursor() === setting) return;
this.terminal.showHardwareCursor = setting;
if (setting === false) {
this.terminal.hideCursor();
}
this.requestRender();
@ -1242,8 +1265,10 @@ export class TUI extends Container {
const beforeMarker = line.slice(0, markerIndex);
const col = visibleWidth(beforeMarker);
// Strip marker from the line
lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
// Strip marker from the line. In native cursor mode, also strip the
// marker-adjacent reverse-video software cursor so only the terminal
// cursor remains visible.
lines[row] = stripCursorMarker(line, this.getShowHardwareCursor() === "native");
return { row, col };
}
@ -1650,7 +1675,7 @@ export class TUI extends Container {
}
this.hardwareCursorRow = targetRow;
if (this.showHardwareCursor) {
if (this.getShowHardwareCursor() !== false) {
this.terminal.showCursor();
} else {
this.terminal.hideCursor();

View file

@ -0,0 +1,180 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { Editor } from "../src/components/editor.ts";
import { type HardwareCursorSetting, ProcessTerminal, type Terminal } from "../src/terminal.ts";
import { CURSOR_MARKER, stripCursorMarker, TUI } from "../src/tui.ts";
import { defaultEditorTheme } from "./test-themes.ts";
const REVERSE_VIDEO_ON = "\x1b[7m";
const REVERSE_VIDEO_OFF = "\x1b[27m";
const SGR_RESET = "\x1b[0m";
class RecordingTerminal implements Terminal {
showHardwareCursor: HardwareCursorSetting = false;
private writes: string[] = [];
private readonly width: number;
private readonly height: number;
constructor(width = 40, height = 6) {
this.width = width;
this.height = height;
}
start(_onInput: (data: string) => void, _onResize: () => void): void {}
stop(): void {}
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {}
write(data: string): void {
this.writes.push(data);
}
get columns(): number {
return this.width;
}
get rows(): number {
return this.height;
}
get kittyProtocolActive(): boolean {
return true;
}
moveBy(lines: number): void {
if (lines > 0) this.write(`\x1b[${lines}B`);
else if (lines < 0) this.write(`\x1b[${-lines}A`);
}
hideCursor(): void {
this.write("\x1b[?25l");
}
showCursor(): void {
this.write("\x1b[?25h");
}
clearLine(): void {
this.write("\x1b[K");
}
clearFromCursor(): void {
this.write("\x1b[J");
}
clearScreen(): void {
this.write("\x1b[2J\x1b[H");
}
setTitle(title: string): void {
this.write(`\x1b]0;${title}\x07`);
}
setProgress(_active: boolean): void {}
allWrites(): string {
return this.writes.join("");
}
async waitForRender(): Promise<void> {
await new Promise<void>((resolve) => process.nextTick(resolve));
await new Promise<void>((resolve) => setTimeout(resolve, 25));
}
}
function withHardwareCursorEnv<T>(value: string | undefined, fn: () => T): T {
const previous = process.env.PI_HARDWARE_CURSOR;
if (value === undefined) delete process.env.PI_HARDWARE_CURSOR;
else process.env.PI_HARDWARE_CURSOR = value;
try {
return fn();
} finally {
if (previous === undefined) delete process.env.PI_HARDWARE_CURSOR;
else process.env.PI_HARDWARE_CURSOR = previous;
}
}
describe("hardware cursor setting", () => {
it("parses PI_HARDWARE_CURSOR=native", () => {
withHardwareCursorEnv("native", () => {
const terminal = new ProcessTerminal();
assert.strictEqual(terminal.showHardwareCursor, "native");
assert.strictEqual(new TUI(terminal).getShowHardwareCursor(), "native");
});
});
it("keeps existing boolean env semantics", () => {
withHardwareCursorEnv(undefined, () => {
assert.strictEqual(new ProcessTerminal().showHardwareCursor, false);
});
withHardwareCursorEnv("1", () => {
assert.strictEqual(new ProcessTerminal().showHardwareCursor, true);
});
});
it("accepts process terminal options", () => {
assert.strictEqual(new ProcessTerminal({ showHardwareCursor: "native" }).showHardwareCursor, "native");
});
it("stores cursor mode on the terminal", () => {
const terminal = new RecordingTerminal();
const tui = new TUI(terminal);
tui.setShowHardwareCursor("native");
assert.strictEqual(terminal.showHardwareCursor, "native");
assert.strictEqual(tui.getShowHardwareCursor(), "native");
});
});
describe("stripCursorMarker", () => {
it("keeps the software cursor outside native mode", () => {
const line = `ab${CURSOR_MARKER}${REVERSE_VIDEO_ON}c${REVERSE_VIDEO_OFF}de`;
assert.strictEqual(stripCursorMarker(line, false), `ab${REVERSE_VIDEO_ON}c${REVERSE_VIDEO_OFF}de`);
});
it("strips marker-adjacent reverse video in native mode", () => {
const line = `ab${CURSOR_MARKER}${REVERSE_VIDEO_ON}c${REVERSE_VIDEO_OFF}de`;
assert.strictEqual(stripCursorMarker(line, true), "abcde");
});
it("handles legacy full SGR reset cursor cells", () => {
const line = `ab${CURSOR_MARKER}${REVERSE_VIDEO_ON}c${SGR_RESET}de`;
assert.strictEqual(stripCursorMarker(line, true), `abc${SGR_RESET}de`);
});
});
describe("native cursor rendering", () => {
it("strips the software cursor and shows the hardware cursor in native mode", async () => {
const terminal = new RecordingTerminal();
terminal.showHardwareCursor = "native";
const tui = new TUI(terminal);
const editor = new Editor(tui, defaultEditorTheme);
editor.setText("abc");
tui.addChild(editor);
tui.setFocus(editor);
tui.start();
await terminal.waitForRender();
const output = terminal.allWrites();
assert.ok(!output.includes(REVERSE_VIDEO_ON), "native mode should not paint a software cursor");
assert.ok(output.includes("\x1b[?25h"), "native mode should show the hardware cursor");
tui.stop();
});
it("keeps the software cursor when hardware cursor visibility is true", async () => {
const terminal = new RecordingTerminal();
terminal.showHardwareCursor = true;
const tui = new TUI(terminal);
const editor = new Editor(tui, defaultEditorTheme);
editor.setText("abc");
tui.addChild(editor);
tui.setFocus(editor);
tui.start();
await terminal.waitForRender();
assert.ok(terminal.allWrites().includes(REVERSE_VIDEO_ON), "true mode should keep the software cursor");
tui.stop();
});
});

View file

@ -1,6 +1,6 @@
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import xterm from "@xterm/headless";
import type { Terminal } from "../src/terminal.ts";
import type { HardwareCursorSetting, Terminal } from "../src/terminal.ts";
// Extract Terminal class from the module
const XtermTerminal = xterm.Terminal;
@ -9,6 +9,8 @@ const XtermTerminal = xterm.Terminal;
* Virtual terminal for testing using xterm.js for accurate terminal emulation
*/
export class VirtualTerminal implements Terminal {
showHardwareCursor: HardwareCursorSetting = false;
private xterm: XtermTerminalType;
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;