Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/suspend-terminal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './suspend-terminal.js';
75 changes: 75 additions & 0 deletions examples/suspend-terminal/suspend-terminal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import process from 'node:process';
import {spawn} from 'node:child_process';
import React, {useState} from 'react';
import {render, Text, Box, useApp, useInput} from '../../src/index.js';

const runChild = async (command: string, args: string[]): Promise<void> =>
new Promise((resolve, reject) => {
// With stdio: 'inherit' the child takes full ownership of the terminal, which
// is exactly why Ink must release it via suspendTerminal first.
const child = spawn(command, args, {stdio: 'inherit'});
child.on('exit', () => {
resolve();
});
child.on('error', reject);
});

function Example() {
const {suspendTerminal, exit} = useApp();
const [counter, setCounter] = useState(0);
const [status, setStatus] = useState('ready');

useInput(input => {
if (input === 'q') {
exit();
return;
}

if (input === '+') {
setCounter(value => value + 1);
return;
}

if (input === 'e' || input === 'r') {
void (async () => {
setStatus('suspended — child owns the terminal');

try {
await suspendTerminal(async () => {
if (input === 'e') {
const editor = process.env.EDITOR ?? 'vi';
await runChild(editor, []);
} else {
await runChild('sh', [
'-c',
String.raw`printf "Child process owns the terminal.\nType something and press Enter: "; read -r line; printf "You typed: %s\n" "$line"`,
]);
}
});

setStatus('resumed — Ink redrew and the counter is preserved');
} catch (error) {
setStatus(`child failed: ${(error as Error).message}`);
}
})();
}
});

return (
<Box flexDirection="column" borderStyle="round" paddingX={1}>
<Text bold>suspendTerminal() demo</Text>
<Text>
Counter: <Text color="green">{counter}</Text>
</Text>
<Text dimColor>{status}</Text>
<Box marginTop={1} flexDirection="column">
<Text>e — open $EDITOR (defaults to vi)</Text>
<Text>r — run a shell read prompt</Text>
<Text>+ — increment the counter</Text>
<Text>q — quit</Text>
</Box>
</Box>
);
}

render(<Example />);
51 changes: 50 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -1270,7 +1270,11 @@ Accepts the same values as [`backgroundColor`](#backgroundcolor) in `<Text>` com
Falls back to `borderBackgroundColor` if not specified.

```jsx
<Box borderStyle="round" borderColor="white" borderBottomBackgroundColor="green">
<Box
borderStyle="round"
borderColor="white"
borderBottomBackgroundColor="green"
>
<Text>Hello world</Text>
</Box>
```
Expand Down Expand Up @@ -1950,6 +1954,51 @@ const Example = () => {
};
```

#### suspendTerminal(callback?)

Type: `Function`

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.

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.

##### callback

Type: `Function`

When a callback is provided, Ink suspends, runs the callback, and restores the terminal once it settles, even if the callback throws.

```js
import {useApp} from 'ink';

const {suspendTerminal} = useApp();

await suspendTerminal(async () => {
await runEditor();
});
```

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.

```js
const suspension = await suspendTerminal();

try {
await runEditor();
} finally {
await suspension.resume();
}
```

The suspension is also a disposable, so it can be resumed automatically with `await using`:

```js
await using suspension = await suspendTerminal();
await runEditor();
```

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.

### useStdin()

A React hook that returns the stdin stream and stdin-related utilities.
Expand Down
67 changes: 65 additions & 2 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import React, {
useCallback,
useMemo,
useEffect,
useInsertionEffect,
} from 'react';
import cliCursor from 'cli-cursor';
import {type CursorPosition} from '../log-update.js';
import {createInputParser} from '../input-parser.js';
import AppContext from './AppContext.js';
import AppContext, {type SuspendTerminal} from './AppContext.js';
import StdinContext from './StdinContext.js';
import StdoutContext from './StdoutContext.js';
import StderrContext from './StderrContext.js';
Expand Down Expand Up @@ -41,6 +42,11 @@ type Props = {
readonly exitOnCtrlC: boolean;
readonly onExit: (errorOrResult?: unknown) => void;
readonly onWaitUntilRenderFlush: () => Promise<void>;
readonly onSuspendTerminal: SuspendTerminal;
readonly onRegisterInputControl: (
pauseInput: () => void,
resumeInput: () => void,
) => void;
readonly setCursorPosition: (position: CursorPosition | undefined) => void;
readonly interactive: boolean;
readonly renderThrottleMs: number;
Expand All @@ -64,6 +70,8 @@ function App({
exitOnCtrlC,
onExit,
onWaitUntilRenderFlush,
onSuspendTerminal,
onRegisterInputControl,
setCursorPosition,
interactive,
renderThrottleMs,
Expand Down Expand Up @@ -403,6 +411,60 @@ function App({
[stdout],
);

// Remembers which input modes were active so resumeInput can reinstate exactly
// those after a terminal suspension, without touching the ref counts (the React
// components still "own" raw mode/bracketed paste across the suspension).
const suspendedInputStateRef = useRef({
rawMode: false,
bracketedPaste: false,
});

const pauseInput = useCallback((): void => {
const wasRawMode = isRawModeSupported && rawModeEnabledCount.current > 0;
const wasBracketedPaste = bracketedPasteModeEnabledCount.current > 0;
suspendedInputStateRef.current = {
rawMode: wasRawMode,
bracketedPaste: wasBracketedPaste,
};

if (wasBracketedPaste && stdout.isTTY) {
try {
stdout.write('\u001B[?2004l');
} catch {}
}

if (wasRawMode) {
stdin.setRawMode(false);
stdin.unref();
clearInputState();
}
}, [isRawModeSupported, stdin, stdout, clearInputState]);

const resumeInput = useCallback((): void => {
const {rawMode, bracketedPaste} = suspendedInputStateRef.current;

if (rawMode) {
stdin.setEncoding('utf8');
stdin.ref();
stdin.setRawMode(true);
attachReadableListener();
}

if (bracketedPaste && stdout.isTTY) {
try {
stdout.write('\u001B[?2004h');
} catch {}
}
}, [stdin, stdout, attachReadableListener]);

// Register input pause/resume in an insertion effect: it runs before every
// passive effect (parent and child), so a child that calls suspendTerminal()
// from its own effect always finds the input control already registered. A
// normal effect would run too late (child effects fire before the parent's).
useInsertionEffect(() => {
onRegisterInputControl(pauseInput, resumeInput);
}, [onRegisterInputControl, pauseInput, resumeInput]);

// Focus navigation helpers
const findNextFocusable = useCallback(
(
Expand Down Expand Up @@ -645,8 +707,9 @@ function App({
() => ({
exit: handleExit,
waitUntilRenderFlush: onWaitUntilRenderFlush,
suspendTerminal: onSuspendTerminal,
}),
[handleExit, onWaitUntilRenderFlush],
[handleExit, onWaitUntilRenderFlush, onSuspendTerminal],
);

const stdinContextValue = useMemo(
Expand Down
61 changes: 61 additions & 0 deletions src/components/AppContext.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
import {createContext} from 'react';

/**
A handle returned by `suspendTerminal()` when called without a callback.

Call `resume()` to give terminal ownership back to Ink, or use `await using`
so the suspension is resumed automatically when it leaves scope.
*/
export type TerminalSuspension = {
readonly resume: () => Promise<void>;
readonly [Symbol.asyncDispose]: () => Promise<void>;
};

/**
Temporarily hand the terminal over to a child process (e.g. `$EDITOR`, `less`,
`fzf`), then restore Ink's terminal state and force a full redraw.
*/
export type SuspendTerminal = {
(callback: () => void | Promise<void>): Promise<void>;
(): Promise<TerminalSuspension>;
};

export type Props = {
/**
Exit (unmount) the whole Ink app.
Expand Down Expand Up @@ -33,15 +53,56 @@ export type Props = {
```
*/
readonly waitUntilRenderFlush: () => Promise<void>;

/**
Temporarily release the terminal so a child process can take it over, then
restore Ink's terminal state and force a full redraw.

Use the callback form for the common case — Ink restores the terminal even
if the callback throws:

@example
```jsx
import {useApp} from 'ink';

const {suspendTerminal} = useApp();

await suspendTerminal(async () => {
await runEditor();
});
```

Or hold a suspension and resume it yourself:

@example
```jsx
await using suspension = await suspendTerminal();
await runEditor();
```
*/
readonly suspendTerminal: SuspendTerminal;
};

/**
`AppContext` is a React context that exposes lifecycle methods for the app.
*/
// Keep the default value typed so `useApp()` preserves the public `exit(errorOrResult?)` signature.
const noopSuspension: TerminalSuspension = {
async resume() {},
async [Symbol.asyncDispose]() {},
};

const defaultValue: Props = {
exit(_errorOrResult?: Error | unknown) {},
async waitUntilRenderFlush() {},
suspendTerminal: (async (callback?: () => void | Promise<void>) => {
if (callback) {
await callback();
return undefined;
}

return noopSuspension;
}) as SuspendTerminal,
};

// eslint-disable-next-line @typescript-eslint/naming-convention
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export {default as Spacer} from './components/Spacer.js';
export type {Key} from './hooks/use-input.js';
export {default as useInput} from './hooks/use-input.js';
export {default as usePaste} from './hooks/use-paste.js';
export type {
SuspendTerminal,
TerminalSuspension,
} from './components/AppContext.js';
export {default as useApp} from './hooks/use-app.js';
export {default as useStdin} from './hooks/use-stdin.js';
export {default as useStdout} from './hooks/use-stdout.js';
Expand Down
Loading