diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index ce37ae2d..d8f5cbd5 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -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()`: diff --git a/packages/coding-agent/examples/extensions/overlay-test.ts b/packages/coding-agent/examples/extensions/overlay-test.ts index 6818dcf9..80aa2017 100644 --- a/packages/coding-agent/examples/extensions/overlay-test.ts +++ b/packages/coding-agent/examples/extensions/overlay-test.ts @@ -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 { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 9d1f183d..e05abeab 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 3182e04c..171174dc 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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 } diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 642f83c1..87c19f08 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -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); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 4ee8de5e..3bf1f76b 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -75,6 +75,9 @@ export { export { type Component, Container, + CURSOR_MARKER, + type Focusable, + isFocusable, type OverlayAnchor, type OverlayHandle, type OverlayMargin, diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 84d0f8f4..0f550761 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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(); + } } diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 16e372a6..aeaece04 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -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; }