diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 20d13deb4..853339262 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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)). diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 18c9ad975..7761fa218 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -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 diff --git a/packages/coding-agent/docs/terminal-setup.md b/packages/coding-agent/docs/terminal-setup.md index ff1638b5e..7786609c8 100644 --- a/packages/coding-agent/docs/terminal-setup.md +++ b/packages/coding-agent/docs/terminal-setup.md @@ -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. diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index c8ff3554e..fbccb7697 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -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 diff --git a/packages/coding-agent/src/cli/config-selector.ts b/packages/coding-agent/src/cli/config-selector.ts index 74265a100..0b5fd2b67 100644 --- a/packages/coding-agent/src/cli/config-selector.ts +++ b/packages/coding-agent/src/cli/config-selector.ts @@ -21,7 +21,7 @@ export async function selectConfig(options: ConfigSelectorOptions): Promise { - const ui = new TUI(new ProcessTerminal()); + const ui = new TUI(new ProcessTerminal({ showHardwareCursor: options.settingsManager.getShowHardwareCursor() })); let resolved = false; const selector = new ConfigSelectorComponent( diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts index 42dcb808d..3f62b8732 100644 --- a/packages/coding-agent/src/cli/session-picker.ts +++ b/packages/coding-agent/src/cli/session-picker.ts @@ -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; +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 { 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; diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts index 93841304c..6c591ad9a 100644 --- a/packages/coding-agent/src/cli/startup-ui.ts +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -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; } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index a90916a43..1da765a97 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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(); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index f66040bba..4afad0ab9 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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")); 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 7cc92614d..bb9acc8ad 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -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)); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 613d588c9..060e86b8d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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); diff --git a/packages/coding-agent/test/hardware-cursor-setting.test.ts b/packages/coding-agent/test/hardware-cursor-setting.test.ts new file mode 100644 index 000000000..683611ab3 --- /dev/null +++ b/packages/coding-agent/test/hardware-cursor-setting.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +function withHardwareCursorEnv(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); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 96ff6d5c8..a62582c35 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/README.md b/packages/tui/README.md index d0aae4005..aaaa7a190 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -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: diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 4e76b1079..c64ba9ff4 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -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, diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 2542286ae..357f27d4e 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -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; } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index a7054888a..a9239cf42 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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(); diff --git a/packages/tui/test/hardware-cursor.test.ts b/packages/tui/test/hardware-cursor.test.ts new file mode 100644 index 000000000..ea87b21fe --- /dev/null +++ b/packages/tui/test/hardware-cursor.test.ts @@ -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 {} + + 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 { + await new Promise((resolve) => process.nextTick(resolve)); + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +function withHardwareCursorEnv(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(); + }); +}); diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 4e067f4e5..b46829545 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -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;