mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-07-09 17:28:45 +00:00
feat(tui): add native cursor mode
This commit is contained in:
parent
542683b29a
commit
3d09963350
19 changed files with 334 additions and 45 deletions
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
33
packages/coding-agent/test/hardware-cursor-setting.test.ts
Normal file
33
packages/coding-agent/test/hardware-cursor-setting.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
180
packages/tui/test/hardware-cursor.test.ts
Normal file
180
packages/tui/test/hardware-cursor.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue