Skip to content

Commit 557b029

Browse files
authored
Fix handling of terminal resize (#828)
1 parent 69813b4 commit 557b029

File tree

4 files changed

+217
-3
lines changed

4 files changed

+217
-3
lines changed

examples/terminal-resize/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import './terminal-resize.js';
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import React, {useState} from 'react';
2+
import {render, Box, Text, useInput} from '../../src/index.js';
3+
4+
function TerminalResizeTest() {
5+
const [value, setValue] = useState('');
6+
7+
useInput(input => {
8+
if (input === '\r') {
9+
// Enter key - clear input
10+
setValue('');
11+
} else if (input === '\u007F' || input === '\b') {
12+
// Backspace
13+
setValue(previous => previous.slice(0, -1));
14+
} else {
15+
// Regular character
16+
setValue(previous => previous + input);
17+
}
18+
});
19+
20+
return (
21+
<Box flexDirection="column" padding={1}>
22+
<Text bold color="cyan">
23+
=== Terminal Resize Test ===
24+
</Text>
25+
<Text>
26+
Type something and then resize your terminal (drag the edge or press
27+
Cmd/Ctrl -/+)
28+
</Text>
29+
<Text>Input: "{value}"</Text>
30+
<Box marginTop={1}>
31+
<Text dimColor>Press Ctrl+C to exit</Text>
32+
</Box>
33+
</Box>
34+
);
35+
}
36+
37+
render(<TerminalResizeTest />, {
38+
patchConsole: true,
39+
exitOnCtrlC: true,
40+
});

src/ink.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export default class Ink {
5454
private isUnmounted: boolean;
5555
private lastOutput: string;
5656
private lastOutputHeight: number;
57+
private lastTerminalWidth: number;
5758
private readonly container: FiberRoot;
5859
private readonly rootNode: dom.DOMElement;
5960
// This variable is used only in debug mode to store full static output
@@ -103,6 +104,7 @@ export default class Ink {
103104
// Store last output to only rerender when needed
104105
this.lastOutput = '';
105106
this.lastOutputHeight = 0;
107+
this.lastTerminalWidth = this.getTerminalWidth();
106108

107109
// This variable is used only in debug mode to store full static output
108110
// so that it's rerendered every time, not just new static parts, like in non-debug mode
@@ -149,19 +151,33 @@ export default class Ink {
149151
}
150152
}
151153

154+
getTerminalWidth = () => {
155+
// The 'columns' property can be undefined or 0 when not using a TTY.
156+
// In that case we fall back to 80.
157+
return this.options.stdout.columns || 80;
158+
};
159+
152160
resized = () => {
161+
const currentWidth = this.getTerminalWidth();
162+
163+
if (currentWidth < this.lastTerminalWidth) {
164+
// We clear the screen when decreasing terminal width to prevent duplicate overlapping re-renders.
165+
this.log.clear();
166+
this.lastOutput = '';
167+
}
168+
153169
this.calculateLayout();
154170
this.onRender();
171+
172+
this.lastTerminalWidth = currentWidth;
155173
};
156174

157175
resolveExitPromise: () => void = () => {};
158176
rejectExitPromise: (reason?: Error) => void = () => {};
159177
unsubscribeExit: () => void = () => {};
160178

161179
calculateLayout = () => {
162-
// The 'columns' property can be undefined or 0 when not using a TTY.
163-
// In that case we fall back to 80.
164-
const terminalWidth = this.options.stdout.columns || 80;
180+
const terminalWidth = this.getTerminalWidth();
165181

166182
this.rootNode.yogaNode!.setWidth(terminalWidth);
167183

test/terminal-resize.tsx

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import test from 'ava';
2+
import delay from 'delay';
3+
import stripAnsi from 'strip-ansi';
4+
import React from 'react';
5+
import {render, Box, Text} from '../src/index.js';
6+
import createStdout from './helpers/create-stdout.js';
7+
8+
test.serial('clear screen when terminal width decreases', async t => {
9+
const stdout = createStdout(100);
10+
11+
function Test() {
12+
return (
13+
<Box borderStyle="round">
14+
<Text>Hello World</Text>
15+
</Box>
16+
);
17+
}
18+
19+
render(<Test />, {stdout});
20+
21+
const initialOutput = stripAnsi(
22+
(stdout.write as any).firstCall.args[0] as string,
23+
);
24+
t.true(initialOutput.includes('Hello World'));
25+
t.true(initialOutput.includes('╭')); // Box border
26+
27+
// Decrease width - should trigger clear and rerender
28+
stdout.columns = 50;
29+
stdout.emit('resize');
30+
await delay(100);
31+
32+
// Verify the output was updated for smaller width
33+
const lastOutput = stripAnsi(
34+
(stdout.write as any).lastCall.args[0] as string,
35+
);
36+
t.true(lastOutput.includes('Hello World'));
37+
t.true(lastOutput.includes('╭')); // Box border
38+
t.not(initialOutput, lastOutput); // Output should change due to width
39+
});
40+
41+
test.serial('no screen clear when terminal width increases', async t => {
42+
const stdout = createStdout(50);
43+
44+
function Test() {
45+
return (
46+
<Box borderStyle="round">
47+
<Text>Test</Text>
48+
</Box>
49+
);
50+
}
51+
52+
render(<Test />, {stdout});
53+
54+
const initialOutput = (stdout.write as any).firstCall.args[0] as string;
55+
56+
// Increase width - should rerender but not clear
57+
stdout.columns = 100;
58+
stdout.emit('resize');
59+
await delay(100);
60+
61+
const lastOutput = (stdout.write as any).lastCall.args[0] as string;
62+
63+
// When increasing width, we don't clear, so we should see eraseLines used for incremental update
64+
// But when decreasing, the clear() is called which also uses eraseLines
65+
// The key difference: decreasing width triggers an explicit clear before render
66+
t.not(stripAnsi(initialOutput), stripAnsi(lastOutput));
67+
t.true(stripAnsi(lastOutput).includes('Test'));
68+
});
69+
70+
test.serial(
71+
'consecutive width decreases trigger screen clear each time',
72+
async t => {
73+
const stdout = createStdout(100);
74+
75+
function Test() {
76+
return (
77+
<Box borderStyle="round">
78+
<Text>Content</Text>
79+
</Box>
80+
);
81+
}
82+
83+
render(<Test />, {stdout});
84+
85+
const initialOutput = stripAnsi(
86+
(stdout.write as any).firstCall.args[0] as string,
87+
);
88+
89+
// First decrease
90+
stdout.columns = 80;
91+
stdout.emit('resize');
92+
await delay(100);
93+
94+
const afterFirstDecrease = stripAnsi(
95+
(stdout.write as any).lastCall.args[0] as string,
96+
);
97+
t.not(initialOutput, afterFirstDecrease);
98+
t.true(afterFirstDecrease.includes('Content'));
99+
100+
// Second decrease
101+
stdout.columns = 60;
102+
stdout.emit('resize');
103+
await delay(100);
104+
105+
const afterSecondDecrease = stripAnsi(
106+
(stdout.write as any).lastCall.args[0] as string,
107+
);
108+
t.not(afterFirstDecrease, afterSecondDecrease);
109+
t.true(afterSecondDecrease.includes('Content'));
110+
},
111+
);
112+
113+
test.serial('width decrease clears lastOutput to force rerender', async t => {
114+
const stdout = createStdout(100);
115+
116+
function Test() {
117+
return (
118+
<Box borderStyle="round">
119+
<Text>Test Content</Text>
120+
</Box>
121+
);
122+
}
123+
124+
const {rerender} = render(<Test />, {stdout});
125+
126+
const initialOutput = stripAnsi(
127+
(stdout.write as any).firstCall.args[0] as string,
128+
);
129+
130+
// Decrease width - with a border, this will definitely change the output
131+
stdout.columns = 50;
132+
stdout.emit('resize');
133+
await delay(100);
134+
135+
const afterResizeOutput = stripAnsi(
136+
(stdout.write as any).lastCall.args[0] as string,
137+
);
138+
139+
// Outputs should be different because the border width changed
140+
t.not(initialOutput, afterResizeOutput);
141+
t.true(afterResizeOutput.includes('Test Content'));
142+
143+
// Now try to rerender with a different component
144+
rerender(
145+
<Box borderStyle="round">
146+
<Text>Updated Content</Text>
147+
</Box>,
148+
);
149+
await delay(100);
150+
151+
// Verify content was updated
152+
t.true(
153+
stripAnsi((stdout.write as any).lastCall.args[0] as string).includes(
154+
'Updated Content',
155+
),
156+
);
157+
});

0 commit comments

Comments
 (0)