feat(tui): hardware cursor positioning for IME support

- Add Focusable interface for components that need hardware cursor positioning
- Add CURSOR_MARKER (APC escape sequence) for marking cursor position in render output
- Editor and Input components implement Focusable and emit marker when focused
- TUI extracts cursor position from rendered output and positions hardware cursor
- Track hardwareCursorRow separately from cursorRow for differential rendering
- visibleWidth() and extractAnsiCode() now handle APC sequences
- Update overlay-test.ts example to demonstrate Focusable usage
- Add documentation for Focusable interface in docs/tui.md

Closes #719, closes #525
This commit is contained in:
Mario Zechner 2026-01-16 04:30:07 +01:00
parent d9464383ec
commit 07fad1362c
8 changed files with 193 additions and 17 deletions

View file

@ -26,6 +26,32 @@ interface Component {
The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered line. Styles do not carry across lines. If you emit multi-line text with styling, reapply styles per line or use `wrapTextWithAnsi()` so styles are preserved for each wrapped line.
## Focusable Interface (IME Support)
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 "@mariozechner/pi-tui";
class MyInput implements Component, Focusable {
focused: boolean = false; // Set by TUI when focus changes
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}`];
}
}
```
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
This enables IME candidate windows to appear at the correct position for CJK input methods. The `Editor` and `Input` built-in components already implement this interface.
## Using Components
**In hooks** via `ctx.ui.custom()`:

View file

@ -9,7 +9,7 @@
*/
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
import { matchesKey, visibleWidth } from "@mariozechner/pi-tui";
import { CURSOR_MARKER, type Focusable, matchesKey, visibleWidth } from "@mariozechner/pi-tui";
export default function (pi: ExtensionAPI) {
pi.registerCommand("overlay-test", {
@ -28,9 +28,12 @@ export default function (pi: ExtensionAPI) {
});
}
class OverlayTestComponent {
class OverlayTestComponent implements Focusable {
readonly width = 70;
/** Focusable interface - set by TUI when focus changes */
focused = false;
private selected = 0;
private items = [
{ label: "Search", hasInput: true, text: "", cursor: 0 },
@ -123,7 +126,9 @@ class OverlayTestComponent {
const before = inputDisplay.slice(0, item.cursor);
const cursorChar = item.cursor < inputDisplay.length ? inputDisplay[item.cursor] : " ";
const after = inputDisplay.slice(item.cursor + 1);
inputDisplay = `${before}\x1b[7m${cursorChar}\x1b[27m${after}`;
// Emit hardware cursor marker for IME support when focused
const marker = this.focused ? CURSOR_MARKER : "";
inputDisplay = `${before}${marker}\x1b[7m${cursorChar}\x1b[27m${after}`;
}
content = `${prefix + label} ${inputDisplay}`;
} else {

View file

@ -8,11 +8,15 @@
### Added
- Hardware cursor positioning for IME support in `Editor` and `Input` components. The terminal cursor now follows the text cursor position, enabling proper IME candidate window placement for CJK input. ([#719](https://github.com/badlogic/pi-mono/pull/719))
- `Focusable` interface for components that need hardware cursor positioning. Implement `focused: boolean` and emit `CURSOR_MARKER` in render output when focused.
- `CURSOR_MARKER` constant and `isFocusable()` type guard exported from the package
- Editor now supports Page Up/Down keys (Fn+Up/Down on MacBook) for scrolling through large content ([#732](https://github.com/badlogic/pi-mono/issues/732))
### Fixed
- Editor no longer corrupts terminal display when text exceeds screen height. Content now scrolls vertically with indicators showing lines above/below the viewport. Max height is 30% of terminal (minimum 5 lines). ([#732](https://github.com/badlogic/pi-mono/issues/732))
- `visibleWidth()` and `extractAnsiCode()` now handle APC escape sequences (`ESC _ ... BEL`), fixing width calculation and string slicing for strings containing cursor markers
## [0.46.0] - 2026-01-15

View file

@ -1,7 +1,7 @@
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
import { getEditorKeybindings } from "../keybindings.js";
import { matchesKey } from "../keys.js";
import type { Component, TUI } from "../tui.js";
import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.js";
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
import { SelectList, type SelectListTheme } from "./select-list.js";
@ -204,13 +204,16 @@ export interface EditorTheme {
selectList: SelectListTheme;
}
export class Editor implements Component {
export class Editor implements Component, Focusable {
private state: EditorState = {
lines: [""],
cursorLine: 0,
cursorCol: 0,
};
/** Focusable interface - set by TUI when focus changes */
focused: boolean = false;
protected tui: TUI;
private theme: EditorTheme;
@ -365,6 +368,9 @@ export class Editor implements Component {
}
// Render each visible layout line
// Emit hardware cursor marker only when focused and not showing autocomplete
const emitCursorMarker = this.focused && !this.isAutocompleting;
for (const layoutLine of visibleLines) {
let displayText = layoutLine.text;
let lineVisibleWidth = visibleWidth(layoutLine.text);
@ -374,6 +380,9 @@ export class Editor implements Component {
const before = displayText.slice(0, layoutLine.cursorPos);
const after = displayText.slice(layoutLine.cursorPos);
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
const marker = emitCursorMarker ? CURSOR_MARKER : "";
if (after.length > 0) {
// Cursor is on a character (grapheme) - replace it with highlighted version
// Get the first grapheme from 'after'
@ -381,14 +390,14 @@ export class Editor implements Component {
const firstGrapheme = afterGraphemes[0]?.segment || "";
const restAfter = after.slice(firstGrapheme.length);
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
displayText = before + cursor + restAfter;
displayText = before + marker + cursor + restAfter;
// lineVisibleWidth stays the same - we're replacing, not adding
} else {
// Cursor is at the end - check if we have room for the space
if (lineVisibleWidth < width) {
// We have room - add highlighted space
const cursor = "\x1b[7m \x1b[0m";
displayText = before + cursor;
displayText = before + marker + cursor;
// lineVisibleWidth increases by 1 - we're adding a space
lineVisibleWidth = lineVisibleWidth + 1;
} else {
@ -403,7 +412,7 @@ export class Editor implements Component {
.slice(0, -1)
.map((g) => g.segment)
.join("");
displayText = beforeWithoutLast + cursor;
displayText = beforeWithoutLast + marker + cursor;
}
// lineVisibleWidth stays the same
}

View file

@ -1,5 +1,5 @@
import { getEditorKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js";
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js";
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
const segmenter = getSegmenter();
@ -7,12 +7,15 @@ const segmenter = getSegmenter();
/**
* Input component - single-line text input with horizontal scrolling
*/
export class Input implements Component {
export class Input implements Component, Focusable {
private value: string = "";
private cursor: number = 0; // Cursor position in the value
public onSubmit?: (value: string) => void;
public onEscape?: () => void;
/** Focusable interface - set by TUI when focus changes */
focused: boolean = false;
// Bracketed paste mode buffering
private pasteBuffer: string = "";
private isInPaste: boolean = false;
@ -325,9 +328,12 @@ export class Input implements Component {
const atCursor = visibleText[cursorDisplay] || " "; // Character at cursor, or space if at end
const afterCursor = visibleText.slice(cursorDisplay + 1);
// 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
const textWithCursor = beforeCursor + cursorChar + afterCursor;
const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;
// Calculate visual width
const visualLength = visibleWidth(textWithCursor);

View file

@ -75,6 +75,9 @@ export {
export {
type Component,
Container,
CURSOR_MARKER,
type Focusable,
isFocusable,
type OverlayAnchor,
type OverlayHandle,
type OverlayMargin,

View file

@ -39,6 +39,30 @@ export interface Component {
invalidate(): void;
}
/**
* Interface for components that can receive focus and display a hardware cursor.
* When focused, the component should emit CURSOR_MARKER at the cursor position
* in its render output. TUI will find this marker and position the hardware
* cursor there for proper IME candidate window positioning.
*/
export interface Focusable {
/** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */
focused: boolean;
}
/** Type guard to check if a component implements Focusable */
export function isFocusable(component: Component | null): component is Component & Focusable {
return component !== null && "focused" in component;
}
/**
* Cursor position marker - APC (Application Program Command) sequence.
* This is a zero-width escape sequence that terminals ignore.
* Components emit this at the cursor position when focused.
* TUI finds and strips this marker, then positions the hardware cursor there.
*/
export const CURSOR_MARKER = "\x1b_pi:c\x07";
export { visibleWidth };
/**
@ -180,7 +204,8 @@ export class TUI extends Container {
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
public onDebug?: () => void;
private renderRequested = false;
private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)
private cursorRow = 0; // Logical cursor row (end of rendered content)
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
private inputBuffer = ""; // Buffer for parsing terminal responses
private cellSizeQueryPending = false;
@ -198,7 +223,17 @@ export class TUI extends Container {
}
setFocus(component: Component | null): void {
// Clear focused flag on old component
if (isFocusable(this.focusedComponent)) {
this.focusedComponent.focused = false;
}
this.focusedComponent = component;
// Set focused flag on new component
if (isFocusable(component)) {
component.focused = true;
}
}
/**
@ -317,7 +352,7 @@ export class TUI extends Container {
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
if (this.previousLines.length > 0) {
const targetRow = this.previousLines.length; // Line after the last content
const lineDiff = targetRow - this.cursorRow;
const lineDiff = targetRow - this.hardwareCursorRow;
if (lineDiff > 0) {
this.terminal.write(`\x1b[${lineDiff}B`);
} else if (lineDiff < 0) {
@ -335,6 +370,7 @@ export class TUI extends Container {
this.previousLines = [];
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
this.cursorRow = 0;
this.hardwareCursorRow = 0;
}
if (this.renderRequested) return;
this.renderRequested = true;
@ -700,6 +736,29 @@ export class TUI extends Container {
return sliceByColumn(result, 0, totalWidth, true);
}
/**
* Find and extract cursor position from rendered lines.
* Searches for CURSOR_MARKER, calculates its position, and strips it from the output.
* @returns Cursor position { row, col } or null if no marker found
*/
private extractCursorPosition(lines: string[]): { row: number; col: number } | null {
for (let row = 0; row < lines.length; row++) {
const line = lines[row];
const markerIndex = line.indexOf(CURSOR_MARKER);
if (markerIndex !== -1) {
// Calculate visual column (width of text before marker)
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);
return { row, col };
}
}
return null;
}
private doRender(): void {
const width = this.terminal.columns;
const height = this.terminal.rows;
@ -712,6 +771,9 @@ export class TUI extends Container {
newLines = this.compositeOverlays(newLines, width, height);
}
// Extract cursor position before applying line resets (marker must be found first)
const cursorPos = this.extractCursorPosition(newLines);
newLines = this.applyLineResets(newLines);
// Width changed - need full re-render
@ -728,6 +790,8 @@ export class TUI extends Container {
this.terminal.write(buffer);
// After rendering N lines, cursor is at end of last line (clamp to 0 for empty)
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = this.cursorRow;
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
return;
@ -744,6 +808,8 @@ export class TUI extends Container {
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = this.cursorRow;
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
return;
@ -765,8 +831,9 @@ export class TUI extends Container {
}
}
// No changes
// No changes - but still need to update hardware cursor position if it moved
if (firstChanged === -1) {
this.positionHardwareCursor(cursorPos, newLines.length);
return;
}
@ -776,7 +843,7 @@ export class TUI extends Container {
let buffer = "\x1b[?2026h";
// Move to end of new content (clamp to 0 for empty content)
const targetRow = Math.max(0, newLines.length - 1);
const lineDiff = targetRow - this.cursorRow;
const lineDiff = targetRow - this.hardwareCursorRow;
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
buffer += "\r";
@ -789,7 +856,9 @@ export class TUI extends Container {
buffer += "\x1b[?2026l";
this.terminal.write(buffer);
this.cursorRow = targetRow;
this.hardwareCursorRow = targetRow;
}
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
return;
@ -811,6 +880,8 @@ export class TUI extends Container {
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = this.cursorRow;
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
return;
@ -820,8 +891,8 @@ export class TUI extends Container {
// Build buffer with all updates wrapped in synchronized output
let buffer = "\x1b[?2026h"; // Begin synchronized output
// Move cursor to first changed line
const lineDiff = firstChanged - this.cursorRow;
// Move cursor to first changed line (use hardwareCursorRow for actual position)
const lineDiff = firstChanged - this.hardwareCursorRow;
if (lineDiff > 0) {
buffer += `\x1b[${lineDiff}B`; // Move down
} else if (lineDiff < 0) {
@ -895,8 +966,46 @@ export class TUI extends Container {
// Track cursor position for next render
this.cursorRow = finalCursorRow;
this.hardwareCursorRow = finalCursorRow;
// Position hardware cursor for IME
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousWidth = width;
}
/**
* Position the hardware cursor for IME candidate window.
* @param cursorPos The cursor position extracted from rendered output, or null
* @param totalLines Total number of rendered lines
*/
private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void {
if (!cursorPos || totalLines <= 0) {
this.terminal.hideCursor();
return;
}
// Clamp cursor position to valid range
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
const targetCol = Math.max(0, cursorPos.col);
// Move cursor from current position to target
const rowDelta = targetRow - this.hardwareCursorRow;
let buffer = "";
if (rowDelta > 0) {
buffer += `\x1b[${rowDelta}B`; // Move down
} else if (rowDelta < 0) {
buffer += `\x1b[${-rowDelta}A`; // Move up
}
// Move to absolute column (1-indexed)
buffer += `\x1b[${targetCol + 1}G`;
if (buffer) {
this.terminal.write(buffer);
}
this.hardwareCursorRow = targetRow;
this.terminal.showCursor();
}
}

View file

@ -112,6 +112,8 @@ export function visibleWidth(str: string): number {
clean = clean.replace(/\x1b\[[0-9;]*[mGKHJ]/g, "");
// Strip OSC 8 hyperlinks: \x1b]8;;URL\x07 and \x1b]8;;\x07
clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, "");
// Strip APC sequences: \x1b_...\x07 or \x1b_...\x1b\\ (used for cursor marker)
clean = clean.replace(/\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
}
// Calculate width
@ -160,6 +162,18 @@ export function extractAnsiCode(str: string, pos: number): { code: string; lengt
return null;
}
// APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \)
// Used for cursor marker and application-specific commands
if (next === "_") {
let j = pos + 2;
while (j < str.length) {
if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos };
if (str[j] === "\x1b" && str[j + 1] === "\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos };
j++;
}
return null;
}
return null;
}