Skip to content

Add a side-channeled inline Cursor element - #1004

Open
dhleong wants to merge 26 commits into
vadimdemedes:masterfrom
dhleong:dhleong/silent-inline-cursor-element
Open

dhleong wants to merge 26 commits into
vadimdemedes:masterfrom
dhleong:dhleong/silent-inline-cursor-element

Conversation

@dhleong

@dhleong dhleong commented Sep 19, 2026

Copy link
Copy Markdown

This is an alternative to #872 (and replacement to #1002) that also address #870 and #251.

<Cursor /> renders inline within <Text />, following it naturally with Ink's word wraps. Wherever <Cursor /> is rendered within the output is where Ink will place the system cursor.

How

Unlike #1002, as discussed there this approach renders an ink-text element with a new internal_cursorOffset param that signals squashTextNodes to return a cursorOffset that renderNodeToOutput then uses to calculate a CursorPosition (including handling of text wrapping).

I was a little concerned about getting the cursor wrapped cleanly, following the text, but at least the cases I've tested seem to work pretty well and were less challenging to handle than expected.

Details

  • Like Add an inline Cursor element #1002, <Cursor /> is mutually exclusive with useCursor, enforced at runtime. In component mode, the absence of a rendered Cursor in the output means to hide the cursor, but useCursor will never render one in the output so clearing the component cursor would also clear any set by useCursor. There might be a clever way to use the CursorContext to indicate to Ink that a useCursor hook is mounted, but that didn't seem worth it.
  • Since we're no longer explicitly placing the cursor in this mode, it may be useful to know where it's ended up. I know you said we could skip it, but it was helpful for debugging and in unit tests, so I added an onCursorUpdated render prop. For most consumers it may be more convenient to provide the cursor position via CursorContext and a new hook, but I think users that need that could create such a thing themselves without too much trouble from this render prop.
  • I've included a cursor-ime-component example that also has basic cursor movement support via the arrow keys, for playing with the wrapping behavior.

Questions

  • I have not thought much about transforms or text truncation here. Any concerns in this area?
  • There's a fair chance there are more text wrapping edge cases not yet discovered. I've tested a few things with the example and added the ones I fixed to the unit test before opening this, but admittedly it's been a long day 😄
  • As built, we could add support for <Cursor /> wrapping <Text /> children and accepting an offset property. Is that interesting?

@sindresorhus

Copy link
Copy Markdown
Collaborator

I manually ran these cases through the renderer and checked both the reported position and the emitted cursor escape:

  • a\nb<Cursor />c renders as a\nbc, but reports {x: 2, y: 0} instead of {x: 1, y: 1}. Explicit newlines are only accounted for when automatic wrapping also happens.
  • In a four-column box, 你好你<Cursor /> renders as 你好\n你, but reports {x: 4, y: 1} instead of {x: 2, y: 1}.
  • Nested styled text has the same problem. In a three-column box, <Text><Text color="red">abcd</Text><Cursor />e</Text> renders as abc\nde, but reports {x: 4, y: 0} instead of {x: 1, y: 1}.
  • Sanitization changes the text without updating the marker offset. A\x1B[2JB<Cursor />C renders as ABC, but reports {x: 3, y: 0}, placing the cursor after C instead of between B and C.
  • Width-changing transforms move the output but not the cursor. A transform prepending prefix renders prefixab, while a cursor between a and b is still reported at x=1 instead of x=7.
  • Clipping and truncation only affect the output, not the side-channel position. A five-column clipped box renders Hello but reports x=11, and truncated Hell… also reports x=11. That can place the real cursor over unrelated UI.

The branch also currently fails its own two “cursor wraps after text” tests. The test helper treats an omitted offset as present, so it duplicates the input with the cursor between the copies. The earlier movement tests don't catch that because they check the cursor position without asserting the duplicated rendered output.

The common problem is that the cursor starts as a source string index, but sanitization and nested transforms can change the string without updating that index. It is then converted to a terminal-column count, which the wrapping code compares with JavaScript string indexes, while outer transforms can still change the visible output afterward. The source-to-output mapping has already been lost before the final position is resolved, so I don't think more offset adjustments will make this robust.

@dhleong

dhleong commented Sep 19, 2026

Copy link
Copy Markdown
Author

The docs say that Transform:

shouldn't change the dimensions of the output;

Wouldn't something that prefixes the text break that expectation?

@dhleong
dhleong force-pushed the dhleong/silent-inline-cursor-element branch from ac4e713 to 73724d8 Compare September 20, 2026 00:47
squashTextNodes now consistently returns *offsets* into the string
returned, rather than a terminal cursor position; renderNodeToOutput
handles stringWidth math instead
We're somewhat successfully handling wrapping adding length, but
handling the sanitization *removing* it may be... interesting.
@sindresorhus

Copy link
Copy Markdown
Collaborator

Wouldn't something that prefixes the text break that expectation?

Yes, you're right. Prepending prefix changes the output width, so that specific example violates the documented <Transform> contract and should be dropped.


The underlying issue still applies to dimension-preserving transforms, though. For example:

<Text>
	<Transform transform={text => `\u001B[31m${text}\u001B[39m`}>
		<Text>a<Cursor />b</Text>
	</Transform>
</Text>

This still renders ab in two columns, but currently reports x=0 instead of x=1. The cursor offset is captured before internal_transform inserts the zero-width ANSI sequences, then interpreted against the transformed string. The same issue occurs with a cursor inside a nested styled <Text>.

So width-changing transforms do not need to be supported, but the transform concern remains for styling transforms that follow the documented contract.

@dhleong

dhleong commented Sep 20, 2026

Copy link
Copy Markdown
Author

Sounds good, thank you for the clarification 👍 Still iterating here and have tweaked some approaches to handle ansi sanitization and cjk. Thinking about output clipping and transforms right now.

If the expected cursor column is clipped off, what would be the expected behavior? Render at the closest non-clipped position, or just hide it? 🤔

wrapCursorOffsetToPosition now looks at the ansi-stripped wrappedText
when computing its result.

The tricky part here is that something like `<red>abc</red>` might get
wrapped to eg:

```
\u001B[31mab\u001B[39m
\u001B[31mc\u001B[39m
```

Note the extra close and open sequences in addition to the newline added
by wrapping!

By stripping out ansi tokens from the `cursorOffset` and also when
converting from offset to `CursorPosition` we can more easily compare
apples to apples.
*Ideally*, the cursor should probably stay wherever the truncated
part is---which means if we truncate in the middle, and the cursor is
somewhere in that truncated area, it should land on the ellipsis instead
of in the "after" part of the truncation
This *may* be a bit jank... but seems maybe okay? We're reusing the
existing stringWidth computations so it's pretty cheap
Discovered in manual testing that our example IME component wasn't
placing the cursor correctly anymore. Was definitely trying to be too
clever with the "consume x columns" approach before—this version is both
simpler to follow (and better matches the docstring on cursorOffset!)
and is more correct.
We were using the wrapped x offset to determine truncation, but
comparing against a range of the original text; we should use
cursorOffset instead
@dhleong

dhleong commented Sep 21, 2026

Copy link
Copy Markdown
Author

For now, I've opted to hide the cursor if it gets clipped and clamp the cursor to the ellipsis when the text is truncated, but I think clamping on clip would be straightforward enough to implement that if it's a behavior enough folks need, it could be selected via a prop on the Cursor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants