Skip to content

Commit a34ccd1

Browse files
committed
Merge branch 'master' into feat/cursor-shape
2 parents d5ba136 + 25766ae commit a34ccd1

16 files changed

Lines changed: 950 additions & 14 deletions

examples/suspend-terminal/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import './suspend-terminal.js';
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import process from 'node:process';
2+
import {spawn} from 'node:child_process';
3+
import React, {useState} from 'react';
4+
import {render, Text, Box, useApp, useInput} from '../../src/index.js';
5+
6+
const runChild = async (command: string, args: string[]): Promise<void> =>
7+
new Promise((resolve, reject) => {
8+
// With stdio: 'inherit' the child takes full ownership of the terminal, which
9+
// is exactly why Ink must release it via suspendTerminal first.
10+
const child = spawn(command, args, {stdio: 'inherit'});
11+
child.on('exit', () => {
12+
resolve();
13+
});
14+
child.on('error', reject);
15+
});
16+
17+
function Example() {
18+
const {suspendTerminal, exit} = useApp();
19+
const [counter, setCounter] = useState(0);
20+
const [status, setStatus] = useState('ready');
21+
22+
useInput(input => {
23+
if (input === 'q') {
24+
exit();
25+
return;
26+
}
27+
28+
if (input === '+') {
29+
setCounter(value => value + 1);
30+
return;
31+
}
32+
33+
if (input === 'e' || input === 'r') {
34+
void (async () => {
35+
setStatus('suspended — child owns the terminal');
36+
37+
try {
38+
await suspendTerminal(async () => {
39+
if (input === 'e') {
40+
const editor = process.env.EDITOR ?? 'vi';
41+
await runChild(editor, []);
42+
} else {
43+
await runChild('sh', [
44+
'-c',
45+
String.raw`printf "Child process owns the terminal.\nType something and press Enter: "; read -r line; printf "You typed: %s\n" "$line"`,
46+
]);
47+
}
48+
});
49+
50+
setStatus('resumed — Ink redrew and the counter is preserved');
51+
} catch (error) {
52+
setStatus(`child failed: ${(error as Error).message}`);
53+
}
54+
})();
55+
}
56+
});
57+
58+
return (
59+
<Box flexDirection="column" borderStyle="round" paddingX={1}>
60+
<Text bold>suspendTerminal() demo</Text>
61+
<Text>
62+
Counter: <Text color="green">{counter}</Text>
63+
</Text>
64+
<Text dimColor>{status}</Text>
65+
<Box marginTop={1} flexDirection="column">
66+
<Text>e — open $EDITOR (defaults to vi)</Text>
67+
<Text>r — run a shell read prompt</Text>
68+
<Text>+ — increment the counter</Text>
69+
<Text>q — quit</Text>
70+
</Box>
71+
</Box>
72+
);
73+
}
74+
75+
render(<Example />);

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ink",
3-
"version": "7.0.4",
3+
"version": "7.1.0",
44
"description": "React for CLI",
55
"license": "MIT",
66
"repository": "vadimdemedes/ink",
@@ -102,7 +102,7 @@
102102
"strip-ansi": "^7.2.0",
103103
"tsx": "^4.21.0",
104104
"typescript": "^5.8.3",
105-
"xo": "^1.2.3"
105+
"xo": "^2.0.2"
106106
},
107107
"peerDependencies": {
108108
"@types/react": ">=19.2.0",

readme.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1270,7 +1270,11 @@ Accepts the same values as [`backgroundColor`](#backgroundcolor) in `<Text>` com
12701270
Falls back to `borderBackgroundColor` if not specified.
12711271

12721272
```jsx
1273-
<Box borderStyle="round" borderColor="white" borderBottomBackgroundColor="green">
1273+
<Box
1274+
borderStyle="round"
1275+
borderColor="white"
1276+
borderBottomBackgroundColor="green"
1277+
>
12741278
<Text>Hello world</Text>
12751279
</Box>
12761280
```
@@ -1950,6 +1954,51 @@ const Example = () => {
19501954
};
19511955
```
19521956

1957+
#### suspendTerminal(callback?)
1958+
1959+
Type: `Function`
1960+
1961+
Temporarily hands the terminal over to a child process (such as `$EDITOR`, `less`, or `fzf`), then restores Ink's terminal state and forces a full redraw.
1962+
1963+
While suspended, Ink stops writing output, stops consuming input, and restores the terminal modes the child expects (raw mode off, cursor visible, bracketed paste off, alternate screen exited, kitty keyboard protocol off). When the suspension ends, Ink reapplies its own terminal state and repaints from scratch.
1964+
1965+
##### callback
1966+
1967+
Type: `Function`
1968+
1969+
When a callback is provided, Ink suspends, runs the callback, and restores the terminal once it settles, even if the callback throws.
1970+
1971+
```js
1972+
import {useApp} from 'ink';
1973+
1974+
const {suspendTerminal} = useApp();
1975+
1976+
await suspendTerminal(async () => {
1977+
await runEditor();
1978+
});
1979+
```
1980+
1981+
When called without a callback, it returns a suspension you resume yourself. `resume()` is async because restoring the terminal may need to wait for ordered writes and a full redraw.
1982+
1983+
```js
1984+
const suspension = await suspendTerminal();
1985+
1986+
try {
1987+
await runEditor();
1988+
} finally {
1989+
await suspension.resume();
1990+
}
1991+
```
1992+
1993+
The suspension is also a disposable, so it can be resumed automatically with `await using`:
1994+
1995+
```js
1996+
await using suspension = await suspendTerminal();
1997+
await runEditor();
1998+
```
1999+
2000+
Suspending while a suspension is already active throws. This is supported only in interactive TTY mode; in non-interactive output the callback still runs but Ink performs no terminal handoff.
2001+
19532002
### useStdin()
19542003

19552004
A React hook that returns the stdin stream and stdin-related utilities.

src/components/App.tsx

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import React, {
77
useCallback,
88
useMemo,
99
useEffect,
10+
useInsertionEffect,
1011
} from 'react';
1112
import cliCursor from 'cli-cursor';
1213
import {type CursorPosition, type CursorShape} from '../log-update.js';
1314
import {createInputParser} from '../input-parser.js';
14-
import AppContext from './AppContext.js';
15+
import AppContext, {type SuspendTerminal} from './AppContext.js';
1516
import StdinContext from './StdinContext.js';
1617
import StdoutContext from './StdoutContext.js';
1718
import StderrContext from './StderrContext.js';
@@ -41,6 +42,11 @@ type Props = {
4142
readonly exitOnCtrlC: boolean;
4243
readonly onExit: (errorOrResult?: unknown) => void;
4344
readonly onWaitUntilRenderFlush: () => Promise<void>;
45+
readonly onSuspendTerminal: SuspendTerminal;
46+
readonly onRegisterInputControl: (
47+
pauseInput: () => void,
48+
resumeInput: () => void,
49+
) => void;
4450
readonly setCursorPosition: (position: CursorPosition | undefined) => void;
4551
readonly setCursorShape: (shape: CursorShape | undefined) => void;
4652
readonly interactive: boolean;
@@ -65,6 +71,8 @@ function App({
6571
exitOnCtrlC,
6672
onExit,
6773
onWaitUntilRenderFlush,
74+
onSuspendTerminal,
75+
onRegisterInputControl,
6876
setCursorPosition,
6977
setCursorShape,
7078
interactive,
@@ -405,6 +413,60 @@ function App({
405413
[stdout],
406414
);
407415

416+
// Remembers which input modes were active so resumeInput can reinstate exactly
417+
// those after a terminal suspension, without touching the ref counts (the React
418+
// components still "own" raw mode/bracketed paste across the suspension).
419+
const suspendedInputStateRef = useRef({
420+
rawMode: false,
421+
bracketedPaste: false,
422+
});
423+
424+
const pauseInput = useCallback((): void => {
425+
const wasRawMode = isRawModeSupported && rawModeEnabledCount.current > 0;
426+
const wasBracketedPaste = bracketedPasteModeEnabledCount.current > 0;
427+
suspendedInputStateRef.current = {
428+
rawMode: wasRawMode,
429+
bracketedPaste: wasBracketedPaste,
430+
};
431+
432+
if (wasBracketedPaste && stdout.isTTY) {
433+
try {
434+
stdout.write('\u001B[?2004l');
435+
} catch {}
436+
}
437+
438+
if (wasRawMode) {
439+
stdin.setRawMode(false);
440+
stdin.unref();
441+
clearInputState();
442+
}
443+
}, [isRawModeSupported, stdin, stdout, clearInputState]);
444+
445+
const resumeInput = useCallback((): void => {
446+
const {rawMode, bracketedPaste} = suspendedInputStateRef.current;
447+
448+
if (rawMode) {
449+
stdin.setEncoding('utf8');
450+
stdin.ref();
451+
stdin.setRawMode(true);
452+
attachReadableListener();
453+
}
454+
455+
if (bracketedPaste && stdout.isTTY) {
456+
try {
457+
stdout.write('\u001B[?2004h');
458+
} catch {}
459+
}
460+
}, [stdin, stdout, attachReadableListener]);
461+
462+
// Register input pause/resume in an insertion effect: it runs before every
463+
// passive effect (parent and child), so a child that calls suspendTerminal()
464+
// from its own effect always finds the input control already registered. A
465+
// normal effect would run too late (child effects fire before the parent's).
466+
useInsertionEffect(() => {
467+
onRegisterInputControl(pauseInput, resumeInput);
468+
}, [onRegisterInputControl, pauseInput, resumeInput]);
469+
408470
// Focus navigation helpers
409471
const findNextFocusable = useCallback(
410472
(
@@ -647,8 +709,9 @@ function App({
647709
() => ({
648710
exit: handleExit,
649711
waitUntilRenderFlush: onWaitUntilRenderFlush,
712+
suspendTerminal: onSuspendTerminal,
650713
}),
651-
[handleExit, onWaitUntilRenderFlush],
714+
[handleExit, onWaitUntilRenderFlush, onSuspendTerminal],
652715
);
653716

654717
const stdinContextValue = useMemo(

src/components/AppContext.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
import {createContext} from 'react';
22

3+
/**
4+
A handle returned by `suspendTerminal()` when called without a callback.
5+
6+
Call `resume()` to give terminal ownership back to Ink, or use `await using`
7+
so the suspension is resumed automatically when it leaves scope.
8+
*/
9+
export type TerminalSuspension = {
10+
readonly resume: () => Promise<void>;
11+
readonly [Symbol.asyncDispose]: () => Promise<void>;
12+
};
13+
14+
/**
15+
Temporarily hand the terminal over to a child process (e.g. `$EDITOR`, `less`,
16+
`fzf`), then restore Ink's terminal state and force a full redraw.
17+
*/
18+
export type SuspendTerminal = {
19+
(callback: () => void | Promise<void>): Promise<void>;
20+
(): Promise<TerminalSuspension>;
21+
};
22+
323
export type Props = {
424
/**
525
Exit (unmount) the whole Ink app.
@@ -33,15 +53,56 @@ export type Props = {
3353
```
3454
*/
3555
readonly waitUntilRenderFlush: () => Promise<void>;
56+
57+
/**
58+
Temporarily release the terminal so a child process can take it over, then
59+
restore Ink's terminal state and force a full redraw.
60+
61+
Use the callback form for the common case — Ink restores the terminal even
62+
if the callback throws:
63+
64+
@example
65+
```jsx
66+
import {useApp} from 'ink';
67+
68+
const {suspendTerminal} = useApp();
69+
70+
await suspendTerminal(async () => {
71+
await runEditor();
72+
});
73+
```
74+
75+
Or hold a suspension and resume it yourself:
76+
77+
@example
78+
```jsx
79+
await using suspension = await suspendTerminal();
80+
await runEditor();
81+
```
82+
*/
83+
readonly suspendTerminal: SuspendTerminal;
3684
};
3785

3886
/**
3987
`AppContext` is a React context that exposes lifecycle methods for the app.
4088
*/
4189
// Keep the default value typed so `useApp()` preserves the public `exit(errorOrResult?)` signature.
90+
const noopSuspension: TerminalSuspension = {
91+
async resume() {},
92+
async [Symbol.asyncDispose]() {},
93+
};
94+
4295
const defaultValue: Props = {
4396
exit(_errorOrResult?: Error | unknown) {},
4497
async waitUntilRenderFlush() {},
98+
suspendTerminal: (async (callback?: () => void | Promise<void>) => {
99+
if (callback) {
100+
await callback();
101+
return undefined;
102+
}
103+
104+
return noopSuspension;
105+
}) as SuspendTerminal,
45106
};
46107

47108
// eslint-disable-next-line @typescript-eslint/naming-convention

src/components/ErrorOverview.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export default function ErrorOverview({error}: Props) {
2727
const filePath = cleanupPath(origin?.file);
2828
let excerpt: CodeExcerpt[] | undefined;
2929
let lineWidth = 0;
30+
const stackLineCounts = new Map<string, number>();
3031

3132
if (filePath && origin?.line && fs.existsSync(filePath)) {
3233
const sourceCode = fs.readFileSync(filePath, 'utf8');
@@ -96,11 +97,16 @@ export default function ErrorOverview({error}: Props) {
9697
.slice(1)
9798
.map(line => {
9899
const parsedLine = stackUtils.parseLine(line);
100+
const lineCount = stackLineCounts.get(line) ?? 0;
101+
stackLineCounts.set(line, lineCount + 1);
102+
const key = `${line}-${lineCount}`;
99103

100-
// If the line from the stack cannot be parsed, we print out the unparsed line.
101-
if (!parsedLine) {
104+
// If the line from the stack cannot be parsed, or parsed into an incomplete
105+
// frame without source location data (for example, "at native"), we print
106+
// out the unparsed line.
107+
if (!parsedLine?.file || !parsedLine.line || !parsedLine.column) {
102108
return (
103-
<Box key={line}>
109+
<Box key={key}>
104110
<Text dimColor>- </Text>
105111
<Text dimColor bold>
106112
{line}
@@ -111,7 +117,7 @@ export default function ErrorOverview({error}: Props) {
111117
}
112118

113119
return (
114-
<Box key={line}>
120+
<Box key={key}>
115121
<Text dimColor>- </Text>
116122
<Text dimColor bold>
117123
{parsedLine.function}

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export {default as Spacer} from './components/Spacer.js';
2020
export type {Key} from './hooks/use-input.js';
2121
export {default as useInput} from './hooks/use-input.js';
2222
export {default as usePaste} from './hooks/use-paste.js';
23+
export type {
24+
SuspendTerminal,
25+
TerminalSuspension,
26+
} from './components/AppContext.js';
2327
export {default as useApp} from './hooks/use-app.js';
2428
export {default as useStdin} from './hooks/use-stdin.js';
2529
export {default as useStdout} from './hooks/use-stdout.js';

0 commit comments

Comments
 (0)