Reviewed linked issue and relevant source files.
UnderstandChecked changelog and documentation for updates.
UnderstandSummarized and commented on PR and issue.
VerifyYou are given one or more GitHub PR URLs: https://github.com/badlogic/pi-mono/pull/719
For each PR URL, do the following in order:
packages/*/CHANGELOG.md files. Report whether an entry exists. If missing, state that a changelog entry is required before merge and that you will add it if the user decides to merge. Follow the changelog format rules in AGENTS.md. Verify:
### Breaking Changes, ### Added, ### Fixed, etc.)Fixed foo ([#123](https://github.com/badlogic/pi-mono/pull/123) by [@user](https://github.com/user))### Breaking Changes, not just ### FixedOutput format per PR: PR: Changelog:
If no issues are found, say so under Bad and Ugly.
The user wants me to review a GitHub PR. Let me start by reading the PR page in full.
>gh pr view 719 --repo badlogic/pi-mono --json title,body,comments,commits,files,author,labels,state,baseRefName,headRefName{"author":{"id":"MDQ6VXNlcjExODA3MjY0","is_bot":false,"login":"can1357","name":"Can Bölük"},"baseRefName":"main","body":"hw cursor brrr","comments":[{"id":"IC_kwDOPbFNk87fk3lG","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"Part of the solution for #525 \r\n\r\nNeeds vetting on my end. Cursor stuff is brittle.","createdAt":"2026-01-14T18:22:26Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/719#issuecomment-3750984006","viewerDidAuthor":true}],"commits":[{"authoredDate":"2026-01-14T09:16:00Z","authors":[{"email":"[REDACTED]","id":"MDQ6VXNlcjExODA3MjY0","login":"can1357","name":"can1357"}],"committedDate":"2026-01-14T09:16:17Z","messageBody":"- Added hardware cursor positioning support to TUI components.\n- Added getCursorPosition() method to Component interface for cursor coordinate reporting.\n- Added input queuing during terminal cell size detection to prevent input loss.\n- Fixed cursor positioning calculations for multi-byte Unicode characters in input and editor components.\n- Fixed race condition between cell size detection and component rendering.\n- Fixed cursor visibility when autocomplete is active in editor component.","messageHeadline":"feat(tui): added hardware cursor positioning","oid":"72047c057ed5ff2e83922e625a89a89b24b931f9"}],"files":[{"path":"packages/tui/CHANGELOG.md","additions":11,"deletions":1},{"path":"packages/tui/src/components/editor.ts","additions":22,"deletions":0},{"path":"packages/tui/src/components/input.ts","additions":36,"deletions":0},{"path":"packages/tui/src/tui.ts","additions":133,"deletions":24}],"headRefName":"main","labels":[],"state":"OPEN","title":"feat(tui): added hardware cursor positioning"}>gh pr diff 719 --repo badlogic/pi-monodiff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md
index 6524bcfab..380fbe5bc 100644
--- a/packages/tui/CHANGELOG.md
+++ b/packages/tui/CHANGELOG.md
@@ -1,6 +1,16 @@
# Changelog
## [Unreleased]
+### Added
+
+- Added hardware cursor positioning support for input fields and editor components
+- Added input queuing during terminal cell size detection to prevent input loss
+
+### Fixed
+
+- Fixed cursor positioning calculations to handle multi-byte Unicode characters correctly
+- Fixed race condition between cell size detection and component rendering
+- Fixed cursor visibility when autocomplete is active in editor
## [0.45.7] - 2026-01-13
@@ -224,4 +234,4 @@
### Fixed
-- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
+- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
\ No newline at end of file
diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts
index f19b7288d..e7f3bc729 100644
--- a/packages/tui/src/components/editor.ts
+++ b/packages/tui/src/components/editor.ts
@@ -315,6 +315,28 @@ export class Editor implements Component {
// No cached state to invalidate currently
}
+ getCursorPosition(width: number): { row: number; col: number } | null {
+ // Don't show hardware cursor if autocomplete is active
+ if (this.isAutocompleting) {
+ return null;
+ }
+
+ const layoutLines = this.layoutText(width);
+
+ // Find the layout line with the cursor
+ for (let i = 0; i < layoutLines.length; i++) {
+ const layoutLine = layoutLines[i];
+ if (layoutLine?.hasCursor && layoutLine.cursorPos !== undefined) {
+ // Row: +1 for top border
+ // Col: visible width of text before cursor position
+ const before = layoutLine.text.slice(0, layoutLine.cursorPos);
+ return { row: i + 1, col: visibleWidth(before) };
+ }
+ }
+
+ return null;
+ }
+
render(width: number): string[] {
// Store width for cursor navigation
this.lastWidth = width;
diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts
index 642f83c16..debab7fc1 100644
--- a/packages/tui/src/components/input.ts
+++ b/packages/tui/src/components/input.ts
@@ -282,6 +282,42 @@ export class Input implements Component {
// No cached state to invalidate currently
}
+ getCursorPosition(width: number): { row: number; col: number } | null {
+ const prompt = "> ";
+ const availableWidth = width - prompt.length;
+
+ if (availableWidth <= 0) {
+ return null;
+ }
+
+ // Calculate visible text and cursor position within it (same as render())
+ let visibleText = "";
+ let cursorDisplay = this.cursor;
+
+ if (this.value.length < availableWidth) {
+ visibleText = this.value;
+ } else {
+ const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth;
+ const halfWidth = Math.floor(scrollWidth / 2);
+
+ if (this.cursor < halfWidth) {
+ visibleText = this.value.slice(0, scrollWidth);
+ cursorDisplay = this.cursor;
+ } else if (this.cursor > this.value.length - halfWidth) {
+ visibleText = this.value.slice(this.value.length - scrollWidth);
+ cursorDisplay = scrollWidth - (this.value.length - this.cursor);
+ } else {
+ const start = this.cursor - halfWidth;
+ visibleText = this.value.slice(start, start + scrollWidth);
+ cursorDisplay = halfWidth;
+ }
+ }
+
+ // Calculate visual column using visible width of text before cursor
+ const beforeCursor = visibleText.slice(0, cursorDisplay);
+ return { row: 0, col: prompt.length + visibleWidth(beforeCursor) };
+ }
+
render(width: number): string[] {
// Calculate visible window
const prompt = "> ";
diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts
index 84d0f8f43..8a75b2c69 100644
--- a/packages/tui/src/tui.ts
+++ b/packages/tui/src/tui.ts
@@ -32,6 +32,11 @@ export interface Component {
*/
wantsKeyRelease?: boolean;
+ /**
+ * Optional cursor position within the rendered output (0-based row/col).
+ */
+ getCursorPosition?(width: number): { row: number; col: number } | null;
+
/**
* Invalidate any cached rendering state.
* Called when theme changes or when component needs to re-render from scratch.
@@ -137,6 +142,8 @@ export interface OverlayHandle {
*/
export class Container implements Component {
children: Component[] = [];
+ private lastRenderLineCounts: number[] | null = null;
+ private lastRenderWidth: number | null = null;
addChild(component: Component): void {
this.children.push(component);
@@ -157,13 +164,35 @@ export class Container implements Component {
for (const child of this.children) {
child.invalidate?.();
}
+ this.lastRenderLineCounts = null;
+ this.lastRenderWidth = null;
+ }
+
+ getCursorPosition(width: number): { row: number; col: number } | null {
+ const lineCounts = this.lastRenderWidth === width ? this.lastRenderLineCounts : null;
+ let rowOffset = 0;
+ for (let index = 0; index < this.children.length; index++) {
+ const child = this.children[index];
+ const childCursor = child.getCursorPosition?.(width) ?? null;
+ if (childCursor) {
+ return { row: rowOffset + childCursor.row, col: childCursor.col };
+ }
+ const lineCount = lineCounts?.[index];
+ rowOffset += lineCount ?? child.render(width).length;
+ }
+ return null;
}
render(width: number): string[] {
const lines: string[] = [];
+ const lineCounts: number[] = [];
for (const child of this.children) {
- lines.push(...child.render(width));
+ const childLines = child.render(width);
+ lines.push(...childLines);
+ lineCounts.push(childLines.length);
}
+ this.lastRenderLineCounts = lineCounts;
+ this.lastRenderWidth = width;
return lines;
}
}
@@ -180,7 +209,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; // Track end-of-render cursor row (0-indexed)
+ private hardwareCursorRow = 0;
private inputBuffer = ""; // Buffer for parsing terminal responses
private cellSizeQueryPending = false;
@@ -191,6 +221,8 @@ export class TUI extends Container {
preFocus: Component | null;
hidden: boolean;
}[] = [];
+ private inputQueue: string[] = []; // Queue input during cell size query to avoid interleaving
+ private previousCursor: { row: number; col: number } | null = null;
constructor(terminal: Terminal) {
super();
@@ -317,7 +349,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 +367,8 @@ export class TUI extends Container {
this.previousLines = [];
this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
this.cursorRow = 0;
+ this.hardwareCursorRow = 0;
+ this.previousCursor = null;
}
if (this.renderRequested) return;
this.renderRequested = true;
@@ -344,15 +378,67 @@ export class TUI extends Container {
});
}
+ private areCursorsEqual(
+ left: { row: number; col: number } | null,
+ right: { row: number; col: number } | null,
+ ): boolean {
+ if (!left && !right) return true;
+ if (!left || !right) return false;
+ return left.row === right.row && left.col === right.col;
+ }
+
+ private updateHardwareCursor(
+ width: number,
+ totalLines: number,
+ cursor: { row: number; col: number } | null,
+ currentCursorRow: number,
+ ): void {
+ if (!cursor || totalLines <= 0) {
+ this.terminal.hideCursor();
+ this.hardwareCursorRow = Math.max(0, currentCursorRow);
+ return;
+ }
+
+ const targetRow = Math.max(0, Math.min(cursor.row, totalLines - 1));
+ const targetCol = Math.max(0, Math.min(cursor.col, width - 1));
+ const rowDelta = targetRow - currentCursorRow;
+
+ let buffer = "";
+ if (rowDelta > 0) {
+ buffer += `\x1b[${rowDelta}B`;
+ } else if (rowDelta < 0) {
+ buffer += `\x1b[${-rowDelta}A`;
+ }
+ buffer += `\r\x1b[${targetCol + 1}G`;
+ this.terminal.write(buffer);
+ this.hardwareCursorRow = targetRow;
+ this.terminal.showCursor();
+ }
+
private handleInput(data: string): void {
// If we're waiting for cell size response, buffer input and parse
if (this.cellSizeQueryPending) {
this.inputBuffer += data;
const filtered = this.parseCellSizeResponse();
if (filtered.length === 0) return;
- data = filtered;
+ if (filtered.length > 0) {
+ this.inputQueue.push(filtered);
+ }
+ // Process queued input after cell size response completes
+ if (!this.cellSizeQueryPending && this.inputQueue.length > 0) {
+ const queued = this.inputQueue;
+ this.inputQueue = [];
+ for (const item of queued) {
+ this.processInput(item);
+ }
+ }
+ return;
}
+ this.processInput(data);
+ }
+
+ private processInput(data: string): void {
// Global debug key handler (Shift+Ctrl+D)
if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
this.onDebug();
@@ -376,7 +462,6 @@ export class TUI extends Container {
// Pass input to focused component (including Ctrl+C)
// The focused component can decide how to handle Ctrl+C
if (this.focusedComponent?.handleInput) {
- // Filter out key release events unless component opts in
if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
return;
}
@@ -395,16 +480,17 @@ export class TUI extends Container {
const heightPx = parseInt(match[1], 10);
const widthPx = parseInt(match[2], 10);
+ // Remove the response from buffer first
+ this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
+ this.cellSizeQueryPending = false;
+
if (heightPx > 0 && widthPx > 0) {
setCellDimensions({ widthPx, heightPx });
// Invalidate all components so images re-render with correct dimensions
+ // This is safe now because cellSizeQueryPending=false prevents race with render
this.invalidate();
this.requestRender();
}
-
- // Remove the response from buffer
- this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
- this.cellSizeQueryPending = false;
}
// Check if we have a partial cell size response starting (wait for more data)
@@ -701,8 +787,12 @@ export class TUI extends Container {
}
private doRender(): void {
+ // Capture terminal dimensions at start to ensure consistency throughout render
const width = this.terminal.columns;
const height = this.terminal.rows;
+ // Snapshot cursor position at start of render for consistent viewport calculations
+ const currentRenderCursorRow = this.cursorRow;
+ const currentHardwareCursorRow = this.hardwareCursorRow;
// Render all components to get new lines
let newLines = this.render(width);
@@ -714,6 +804,8 @@ export class TUI extends Container {
newLines = this.applyLineResets(newLines);
+ const cursorInfo = this.getCursorPosition(width);
+
// Width changed - need full re-render
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
@@ -726,8 +818,10 @@ export class TUI extends Container {
}
buffer += "\x1b[?2026l"; // End synchronized output
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);
+ // After rendering N lines, cursor is at end of last line (line N-1)
+ this.cursorRow = newLines.length - 1;
+ this.updateHardwareCursor(width, newLines.length, cursorInfo, this.cursorRow);
+ this.previousCursor = cursorInfo;
this.previousLines = newLines;
this.previousWidth = width;
return;
@@ -743,7 +837,9 @@ export class TUI extends Container {
}
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
- this.cursorRow = Math.max(0, newLines.length - 1);
+ this.cursorRow = newLines.length - 1;
+ this.updateHardwareCursor(width, newLines.length, cursorInfo, this.cursorRow);
+ this.previousCursor = cursorInfo;
this.previousLines = newLines;
this.previousWidth = width;
return;
@@ -767,6 +863,10 @@ export class TUI extends Container {
// No changes
if (firstChanged === -1) {
+ if (!this.areCursorsEqual(cursorInfo, this.previousCursor)) {
+ this.updateHardwareCursor(width, newLines.length, cursorInfo, currentHardwareCursorRow);
+ this.previousCursor = cursorInfo;
+ }
return;
}
@@ -774,9 +874,9 @@ export class TUI extends Container {
if (firstChanged >= newLines.length) {
if (this.previousLines.length > newLines.length) {
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;
+ // Move to end of new content
+ const targetRow = newLines.length - 1;
+ const lineDiff = targetRow - currentHardwareCursorRow;
if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;
else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;
buffer += "\r";
@@ -788,7 +888,8 @@ export class TUI extends Container {
buffer += `\x1b[${extraLines}A`;
buffer += "\x1b[?2026l";
this.terminal.write(buffer);
- this.cursorRow = targetRow;
+ this.cursorRow = newLines.length - 1;
+ this.hardwareCursorRow = this.cursorRow;
}
this.previousLines = newLines;
this.previousWidth = width;
@@ -796,10 +897,10 @@ export class TUI extends Container {
}
// Check if firstChanged is outside the viewport
- // cursorRow is the line where cursor is (0-indexed)
- // Viewport shows lines from (cursorRow - height + 1) to cursorRow
+ // Use snapshotted cursor position for consistent viewport calculation
+ // Viewport shows lines from (currentRenderCursorRow - height + 1) to currentRenderCursorRow
// If firstChanged < viewportTop, we need full re-render
- const viewportTop = this.cursorRow - height + 1;
+ const viewportTop = currentRenderCursorRow - height + 1;
if (firstChanged < viewportTop) {
// First change is above viewport - need full re-render
let buffer = "\x1b[?2026h"; // Begin synchronized output
@@ -810,7 +911,9 @@ export class TUI extends Container {
}
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
- this.cursorRow = Math.max(0, newLines.length - 1);
+ this.cursorRow = newLines.length - 1;
+ this.updateHardwareCursor(width, newLines.length, cursorInfo, this.cursorRow);
+ this.previousCursor = cursorInfo;
this.previousLines = newLines;
this.previousWidth = width;
return;
@@ -820,8 +923,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 using snapshotted position
+ const lineDiff = firstChanged - currentHardwareCursorRow;
if (lineDiff > 0) {
buffer += `\x1b[${lineDiff}B`; // Move down
} else if (lineDiff < 0) {
@@ -850,8 +953,12 @@ export class TUI extends Container {
...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
"",
].join("\n");
- fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
- fs.writeFileSync(crashLogPath, crashData);
+ try {
+ fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
+ fs.writeFileSync(crashLogPath, crashData);
+ } catch {
+ // Ignore - crash log is best-effort
+ }
// Clean up terminal state before throwing
this.stop();
@@ -895,6 +1002,8 @@ export class TUI extends Container {
// Track cursor position for next render
this.cursorRow = finalCursorRow;
+ this.updateHardwareCursor(width, newLines.length, cursorInfo, this.cursorRow);
+ this.previousCursor = cursorInfo;
this.previousLines = newLines;
this.previousWidth = width;Now I need to read the referenced issue #525 that was mentioned in the comments.
Let me read the linked issue #525 and examine the relevant source files:
>gh issue view 525 --repo badlogic/pi-mono --json title,body,comments,labels,state{"body":"Fixed IME candidate window positioning by moving the real terminal cursor to follow the fake cursor.\r\n\r\nThe problem of #491 is when a huge chunk of content is renderred, the fake cursor position will be occasional mis-tracked, add a DSR cursor query after render to force move the real cursor to right place will solve it. But... this will messup all the non inputable cursor interactive like settings navigation, so, add a gate for only editor and input cursor.\r\n\r\nPlay around with this new behavier for an afternoon, burned out 20% weekly tokens of codex, not trigger any TUI disaster yet, so here we are, lucky for our CJK people.","comments":[{"id":"IC_kwDOPbFNk87eAIvo","author":{"login":"ghoulr"},"authorAssociation":"CONTRIBUTOR","body":"Refactor([6f98a10](https://github.com/badlogic/pi-mono/pull/525/commits/6f98a1040e575b37a3c68ef22a266fd4db331aa6)):\r\n- render a dedicated cursor marker (`ESC[7;999m` … `ESC[27m`) to denote the “fake cursor” unambiguously\r\n- On each render build a Component -> [startRow, endRow) range map; after rendering, reverse scans only the focused component to sync real cursor position\r\n\r\nnow we can move the real cursor without performance downside and no ambiguity\r\n\r\nAdd([05052cb](https://github.com/badlogic/pi-mono/pull/525/commits/05052cbb6c6632746941027eb1a600300c71259b)):\r\n- add cursor to session tree search input, so IME can work correctly in this component","createdAt":"2026-01-08T16:08:20Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/525#issuecomment-3724577768","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87ejSDk","author":{"login":"ghoulr"},"authorAssociation":"CONTRIBUTOR","body":"Hi @badlogic ,is there anything I can do to get this PR merged? I have to local patch updated pi every day if not several time a day, the good thing is it works perfectly everytime I patched pi, bad thing is this is really annoying. Big Thanks!","createdAt":"2026-01-11T01:15:07Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/525#issuecomment-3733790948","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87ejeU-","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"Sorry, I haven't gotten to this yet. And also sorry for the rebase conflicts. The TUI renderer is really brittle if cursor position isn't tracked appropriately. I need to come up with a bunch of tests that ensure this works as intended, which is also not trivial. That's why this is taking so long.","createdAt":"2026-01-11T02:23:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/525#issuecomment-3733841214","viewerDidAuthor":true},{"id":"IC_kwDOPbFNk87ejfR6","author":{"login":"ghoulr"},"authorAssociation":"CONTRIBUTOR","body":"Could you give me some hints of what tests should be done, I can do this by myself.","createdAt":"2026-01-11T02:29:28Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/525#issuecomment-3733845114","viewerDidAuthor":false},{"id":"IC_kwDOPbFNk87en9iQ","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"I'm actually trying to redesign this. I think the changes in this PR are too wide reaching. I believe this can be done I more concise way, which should also easily applicable to custom components that can receive focus and set a faux cursor position.\r\n\r\nI'll ping you once I have an implementation that works. Markers are definitely the way to go.","createdAt":"2026-01-11T16:49:54Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[{"content":"HEART","users":{"totalCount":2}}],"url":"https://github.com/badlogic/pi-mono/pull/525#issuecomment-3735017616","viewerDidAuthor":true},{"id":"IC_kwDOPbFNk87f9QF-","author":{"login":"badlogic"},"authorAssociation":"OWNER","body":"Ok, we'll do a variant of #719 I think. Still need to human reciew and test, stuff's tricky.","createdAt":"2026-01-15T23:33:36Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/badlogic/pi-mono/pull/525#issuecomment-3757375870","viewerDidAuthor":true}],"labels":[],"state":"CLOSED","title":"sync real cursor to fake cursor position in editor/input"}Now let me read the CHANGELOG.md to check if there's already a changelog entry:
Now let me check the changelog and look for any documentation that might need updates:
>ls -la /Users/badlogic/workspaces/pi-mono/packages/coding-agent/docs/total 408
drwxr-xr-x@ 11 badlogic staff 352 Jan 16 02:42 .
drwxr-xr-x@ 16 badlogic staff 512 Jan 16 02:42 ..
-rw-r--r--@ 1 badlogic staff 14757 Jan 3 00:47 compaction.md
-rw-r--r--@ 1 badlogic staff 51579 Jan 16 02:59 extensions.md
-rw-r--r--@ 1 badlogic staff 23836 Jan 16 03:00 rpc.md
-rw-r--r--@ 1 badlogic staff 29592 Jan 13 22:01 sdk.md
-rw-r--r--@ 1 badlogic staff 9717 Jan 12 23:33 session.md
-rw-r--r--@ 1 badlogic staff 9389 Jan 12 23:33 skills.md
-rw-r--r--@ 1 badlogic staff 16734 Jan 8 19:37 theme.md
-rw-r--r--@ 1 badlogic staff 5853 Jan 12 23:33 tree.md
-rw-r--r--@ 1 badlogic staff 24747 Jan 16 02:07 tui.mdLet me analyze this PR:
[Unreleased] but the current main branch doesn't have this section - it starts with ## [0.46.0].Let me verify the current state of the changelog and the tui.ts file on main:
Let me verify the current state of the changelog on main and check if there are any other related issues:
>cd /Users/badlogic/workspaces/pi-mono && git log --oneline -5 packages/tui/CHANGELOG.mde46cc55f Add [Unreleased] section for next cycle
0aaa4ef5 Release v0.46.0
a216cc49 chore: update changelogs
15a9670d fix: keyboard shortcuts on non-Latin keyboard layouts (#718)
95859725 Add [Unreleased] section for next cycle>gh issue view 491 --repo badlogic/pi-mono --json title,body,state 2>/dev/null || echo "Issue 491 not found or no access"{"body":"Since the real cursor is always at right bottom conner, IME candidates window is not follow the fake cursor and very annoying, this fix move the hidden real cursor with fake cursor to avoid this behavier, tested in linux/wayland, but I think it will work on all OS.","state":"CLOSED","title":"fix(tui): move real cursor to fake cursor for IME"}I have a good understanding of this PR now. Let me provide a comprehensive review.
Key findings:
The PR includes:
getCursorPosition() method added to the Component interfaceThe changelog is included in the PR diff and is properly formatted with:
### Added section with the new features### Fixed section with the bug fixesHowever, the PR doesn't include attribution for being an external contribution (PR link and author).
The PR is from @can1357 (external contributor), so the changelog entries need to include PR link and author attribution.
Let me verify the current state of main's changelog matches what I see in the output.
Now let me check the TUI docs to see if documentation updates might be needed:
Now I have a complete picture. Let me write the review.
Summary:
getCursorPosition() method to the Component interfaceReview points: Good:
getCursorPosition() method on Component interfacevisibleWidth()Bad:
Ugly:
getCursorPosition() implementation in Container is somewhat complex - it tries to use cached line counts but falls back to re-rendering if width changed. This could have subtle bugs if render state gets out of sync.Documentation:
getCursorPosition() as an optional methodThe PR is authored by @can1357 which is an external contributor (not badlogic), so the changelog entries need attribution.
PR: https://github.com/badlogic/pi-mono/pull/719
Changelog:
## [Unreleased] with ### Added and ### Fixed sections([#719](https://github.com/badlogic/pi-mono/pull/719) by [@can1357](https://github.com/can1357))## [Unreleased] section is empty, so these entries will be newGood:
getCursorPosition() method to Component interface, allowing components to opt-in to hardware cursor positioning without breaking existing componentsvisibleWidth() for Unicode-aware cursor column calculation in both Input and Editor componentsinputQueue) prevents input loss during terminal initializationcellSizeQueryPending before calling setCellDimensions() / invalidate() / requestRender()cursorRow for content position) from hardware cursor tracking (hardwareCursorRow for escape sequence positioning)Bad:
previousCursor equality check in areCursorsEqual() could use a single comparison if cursors were stored as a string key or similar (minor)Ugly:
getCursorPosition() uses cached lastRenderLineCounts but falls back to re-calling child.render(width) if width changed. This coupling between render state and cursor position could lead to subtle bugs if a child component's render output changes between calls without invalidate() being called. The fallback also has performance implications for complex component trees.Questions or Assumptions:
getCursorPosition() call happens on the base Container, but overlays are composited on top. If an overlay component (like autocomplete) returns a cursor position, will it be correct relative to the composited output?getCursorPosition() returns null when autocompleting. Is this the intended behavior, or should the autocomplete list's cursor position be used?Change summary:
getCursorPosition(width): { row: number; col: number } | null method to Component interfacegetCursorPosition() in Input, Editor, and Container componentsEditor returns null when autocomplete is active (hides hardware cursor)updateHardwareCursor() after each render to position hardware cursorcellSizeQueryPending = false before triggering re-rendershandleInput() to separate input buffering from processing via new processInput()Tests:
Documentation:
packages/coding-agent/docs/tui.md should be updated to document the new optional getCursorPosition?(width: number): { row: number; col: number } | null method in the Component Interface section. This is relevant for extension authors creating custom focusable components.read all of @packages/tui/src/tui.ts and @packages/tui/src/components/editor.ts (and why do we have @packages/tui/src/editor-component.ts?) we need a deep analysis of how we can fix this issue correctly. i feel this one is a hack. components reporting the cursor position: in what coordinate system? relative to the components first line? could we do this based on what component has focus?
The user wants me to do a deep analysis of the cursor positioning approach in the PR. They're concerned it might be a hack and want to understand:
Let me read these files carefully.
Now I have a complete picture. Let me analyze:
TUI class extends Container and manages:
focusedComponent)cursorRow)Component interface is minimal:
render(width): string[] - return lines to displayhandleInput?(data) - optional keyboard handlerinvalidate() - clear cached stateEditor is a Component that:
\x1b[7m)cursorLine, cursorCol)this.tui (passed in constructor)EditorComponent is an interface for custom editor implementations, allowing extensions to replace the default editor.
Input is a simpler single-line input component (also renders fake cursor)
The PR adds getCursorPosition(width): { row: number; col: number } | null to Component interface. The returned coordinates are relative to the component's first rendered line (0-indexed row within the component's output).
Issues with this approach:
Every component must implement it - Container has to walk all children, accumulate line counts, and find which child has a cursor. This is done again after render, potentially re-rendering children.
Coordinate system is unclear - Is it visual position or character position? The PR uses visibleWidth() so it's visual columns, but the rows are "layout line indices" which need translation.
Focus-agnostic - The PR walks ALL children looking for ANY cursor. It doesn't use the existing focus tracking.
Stale caching - Container caches lastRenderLineCounts to avoid re-rendering when querying cursor positions, but this cache can become outdated.
Overlay complications - When overlays are composited on top of base content and an overlay has focus, the coordinate system for its cursor position becomes ambiguous.
Since TUI already tracks which component has focus via focusedComponent, I could leverage that instead. Only the focused component needs to report its cursor position, eliminating the need to walk the entire component tree. TUI already knows where the focused component appears in the render output, so it could translate the component-relative coordinates to absolute terminal coordinates without additional caching or re-rendering.
Another option is using cursor markers embedded in the render output itself. Instead of asking components for coordinates after rendering, they could emit special escape sequence markers (like ESC[7;999m ... ESC[27m from issue #525) that the TUI scans for to position the hardware cursor. This avoids coordinate translation entirely.
For a cleaner implementation, I could define a getCursorPosition method that only gets called on the focused component, taking the available width and returning row and column offsets. During rendering, the TUI tracks where the focused component starts, then uses that baseline to calculate absolute cursor coordinates. d output for this marker and positions hardware cursor there. Scanning the rendered output works automatically regardless of component hierarchy, and coordinates are already in the final rendered space. The tradeoff is some scanning overhead per render cycle and needing to strip the markers before display.
Another approach is having components register a cursor callback when they gain focus, letting the TUI call it directly without needing to know about component structure. This requires each component to provide the callback when focus is set, adding some coordination overhead.
The cleanest solution is tracking where the focused component's output appears in the final rendered lines. The TUI already knows which component has focus—it just needs to record where that component's content starts during rendering. After the render completes, if the focused component provides a cursor position method, the TUI adds the component's output offset to get the absolute position. For overlays, the TUI can use the overlay's position offset that's already calculated during composition.
The editor-component.ts file serves as an interface for custom editor implementations, while the Editor class in editor.ts is the default concrete implementation. This design lets extensions create alternative editors (like vim mode) by implementing the EditorComponent interface, with the main app using duck typing to work with either version.
Now let me look at how Editor constructor gets tui and how focus is managed:
>cd /Users/badlogic/workspaces/pi-mono && grep -rn "new Editor" packages/coding-agent/src --include="*.ts" | head -20packages/coding-agent/src/core/keybindings.ts:124: setEditorKeybindings(new EditorKeybindingsManager(editorConfig));
packages/coding-agent/src/modes/interactive/components/extension-editor.ts:47: this.editor = new Editor(tui, getEditorTheme());>cd /Users/badlogic/workspaces/pi-mono && grep -rn "setFocus" packages/coding-agent/src --include="*.ts" | head -20packages/coding-agent/src/modes/interactive/interactive-mode.ts:417: this.ui.setFocus(this.editor);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:1022: this.ui.setFocus(this.extensionSelector);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:1035: this.ui.setFocus(this.editor);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:1089: this.ui.setFocus(this.extensionInput);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:1102: this.ui.setFocus(this.editor);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:1128: this.ui.setFocus(this.extensionEditor);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:1140: this.ui.setFocus(this.editor);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:1199: this.ui.setFocus(this.editor as Component);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:1237: this.ui.setFocus(this.editor);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:1283: this.ui.setFocus(component);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2462: this.ui.setFocus(this.editor);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:2467: this.ui.setFocus(focus);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:3050: this.ui.setFocus(dialog);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:3065: this.ui.setFocus(this.editor);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:3165: this.ui.setFocus(loader);
packages/coding-agent/src/modes/interactive/interactive-mode.ts:3172: this.ui.setFocus(this.editor);
packages/coding-agent/src/cli/session-picker.ts:45: ui.setFocus(selector.getSessionList());Now I understand the full picture. Let me provide a deep analysis.
Now I have a complete picture. Let me write out my deep analysis.
TUI extends Container, tracks:
focusedComponent - which component receives inputcursorRow - where the terminal cursor is for differential rendering (NOT the user's text cursor)Component interface is minimal:
render(width): string[]handleInput?(data)invalidate()Editor and Input render a "fake cursor" using reverse video (\x1b[7m). The hardware cursor is hidden. This causes IME candidate windows to appear at the wrong location.
EditorComponent in editor-component.ts is an interface for custom editor implementations (vim mode, etc.). The Editor class in editor.ts is the default implementation.
1. Coordinate System Confusion
The PR's getCursorPosition(width) returns { row, col } where:
row is relative to the component's first rendered line (0-indexed)col is the visual column (using visibleWidth())But Container's implementation walks ALL children, accumulates line counts, and returns absolute coordinates. This mixing of relative/absolute is confusing and error-prone.
2. Every Component Must Participate
Container has to implement getCursorPosition() by iterating children:
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;}This re-renders children to count lines (the PR tries to cache this, but it's fragile).
3. Ignores Focus
The TUI already knows which component has focus via focusedComponent. But the PR's approach walks all children looking for any cursor. This is wasteful and semantically wrong: only the focused component should report a cursor.
4. Doesn't Handle Overlays
Overlays are composited at specific (row, col) positions. If a focused overlay component reports cursor position (2, 5), that's relative to the overlay's content, not the final composited output. The PR doesn't account for overlay positioning.
5. Cache Staleness
The Container caches lastRenderLineCounts but this can get out of sync if a child's render output changes between render() and getCursorPosition() calls.
The key insight: TUI already tracks focus. It just needs to also track where the focused component appears in the rendered output.
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); } } }}For overlays: the overlay's (row, col) position is already known from resolveOverlayLayout(). Add that offset.
Pros:
getCursorPosition()Cons:
Components emit a special marker where the cursor should be:
// 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`;TUI scans rendered output for marker, extracts position, strips marker, positions hardware cursor.
Pros:
Cons:
Instead of adding to Component interface, create a separate CursorReporter interface:
interface CursorReporter { getCursorPosition(width: number): { row: number; col: number } | null;}
// Editor implements both Component and CursorReporterclass Editor implements Component, CursorReporter { ... }TUI checks if focusedComponent is also a CursorReporter:
if (isCursorReporter(this.focusedComponent)) { const pos = this.focusedComponent.getCursorPosition(width); // ...}Pros:
Cons:
Option A (Focus-Based Tracking) is the cleanest:
Only the focused component implements getCursorPosition() returning relative coordinates (row relative to its first line, col as visual width)
TUI tracks where the focused component starts during render
For overlays, add the overlay's position offset
Container does NOT implement getCursorPosition() - it's not needed because TUI tracks the focused component directly
The coordinate system is explicit:
{ row, col } relative to their own outputThis avoids the PR's issues:
The user is asking how TUI would track where the focused component starts during render. This is actually the tricky part because:
Let me think about this more carefully...
The current render flow:
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 }}The problem is that Container.render() doesn't know about focus. And the focused component could be:
For direct children, it's easy - TUI could override render() to track position. But for nested components, it's harder.
Options:
Option 1: Walk the tree to find focused component's path
Before or after render, walk the component tree to find the path from TUI to the focused component. Then during render, track line counts for components on that path.
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; }}Then during render, track cumulative row offset for each component in the path. But this is complex and requires knowing the structure of containers.
Option 2: Register focused component's position during render
Have Container track and report child positions during rendering. This way, as each child renders, Container records where it starts in the output. Then TUI can query the focused component's starting position after render completes.
class Container { private childPositions: Map<Component, number> = new Map(); render(width: number): string[] { this.childPositions.clear();The Container stores each child's row position as it builds the output, then provides a method to retrieve that position. However, this approach still doesn't solve the nesting problem—we'd need to traverse the entire component tree to find the absolute position of a deeply nested focused component.
Option 3: Focused component knows its absolute position
Pass the current row offset through the render call so each component knows where it sits in the final output. The focused component can then store this information when rendered. This is cleaner than traversing paths, but it requires modifying the Component interface to accept an optional offset parameter, which is somewhat invasive.
Option 4:
The focused component emits a special marker in its rendered output. TUI scans for this marker after render, extracts position, strips marker. This is actually cleaner because:
Option 5: Query after render
After render, TUI asks the focused component for its cursor position, then walks the component tree to calculate the offset. This is what the PR does, but with Container implementing getCursorPosition.
The issue is we need to know where in the output the focused component's lines appear. The rendered output is just an array of strings with no metadata about which component produced which lines.
Several approaches exist: tracking during render requires instrumenting Container.render() or passing context; marking in output means the component puts a marker that TUI finds; querying and searching involves rendering the component separately and finding its output in the full output (fragile, could have duplicates); maintaining a render tree keeps metadata about which components produced which line ranges.
The simplest approach might be handling the non-nested case where the focused component is a direct child of TUI—TUI can easily track this since it controls the render loop. For nested cases, which are rare in practice, the main focused components are Editor (direct child of TUI's main container), Input (used in overlays with their own positioning), and SelectList (also used in overlays).
Overlays already have explicit positioning via resolveOverlayLayout(). So the solution only needs to handle direct children of TUI by tracking during render, and overlay components by using their overlay position plus the component's relative position. Looking at the actual component tree in interactive-mode.ts, the focused component is either a direct child of TUI or very shallow nesting, or an overlay component with a known position. For direct children, TUI.render() could be overridden to track where each component's output appears.
However, Container.render() just concatenates children and TUI doesn't override it. The most practical approach seems to be using a cursor marker: Editor.render() produces lines with a fake cursor in reverse video and emits a special escape sequence near it that encodes the cursor position. After TUI.render() and compositeOverlays() complete, TUI scans the final output for this marker, extracts the row/col from its position, and strips it out.
The marker could be a zero-width escape sequence like \x1b[?hwcursor\x1b[0m that doesn't affect rendering, or TUI could use the reverse video sequence itself as the marker since only the focused component should emit it. The key insight is that after compositing, the marker appears at the correct absolute position, so TUI just needs to find it and use that position to place the hardware cursor. The flow would start with the Editor checking whether it has focus to determine if it should emit the marker.
You're right to push on this - it's the crux of the problem. Let me think through the options:
TUI calls Container.render() which just concatenates child outputs:
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; }}The focused component could be:
For direct children, TUI could override render() to track positions. But for nested components, we'd need to either:
childPositions map (overhead for all containers)All of these are complex and fragile.
The marker approach sidesteps the tracking problem entirely:
// 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; }}Then in TUI:
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; }}Why this works:
The focused component needs to know it has focus. Currently Editor already has access to this.tui, so it could check this.tui.focusedComponent === this or TUI could set a flag.
How does the focused component know it has focus?
Options:
this.tui.hasFocus(this) (requires reference to TUI)component.focused = truerender(width, focused) (interface change)Editor already has this.tui reference in its constructor, so option 1 works for Editor. For other components (Input, etc.), they'd need the same pattern or option 2.
What do you think - is the marker approach worth exploring, or do you see issues with it?
i think the marker approach is good, if we can find a marker that's guaranteed unique, does not mess with rendering, etc.
i think we can introduce a Focusable interface that components that need focused flag to check if they need to emit the marker should implement.
makes sense?
The user wants to:
Focusable interface for components that need to check focus and emit the markerLet me think about:
What marker to use that's:
The Focusable interface design
For the marker, options:
OSC 8 hyperlink with special URL: \x1b]8;;__cursor__\x07...\x1b]8;;\x07
Custom OSC sequence: \x1b]9999;\x07
DCS (Device Control String): \x1bP...\x1b\\
APC (Application Program Command): \x1b_...\x1b\\
PM (Privacy Message): \x1b^...\x1b\\
Custom CSI with private parameter: \x1b[?9999X
Actually, the safest approach might be a custom private-use escape sequence that terminals will ignore. Looking at standards:
? prefix in parameters are privateI think APC (\x1b_...\x1b\\ or \x1b_...\x07) is a good choice:
Since the TUI code already handles \x1b_G for Kitty graphics, I need something that won't conflict. Using \x1b_C\x07 (APC with "C" for cursor, terminated by BEL) keeps it simple and distinct.
For the Focusable interface, I need to define how components expose their focus state and emit the cursor marker when focused.
Yes, makes sense. Let me draft the design:
Looking for something that:
APC (Application Program Command) is ideal: \x1b_...\x1b\\
It's explicitly defined for application-specific use. Terminals should ignore unknown APC sequences. The TUI already handles \x1b_G for Kitty graphics, so we just need a distinct payload.
// Cursor position marker - APC with unique payloadconst CURSOR_MARKER = "\x1b_pi:cursor\x1b\\";Or even shorter with BEL terminator (also valid):
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; } // ... }}\x1b_pi:c\x07 (APC sequence, zero-width, ignored by terminals)focused flagThis keeps the Component interface unchanged, only components that need cursor tracking implement Focusable, and the marker naturally flows through any nesting/compositing.
Want me to look at what it would take to implement this properly as an alternative to the PR?
next question: for differential rendering, we set the cursor to the first line that changed within the visible terminal viewport, does this cursor positioning mess with that somehow?
The user is asking about a potential conflict between:
Differential rendering cursor - TUI tracks cursorRow to know where the terminal cursor is for efficient updates. It moves the cursor to the first changed line, writes updates, and tracks where the cursor ends up.
Hardware cursor for IME - The new cursor positioning that moves the hardware cursor to where the user's text cursor is (for IME candidate windows).
Let me trace through the differential rendering logic in doRender():
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;}So the differential rendering:
cursorRow)The cursorRow variable is used for:
Now, if we add hardware cursor positioning AFTER the differential rendering writes:
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(); }}The positionHardwareCursor() would move the cursor somewhere else (to the user's text cursor position). This WOULD mess with cursorRow tracking! On the next render, cursorRow would be out of sync with where the terminal cursor actually is, since we moved it for IME positioning but didn't update the tracking variable. The differential rendering would then calculate incorrect cursor movements, potentially moving to the wrong line or writing to unexpected positions.
I need to keep the tracking variable in sync with the actual hardware cursor position. The cleanest approach is to update cursorRow after positioning for IME, so the next differential render knows where the cursor actually is and calculates movements from that position correctly.
However, there's a complication: the differential rendering logic assumes the cursor starts at column 0 (using \r to reset), but the IME cursor could be positioned at any column. The movement calculations that follow would then be based on an incorrect starting position, since the logic moves down/up from cursorRow without accounting for the column offset.
Looking at how the PR handles this, it maintains separate tracking: cursorRow for the logical end-of-render position and hardwareCursorRow for the actual hardware cursor state. The movement calculations use hardwareCursorRow to determine the line difference, ensuring the differential rendering starts from the correct row position.
This separation is actually sound—the PR properly distinguishes between where the rendering logic thinks it is versus where the terminal cursor actually sits. But I should examine the PR diff more closely to verify the implementation details, particularly around how the cursor position snapshot is captured at the start of rendering. - the actual position where the terminal cursor sits
This aligns with what I need to implement. Let me verify my marker-based approach handles this distinction properly.
Great question. Yes, there's a potential conflict. Let me trace through:
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;}cursorRow tracks where the terminal cursor is so the next render can calculate relative movement.
If after differential rendering we move the cursor to the IME position:
// After differential rendering leaves cursor at row 50this.cursorRow = 50;
// Then we move cursor to IME position at row 10this.positionHardwareCursor(10, 15);Next render, we think cursor is at row 50, but it's actually at row 10. Movement calculation is wrong.
We need two row trackers:
class TUI { private cursorRow = 0; // Logical: where content rendering ends private hardwareCursorRow = 0; // Actual: where terminal cursor physically is}Movement calculations use hardwareCursorRow:
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(); }}The PR actually handles this correctly with separate cursorRow and hardwareCursorRow tracking. So the marker approach would need the same:
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(); }}So yes, there's interaction, but it's manageable with dual tracking. The key insight: column doesn't matter for differential rendering (it always does \r to go to column 0), only row matters.
ok, create a feature branch and implement this. i beleive @packages/tui/src/components/input.ts will also need this treatment then
The user wants me to:
Let me start by creating the branch and then implementing the changes.
Key changes needed:
CURSOR_MARKER constant and Focusable interface to tui.tsfocused property and marker emission to Editorfocused property and marker emission to Inputfocused flag in setFocus()hardwareCursorRow separately from cursorRowLet me start implementing.
>cd /Users/badlogic/workspaces/pi-mono && git checkout main && git pull && git checkout -b feature/hardware-cursor-positioningAlready on 'main'
Your branch is up to date with 'origin/main'.
Already up to date.
Switched to a new branch 'feature/hardware-cursor-positioning'Now let me implement the changes. I'll start with tui.ts to add the Focusable interface and cursor positioning logic, then update Editor and Input.
Now I need to implement:
CURSOR_MARKER constantFocusable interface and isFocusable type guardsetFocus to manage the focused flaghardwareCursorRow trackingextractCursorPosition methoddoRender to extract cursor position and position hardware cursorstop to use hardwareCursorRowLet me write the edit for tui.ts first, then do Editor and Input.
Now let me implement the changes. First, I'll update tui.ts:
Now I need to update the TUI class to:
hardwareCursorRow trackingsetFocus to manage the focused flagextractCursorPosition methoddoRender to handle cursor positioningstop to use hardwareCursorRowLet me make these changes step by step.
Now I need to:
stop() to use hardwareCursorRowrequestRender() to reset hardwareCursorRow on forceextractCursorPosition() methoddoRender() to extract cursor position before applyLineResets, and position hardware cursor after renderingLet me update stop() first.
Now update requestRender() to reset hardwareCursorRow on force render.
Now I need to add the extractCursorPosition method and update doRender. Let me add the method first, then update doRender.I'll add extractCursorPosition right before doRender.
Now I need to update the rest of doRender to:
hardwareCursorRow for cursor movement calculationshardwareCursorRow after rendering3. Position the hardware cursor at cursorPos after renderingThe differential rendering section has several places where it calculates cursor movement. Let me update them to use hardwareCursorRow.Also, at the end of each rendering path, I need to position the hardware cursor.Let me read the current doRender to see all the places that need updating.Now I need to update the rest of doRender to use hardwareCursorRow for movement calculations and position the hardware cursor at the end. Let me read the current state and make the updates:
Now I need to see the rest of doRender to update it. Let me continue reading.
Now I need to update doRender to:1. Use hardwareCursorRow for movement calculations (instead of cursorRow)
2. After rendering, position the hardware cursor at cursorPos
3. Update both cursorRow and hardwareCursorRow appropriatelyLet me replace the entire doRender method with the updated version that handles:- First render
Now I need to replace the doRender method to use hardwareCursorRow for movement and position the hardware cursor. This is a larger change:
Now I need to update the Editor class to implement Focusable and emit the CURSOR_MARKER. Let me read the editor.ts file again and make the changes.
Now let me update the Editor class to implement Focusable and emit the cursor marker:
Let me find the Editor class definition and its render method.
>cd /Users/badlogic/workspaces/pi-mono && grep -n "export class Editor" packages/tui/src/components/editor.ts207:export class Editor implements Component {Let me read the Editor class definition and the beginning of the render method.
Now I need to update the Editor class to:
Focusable and CURSOR_MARKER2. Implement the Focusable interface (add focused: boolean = false)CURSOR_MARKER when focused and not autocompletingLet me first update the imports and class definition.
Now update the class definition to implement Focusable and add the focused property.
Now I need to find the render method and update it to emit CURSOR_MARKER. Let me find where the cursor is rendered.
I need to update the render method to emit CURSOR_MARKER when focused and not autocompleting. The cursor rendering happens in the loop where it processes layoutLine.hasCursor. I need to emit the marker right before the fake cursor (the \x1b[7m...\x1b[0m sequence).Let me look at the full section where the cursor is rendered and update it.
I need to update the cursor rendering section to emit CURSOR_MARKER when focused and not autocompleting:
Now I need to update the Input component similarly. Let me read its file and update it.
Now let me update the Input component similarly:
Now I need to update the Input component to:1. Import Focusable and CURSOR_MARKER2. Implement the Focusable interface3. Emit CURSOR_MARKER when focused in the render method
Now I need to update the render method to emit CURSOR_MARKER when focused.
Now let me run the check to make sure everything compiles correctly.
Now let's run the type checker to make sure everything compiles:
>cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1> pi-monorepo@0.0.3 check
> biome check --write . && tsgo --noEmit && npm run check -w @mariozechner/pi-web-ui
Checked 405 files in 264ms. Fixed 2 files.
> @mariozechner/pi-web-ui@0.46.0 check
> biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit
Checked 73 files in 49ms. No fixes applied.
Checked 3 files in 7ms. No fixes applied.Everything compiles. Now let me also add the exports for Focusable, isFocusable, and CURSOR_MARKER from the tui package's index.ts.
Let me check what's exported from the tui package and add the new exports:
I need to add Focusable, isFocusable, and CURSOR_MARKER to the exports from ./tui.js.
Now let me run check again to make sure everything is still good.
>cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1> pi-monorepo@0.0.3 check
> biome check --write . && tsgo --noEmit && npm run check -w @mariozechner/pi-web-ui
Checked 405 files in 239ms. No fixes applied.
> @mariozechner/pi-web-ui@0.46.0 check
> biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit
Checked 73 files in 36ms. No fixes applied.
Checked 3 files in 7ms. No fixes applied.Everything compiles. Now let me add the changelog entry.
Now let me add the changelog entry:
I see there's already an Unreleased section with some entries. I need to add my changes to the appropriate subsections. Let me add the hardware cursor positioning feature.
Now let me also update the docs/tui.md to document the new Focusable interface.
Now let me update the TUI documentation to document the Focusable interface:
I need to add documentation about the Focusable interface. Let me add it after the Component Interface section.
Let me run check again to make sure everything still compiles after all the changes.
Let me run check again to make sure everything is still good:
>cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1> pi-monorepo@0.0.3 check
> biome check --write . && tsgo --noEmit && npm run check -w @mariozechner/pi-web-ui
Checked 405 files in 243ms. No fixes applied.
> @mariozechner/pi-web-ui@0.46.0 check
> biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit
Checked 73 files in 40ms. No fixes applied.
Checked 3 files in 8ms. No fixes applied.All checks pass. Let me provide a summary of what was implemented.
All checks pass. Let me provide a summary of the implementation:
Focusable interface with focused: boolean propertyisFocusable() type guardCURSOR_MARKER constant (APC escape sequence \x1b_pi:c\x07)hardwareCursorRow tracking separate from cursorRowsetFocus() to manage the focused flag on componentsextractCursorPosition() to find and strip cursor marker from rendered outputpositionHardwareCursor() to move hardware cursor for IMEdoRender() to extract cursor position and position hardware cursor after renderingstop() and requestRender() to use hardwareCursorRowFocusable interfacefocused: boolean = false propertyCURSOR_MARKER when focused and not showing autocompleteFocusable interfacefocused: boolean = false propertyCURSOR_MARKER when focusedCURSOR_MARKER, Focusable, and isFocusableFocusable interfaceThe implementation uses a marker-based approach where:
CURSOR_MARKER at the cursor position in their render outputi'm on a german language keyboard, how can i test ime on macos?
The user wants to test IME (Input Method Editor) on macOS with a German keyboard. IME is typically used for CJK (Chinese, Japanese, Korean) input where you type phonetically and select from candidate characters.
On macOS, you can add input sources for different languages including CJK languages that use IME.
Let me explain how to set this up.
To test IME on macOS:
Add a CJK input source:
+ at bottom leftSwitch input sources:
Ctrl+Space or Fn+E to cycle through input sourcesTest IME:
What to look for:
Quick test:
cd /Users/badlogic/workspaces/pi-mono/packages/coding-agentnpm run dev# Switch to Japanese input (Ctrl+Space)# Type in the editorJapanese Romaji is the easiest to test since you just type Latin characters and it shows Japanese candidates.
ok, looking good, works great in editor, but not so great in @packages/coding-agent/examples/extensions/overlay-test.ts any idea?
The user is saying the hardware cursor positioning works in the main Editor but not in an overlay test. Let me read that file to understand what's happening there.
I see the issue. The OverlayTestComponent has inline text inputs with a fake cursor, but it doesn't implement the Focusable interface and doesn't emit CURSOR_MARKER.
The component:
\x1b[7m${cursorChar}\x1b[27mFocusable and doesn't emit CURSOR_MARKERTo fix this, the component needs to:
Focusablefocused: boolean = false propertyCURSOR_MARKER before the fake cursor when focusedHowever, there's a complication: this is an example extension file, not part of the core TUI. The user needs to update their overlay component to use the new interface.
But wait - the TUI sets focused on the component when it gets focus. Looking at showOverlay:
showOverlay(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 }So if the overlay component implements Focusable, it should get focused = true when the overlay is shown.
Let me update the overlay-test.ts example to implement Focusable and emit the cursor marker.
The issue is that OverlayTestComponent doesn't implement Focusable and doesn't emit CURSOR_MARKER. Let me update the example:
Now I need to update the OverlayTestComponent class to implement Focusable and emit the cursor marker.
isn't it using Input?
Let me re-read the overlay-test.ts file. The user is right - looking at the code, it's not using the Input component. It has its own inline input handling directly in the OverlayTestComponent. It renders its own fake cursor with:
inputDisplay = `${before}\x1b[7m${cursorChar}\x1b[27m${after}`;So it needs to emit the CURSOR_MARKER in that inline input rendering. Let me update that part.
You're right, it's not using the Input component - it has its own inline cursor rendering. Let me update the render method to emit the marker:
Now let me run check to make sure everything compiles.
>cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1> pi-monorepo@0.0.3 check
> biome check --write . && tsgo --noEmit && npm run check -w @mariozechner/pi-web-ui
Checked 405 files in 268ms. No fixes applied.
> @mariozechner/pi-web-ui@0.46.0 check
> biome check --write . && tsc --noEmit && cd example && biome check --write . && tsc --noEmit
Checked 73 files in 38ms. No fixes applied.
Checked 3 files in 8ms. No fixes applied.Good, everything compiles. The example should now work with IME. The key insight is that any component with an inline cursor needs to:
FocusableCURSOR_MARKER before the fake cursor when focusedIn this case, the overlay-test.ts example was rendering its own inline input with a fake cursor, so it needed both changes. The Input component from the TUI already handles this, but custom inline inputs need to do it themselves.
Now it should work. The example had its own inline cursor rendering, so it needed to:
Focusable interfaceCURSOR_MARKER before the fake cursor when this.focused is trueThis is a good example to show in the documentation - any custom component with inline text input needs to do this for IME support. Try it now with:
cd packages/coding-agentnpm run dev# then /overlay-test and switch to Japanese input╭────────────────────────────────────────────────────────────────────╮ Loaded context: │ 🧪 Overlay Test │ /Users/badlogic/│ │ /Users/badlogic/│ ─── Edge Cases (borders should align) ─── │ │ Wide: 中文日本語한글テスト漢字繁體简体ひらがなカタカナ가나다라마바 │ Loaded skills: │ Styled: RED GREEN YELLOW ACCENT DIM more colors │ /Users/badlogic/│ Emoji: 👨👩👧👦 🇯🇵 🚀 💻 🎉 🔥 😀 🎯 🌟 💡 🎨 🔧 📦 🏆 🌈 🎪 🎭 🎬 🎮 🎲 │ /Users/badlogic/│ │ /Users/badlogic/│ ─── Actions ─── │ /Users/badlogic/│ ▶ Search: ehy │ /Users/badlogic/│ Run: │ /Users/badlogic/│ Settings │ /Users/badlogic/│ Cancel │ /Users/badlogic/│ │ /Users/badlogic/│ ↑↓ navigate • type to input • Enter select • Esc cancel │ ╰────────────────────────────────────────────────────────────────────╯
hm, the border is now broken, see border marker position to the left of ehy
The issue is that the CURSOR_MARKER is being included in the visible width calculation somehow, which is causing the padding to be off. Let me look at the render method again.
The CURSOR_MARKER is \x1b_pi:c\x07 which is an APC escape sequence. It should be zero-width and not affect the visible width calculation. But it seems like something is going wrong.
Looking at the pad function in overlay-test.ts:
const pad = (s: string, len: number) => { const vis = visibleWidth(s); return s + " ".repeat(Math.max(0, len - vis));};And the row function:
const row = (content: string) => th.fg("border", "│") + pad(content, innerW) + th.fg("border", "│");So when the content includes the CURSOR_MARKER, visibleWidth should return the same value as without it. Let me check if visibleWidth handles APC sequences correctly.
Actually, looking at the code, the marker is emitted inside inputDisplay, which is then used in:
content = `${prefix + label} ${inputDisplay}`;Then content is passed to row() which pads it. So the marker is in the string being measured by visibleWidth.
The issue is likely that visibleWidth doesn't strip APC sequences. Let me check the utils.ts in tui package.
Actually, I should check what visibleWidth does. If it doesn't handle APC sequences, then the marker would be counted as having some width (or causing issues with the parsing).
Let me look at the visibleWidth implementation.
The issue is that visibleWidth() probably doesn't strip APC sequences, so the marker is affecting the width calculation. Let me check:
>cd /Users/badlogic/workspaces/pi-mono && grep -n "visibleWidth" packages/tui/src/utils.ts | head -581:export function visibleWidth(str: string): number {
473: const visibleLength = visibleWidth(line);
486: const tokenVisibleLength = visibleWidth(token);
506: currentVisibleLength = visibleWidth(currentLine);
607: const graphemeWidth = visibleWidth(grapheme);Let me look at the visibleWidth function to see what escape sequences it handles.
I see! The visibleWidth function only strips:
\x1b[...m)\x1b[...G/K/H/J)\x1b]8;;...\x07)It doesn't strip APC sequences (\x1b_...\x07 or \x1b_...\x1b\\).
I need to add stripping of APC sequences to visibleWidth. Let me update it.
Found it! visibleWidth() doesn't strip APC sequences. I need to add that:
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}`;