getCursorPosition(width: number): { row: number; col: number } | null { let rowOffset = 0; for (const child of this.children) { const childCursor = child.getCursorPosition?.(width); if (childCursor) { return { row: rowOffset + childCursor.row, col: childCursor.col }; } rowOffset += child.render(width).length; // Re-renders child! } return null;}class TUI { private focusedComponentStartRow: number = -1; private doRender(): void { // During render, track where focused component starts let currentRow = 0; const focusedLines: string[] = []; for (const child of this.children) { if (child === this.focusedComponent) { this.focusedComponentStartRow = currentRow; } const lines = child.render(width); currentRow += lines.length; } // After render, position hardware cursor if (this.focusedComponent?.getCursorPosition) { const pos = this.focusedComponent.getCursorPosition(width); if (pos) { const absoluteRow = this.focusedComponentStartRow + pos.row; this.positionHardwareCursor(absoluteRow, pos.col); } } }}// Special marker that won't render visuallyconst CURSOR_MARKER = "\x1b[?cursor;r;c\x1b[0m"; // r=row, c=col within component
// In Editor.render():const cursorMarker = `\x1b[?cursor;${layoutLineIndex};${visualCol}\x1b[0m`;interface CursorReporter { getCursorPosition(width: number): { row: number; col: number } | null;}
// Editor implements both Component and CursorReporterclass Editor implements Component, CursorReporter { ... }if (isCursorReporter(this.focusedComponent)) { const pos = this.focusedComponent.getCursorPosition(width); // ...}class Container { render(width: number): string[] { const lines: string[] = []; for (const child of this.children) { lines.push(...child.render(width)); } return lines; }}
class TUI extends Container { doRender(): void { let newLines = this.render(width); // calls Container.render() // ... differential rendering }}class TUI { private findFocusedPath(): Component[] | null { // DFS to find path from this to focusedComponent const path: Component[] = []; const find = (comp: Component): boolean => { if (comp === this.focusedComponent) return true; if ('children' in comp) { for (const child of (comp as Container).children) { path.push(child); if (find(child)) return true; path.pop(); } } return false; }; return find(this) ? path : null; }}class Container { private childPositions: Map<Component, number> = new Map(); render(width: number): string[] { this.childPositions.clear();class Container { render(width: number): string[] { const lines: string[] = []; for (const child of this.children) { lines.push(...child.render(width)); // No tracking of which child produced which lines } return lines; }}// Only the focused component emits this markerconst CURSOR_MARKER = "\x1b]8;;cursor\x07"; // Using OSC 8 (hyperlink) with special ID // Or any zero-width escape sequence
class Editor { render(width: number): string[] { // ... layout text ... // Emit marker at cursor position (only if we have focus) if (this.hasFocus) { // Insert marker right before the fake cursor line = before + CURSOR_MARKER + fakeCursor + after; } return lines; }}class TUI { doRender(): void { let newLines = this.render(width); newLines = this.compositeOverlays(newLines, width, height); // Find cursor marker in final output const cursorPos = this.findAndStripCursorMarker(newLines); newLines = this.applyLineResets(newLines); // ... differential rendering ... // Position hardware cursor at marker location if (cursorPos) { this.positionHardwareCursor(cursorPos.row, cursorPos.col); } } private findAndStripCursorMarker(lines: string[]): { row: number; col: number } | null { for (let row = 0; row < lines.length; row++) { const col = this.findMarkerColumn(lines[row]); if (col !== -1) { lines[row] = lines[row].replace(CURSOR_MARKER, ''); return { row, col }; } } return null; }}// Cursor position marker - APC with unique payloadconst CURSOR_MARKER = "\x1b_pi:cursor\x1b\\";const CURSOR_MARKER = "\x1b_pi:c\x07";/** * Interface for components that can receive focus and display a cursor. * When focused, component should emit CURSOR_MARKER at the cursor position. */export interface Focusable { /** Set by TUI when focus changes */ focused: boolean;}
/** Type guard */export function isFocusable(component: Component): component is Component & Focusable { return 'focused' in component;}class TUI extends Container { setFocus(component: Component | null): void { // Clear old focus if (this.focusedComponent && isFocusable(this.focusedComponent)) { this.focusedComponent.focused = false; } this.focusedComponent = component; // Set new focus if (component && isFocusable(component)) { component.focused = true; } } private doRender(): void { let newLines = this.render(width); newLines = this.compositeOverlays(newLines, width, height); // Find and strip cursor marker, get position const cursorPos = this.extractCursorPosition(newLines); newLines = this.applyLineResets(newLines); // ... existing differential rendering ... // Position hardware cursor if (cursorPos) { this.positionHardwareCursor(cursorPos.row, cursorPos.col); this.terminal.showCursor(); } else { this.terminal.hideCursor(); } } private extractCursorPosition(lines: string[]): { row: number; col: number } | null { const marker = "\x1b_pi:c\x07"; for (let row = 0; row < lines.length; row++) { const markerIndex = lines[row].indexOf(marker); if (markerIndex !== -1) { // Calculate visual column (width of text before marker) const beforeMarker = lines[row].slice(0, markerIndex); const col = visibleWidth(beforeMarker); // Strip marker lines[row] = lines[row].replace(marker, ''); return { row, col }; } } return null; }}class Editor implements Component, Focusable { focused: boolean = false; render(width: number): string[] { // ... existing layout code ... // When building the line with cursor: if (layoutLine.hasCursor && this.focused) { // Emit marker right before the fake cursor character const marker = "\x1b_pi:c\x07"; displayText = before + marker + fakeCursor + after; } else { displayText = before + fakeCursor + after; } // ... }}private doRender(): void { // ... render and composite ... // Find first and last changed lines let firstChanged = -1; let lastChanged = -1; // ... comparison loop ... // Move cursor to first changed line const lineDiff = firstChanged - this.cursorRow; if (lineDiff > 0) { buffer += `\x1b[${lineDiff}B`; // Move down } else if (lineDiff < 0) { buffer += `\x1b[${-lineDiff}A`; // Move up } buffer += "\r"; // Move to column 0 // Write changed lines for (let i = firstChanged; i <= renderEnd; i++) { // ...write line... } // Track where cursor ended up this.cursorRow = finalCursorRow;}private doRender(): void { // ... differential rendering writes ... // Track cursor position for next render this.cursorRow = finalCursorRow; // NEW: Position hardware cursor for IME if (cursorPos) { this.positionHardwareCursor(cursorPos.row, cursorPos.col); this.terminal.showCursor(); }}private doRender(): void { // ... find firstChanged, lastChanged ... // Move cursor to first changed line (relative to current position) const lineDiff = firstChanged - this.cursorRow; if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; buffer += "\r"; // Column 0 // Write changed lines... // Track where cursor ended up this.cursorRow = finalCursorRow;}// After differential rendering leaves cursor at row 50this.cursorRow = 50;
// Then we move cursor to IME position at row 10this.positionHardwareCursor(10, 15);class TUI { private cursorRow = 0; // Logical: where content rendering ends private hardwareCursorRow = 0; // Actual: where terminal cursor physically is}private doRender(): void { // Movement is relative to actual cursor position const lineDiff = firstChanged - this.hardwareCursorRow; // ... write lines ... // After writing, cursor is at end of last written line this.cursorRow = finalCursorRow; this.hardwareCursorRow = finalCursorRow; // Now position for IME (updates hardwareCursorRow) if (cursorPos) { this.positionHardwareCursor(cursorPos.row, cursorPos.col); this.hardwareCursorRow = cursorPos.row; this.terminal.showCursor(); }}private extractCursorPosition(lines: string[]): { row: number; col: number } | null { // ... find marker, calculate position ...}
private doRender(): void { // ... existing differential rendering using hardwareCursorRow for movement ... this.cursorRow = finalCursorRow; this.hardwareCursorRow = finalCursorRow; // Position for IME const cursorPos = /* extracted earlier */; if (cursorPos) { const rowDelta = cursorPos.row - this.hardwareCursorRow; if (rowDelta > 0) this.terminal.write(`\x1b[${rowDelta}B`); else if (rowDelta < 0) this.terminal.write(`\x1b[${-rowDelta}A`); this.terminal.write(`\x1b[${cursorPos.col + 1}G`); // Absolute column this.hardwareCursorRow = cursorPos.row; this.terminal.showCursor(); } else { this.terminal.hideCursor(); }}cd /Users/badlogic/workspaces/pi-mono/packages/coding-agentnpm run dev# Switch to Japanese input (Ctrl+Space)# Type in the editorshowOverlay(component: Component, options?: OverlayOptions): OverlayHandle { const entry = { component, options, preFocus: this.focusedComponent, hidden: false }; this.overlayStack.push(entry); // Only focus if overlay is actually visible if (this.isOverlayVisible(entry)) { this.setFocus(component); // This calls setFocus which sets focused=true if Focusable }inputDisplay = `${before}\x1b[7m${cursorChar}\x1b[27m${after}`;cd packages/coding-agentnpm run dev# then /overlay-test and switch to Japanese inputconst pad = (s: string, len: number) => { const vis = visibleWidth(s); return s + " ".repeat(Math.max(0, len - vis));};const row = (content: string) => th.fg("border", "│") + pad(content, innerW) + th.fg("border", "│");content = `${prefix + label} ${inputDisplay}`;