mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 06:04:40 +00:00
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:
parent
d9464383ec
commit
07fad1362c
8 changed files with 193 additions and 17 deletions
|
|
@ -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()`:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ export {
|
|||
export {
|
||||
type Component,
|
||||
Container,
|
||||
CURSOR_MARKER,
|
||||
type Focusable,
|
||||
isFocusable,
|
||||
type OverlayAnchor,
|
||||
type OverlayHandle,
|
||||
type OverlayMargin,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue