Skip to content

Commit 9bb327c

Browse files
igorcostaAutohand Evolve
andcommitted
Use Ink cursor without rendered composer cursor fallback
Co-authored-by: Autohand Evolve <code-noreply@autohand.ai>
1 parent 91a2171 commit 9bb327c

4 files changed

Lines changed: 40 additions & 58 deletions

File tree

src/ui/ink/InputLine.tsx

Lines changed: 2 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Copyright 2025 Autohand AI LLC
44
* SPDX-License-Identifier: Apache-2.0
55
*/
6-
import React, { useEffect, useMemo, useRef, useState } from 'react';
6+
import React, { useMemo, useRef } from 'react';
77
import { Box, Text, useCursor, type DOMElement } from 'ink';
88
import { useTheme } from '../theme/ThemeContext.js';
99
import { buildMultiLineRenderState } from '../inputPrompt.js';
@@ -38,23 +38,6 @@ function getAbsoluteInkPosition(
3838
return { left, top };
3939
}
4040

41-
function renderHardwareCursorFallback(line: string, cursorColumn: number): string {
42-
if (cursorColumn < 0 || cursorColumn >= line.length) {
43-
return line;
44-
}
45-
46-
// Some terminals let Ink hide the hardware cursor after redraws, so keep a
47-
// visible cursor cell without dropping the character at the cursor offset.
48-
const rightBorder = line.slice(-1);
49-
const beforeRightBorder = line.slice(0, -1);
50-
const shiftedContent = beforeRightBorder.slice(
51-
cursorColumn,
52-
Math.max(cursorColumn, beforeRightBorder.length - 1)
53-
);
54-
55-
return `${beforeRightBorder.slice(0, cursorColumn)}${shiftedContent}${rightBorder}`;
56-
}
57-
5841
export interface InputLineProps {
5942
value: string;
6043
cursorOffset: number;
@@ -84,7 +67,6 @@ function InputLineComponent({
8467
const { theme } = useTheme();
8568
const rootRef = useRef<DOMElement>(null);
8669
const { setCursorPosition } = useCursor();
87-
const [cursorVisible, setCursorVisible] = useState(true);
8870

8971
const borderToken = borderStyle === 'plan'
9072
? 'warning'
@@ -120,25 +102,6 @@ function InputLineComponent({
120102
};
121103
}, [value, cursorOffset, width, borderStyle, placeholderText, nextPromptSuggestion, inlineGhostSuffix]);
122104

123-
useEffect(() => {
124-
if (!isActive) {
125-
setCursorVisible(true);
126-
return;
127-
}
128-
129-
const timer = setInterval(() => {
130-
setCursorVisible((visible) => !visible);
131-
}, 530);
132-
133-
return () => {
134-
clearInterval(timer);
135-
};
136-
}, [isActive]);
137-
138-
useEffect(() => {
139-
setCursorVisible(true);
140-
}, [value, cursorOffset]);
141-
142105
const cursorPosition = (() => {
143106
if (!isActive) {
144107
return undefined;
@@ -153,18 +116,6 @@ function InputLineComponent({
153116

154117
setCursorPosition(cursorPosition);
155118

156-
const renderedLines = useMemo(() => {
157-
if (!isActive || !cursorVisible) {
158-
return displayData.plainLines;
159-
}
160-
161-
return displayData.plainLines.map((line, index) => (
162-
index === displayData.cursorRow
163-
? renderHardwareCursorFallback(line, displayData.cursorColumn)
164-
: line
165-
));
166-
}, [cursorVisible, displayData.cursorColumn, displayData.cursorRow, displayData.plainLines, isActive]);
167-
168119
const renderContentLine = (line: string, index: number) => {
169120
return (
170121
<Text key={index}>
@@ -186,7 +137,7 @@ function InputLineComponent({
186137
return (
187138
<Box ref={rootRef} flexDirection="column">
188139
<Text>{theme.fgBg(borderToken, 'userMessageBg', borders.top)}</Text>
189-
{renderedLines.map(renderContentLine)}
140+
{displayData.plainLines.map(renderContentLine)}
190141
<Text>{theme.fgBg(borderToken, 'userMessageBg', borders.bottom)}</Text>
191142
</Box>
192143
);

tests/tuistory/built-cli.tuistory.test.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,26 @@ function expectCursorAfterTypedText(screen: string, typedText: string): void {
5757
expect(cursorColumn, screen).toBeGreaterThanOrEqual(textColumn + typedText.length);
5858
}
5959

60+
async function waitForInkCursorSequenceAfterTypedText(session: Session, typedText: string): Promise<void> {
61+
const deadline = Date.now() + 2_000;
62+
const cursorColumn = typedText.length + 4;
63+
const expectedSequence = `\u001b[${cursorColumn}G\u001b[?25h`;
64+
let rawTail = '';
65+
66+
while (Date.now() < deadline) {
67+
await session.waitIdle({ timeout: 15 }).catch(() => undefined);
68+
rawTail = session.getRawOutput().slice(-2_000);
69+
70+
if (rawTail.includes(expectedSequence)) {
71+
return;
72+
}
73+
74+
await new Promise((resolve) => setTimeout(resolve, 25));
75+
}
76+
77+
expect(rawTail).toContain(expectedSequence);
78+
}
79+
6080
function composerLineIncludes(screen: string, text: string): boolean {
6181
return screen.split('\n').some((line) => line.includes('│❯') && line.includes(text));
6282
}
@@ -144,7 +164,7 @@ describe('interactive built CLI Tuistory tests', () => {
144164
await exitInteractive(session);
145165
});
146166

147-
it('keeps the real terminal cursor at the typed prompt position while composing', async () => {
167+
it('keeps only the real terminal cursor at the typed prompt position while composing', async () => {
148168
const session = await launchInteractive({
149169
config: {
150170
ui: {
@@ -162,12 +182,22 @@ describe('interactive built CLI Tuistory tests', () => {
162182
const screen = await session.text({
163183
timeout: 2_000,
164184
waitFor: (text) => composerLineIncludes(text, typedPrefix),
165-
showCursor: true,
166185
trimEnd: true,
167186
});
168187

169188
expect(screen).toContain(typedPrefix);
170-
expectCursorAfterTypedText(screen, typedPrefix);
189+
expect(screen).not.toContain(CURSOR_CHAR);
190+
191+
await waitForInkCursorSequenceAfterTypedText(session, typedPrefix);
192+
193+
const cursorScreen = await session.text({
194+
immediate: true,
195+
showCursor: true,
196+
trimEnd: true,
197+
});
198+
if (cursorScreen.includes(CURSOR_CHAR)) {
199+
expectCursorAfterTypedText(cursorScreen, typedPrefix);
200+
}
171201
}
172202

173203
await exitInteractive(session);

tests/ui/ink/AgentUI.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { ThemeProvider } from '../../../src/ui/theme/ThemeContext.js';
2727
import { getPromptBlockWidth } from '../../../src/ui/inputPrompt.js';
2828

2929
function stripAnsi(value: string): string {
30-
return value.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(//g, '');
30+
return value.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '');
3131
}
3232

3333
function setStdoutColumns(stdout: { columns: number; rows?: number }, columns: number): void {

tests/ui/ink/InputLine.test.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { ThemeProvider } from '../../../src/ui/theme/ThemeContext.js';
1515
import { initTheme } from '../../../src/ui/theme/index.js';
1616

1717
function stripAnsi(value: string): string {
18-
return value.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '').replace(//g, '');
18+
return value.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '');
1919
}
2020

2121
function renderInputLine(value: string) {
@@ -176,15 +176,16 @@ describe('InputLine themed variants', () => {
176176
expect(source).toContain("theme.fgBg(borderToken, 'userMessageBg', borders.bottom)");
177177
});
178178

179-
it('uses Ink cursor positioning with a rendered fallback cursor instead of inverse text', () => {
179+
it('uses Ink cursor positioning without rendering a competing cursor glyph', () => {
180180
const source = readFileSync(
181181
path.resolve(process.cwd(), 'src/ui/ink/InputLine.tsx'),
182182
'utf8'
183183
);
184184

185185
expect(source).toContain('useCursor');
186186
expect(source).toContain('setCursorPosition');
187-
expect(source).toContain('renderHardwareCursorFallback');
187+
expect(source).not.toContain('renderHardwareCursorFallback');
188+
expect(source).not.toContain('█');
188189
expect(source).not.toContain('<Text inverse>');
189190
});
190191

0 commit comments

Comments
 (0)