diff --git a/examples/suspend-terminal/index.ts b/examples/suspend-terminal/index.ts new file mode 100644 index 000000000..ec9f92043 --- /dev/null +++ b/examples/suspend-terminal/index.ts @@ -0,0 +1 @@ +import './suspend-terminal.js'; diff --git a/examples/suspend-terminal/suspend-terminal.tsx b/examples/suspend-terminal/suspend-terminal.tsx new file mode 100644 index 000000000..0fdfdfc0b --- /dev/null +++ b/examples/suspend-terminal/suspend-terminal.tsx @@ -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 => + 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 ( + + suspendTerminal() demo + + Counter: {counter} + + {status} + + e — open $EDITOR (defaults to vi) + r — run a shell read prompt + + — increment the counter + q — quit + + + ); +} + +render(); diff --git a/readme.md b/readme.md index 5ef7af9ff..557161128 100644 --- a/readme.md +++ b/readme.md @@ -1270,7 +1270,11 @@ Accepts the same values as [`backgroundColor`](#backgroundcolor) in `` com Falls back to `borderBackgroundColor` if not specified. ```jsx - + Hello world ``` @@ -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. diff --git a/src/components/App.tsx b/src/components/App.tsx index 1e8cf0ba4..c9f2ca8cf 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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'; @@ -41,6 +42,11 @@ type Props = { readonly exitOnCtrlC: boolean; readonly onExit: (errorOrResult?: unknown) => void; readonly onWaitUntilRenderFlush: () => Promise; + readonly onSuspendTerminal: SuspendTerminal; + readonly onRegisterInputControl: ( + pauseInput: () => void, + resumeInput: () => void, + ) => void; readonly setCursorPosition: (position: CursorPosition | undefined) => void; readonly interactive: boolean; readonly renderThrottleMs: number; @@ -64,6 +70,8 @@ function App({ exitOnCtrlC, onExit, onWaitUntilRenderFlush, + onSuspendTerminal, + onRegisterInputControl, setCursorPosition, interactive, renderThrottleMs, @@ -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( ( @@ -645,8 +707,9 @@ function App({ () => ({ exit: handleExit, waitUntilRenderFlush: onWaitUntilRenderFlush, + suspendTerminal: onSuspendTerminal, }), - [handleExit, onWaitUntilRenderFlush], + [handleExit, onWaitUntilRenderFlush, onSuspendTerminal], ); const stdinContextValue = useMemo( diff --git a/src/components/AppContext.ts b/src/components/AppContext.ts index 0c333828a..014b86221 100644 --- a/src/components/AppContext.ts +++ b/src/components/AppContext.ts @@ -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; + readonly [Symbol.asyncDispose]: () => Promise; +}; + +/** +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): Promise; + (): Promise; +}; + export type Props = { /** Exit (unmount) the whole Ink app. @@ -33,15 +53,56 @@ export type Props = { ``` */ readonly waitUntilRenderFlush: () => Promise; + + /** + 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) => { + if (callback) { + await callback(); + return undefined; + } + + return noopSuspension; + }) as SuspendTerminal, }; // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/src/index.ts b/src/index.ts index 2abd32903..5e196ffa1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/ink.tsx b/src/ink.tsx index 4998c60c8..2050b8b59 100644 --- a/src/ink.tsx +++ b/src/ink.tsx @@ -19,6 +19,7 @@ import logUpdate, {type LogUpdate, type CursorPosition} from './log-update.js'; import {bsu, esu, shouldSynchronize} from './write-synchronized.js'; import instances from './instances.js'; import App from './components/App.js'; +import {type TerminalSuspension} from './components/AppContext.js'; import {accessibilityContext as AccessibilityContext} from './components/AccessibilityContext.js'; import { type KittyKeyboardOptions, @@ -325,8 +326,15 @@ export default class Ink { private readonly throttledOnRender?: DebouncedFunc<() => void>; private hasPendingThrottledRender = false; private kittyProtocolEnabled = false; + private kittyFlags: KittyFlagName[] | undefined; private cancelKittyDetection?: () => void; private nextRenderCommit?: {promise: Promise; resolve: () => void}; + // Set while suspendTerminal() has handed the terminal to a child process. + private isSuspended = false; + // Input pause/resume hooks registered by the App component, which owns raw + // mode and bracketed paste state. + private pauseInput?: () => void; + private resumeInput?: () => void; constructor(options: Options) { autoBind(this); @@ -543,6 +551,18 @@ export default class Ink { return; } + // While suspended, the terminal belongs to a child process. Discard queued + // renders; resume() forces a full redraw once Ink reclaims the terminal. + // Resolve any awaited render commit so callers don't hang during suspension. + if (this.isSuspended) { + if (this.nextRenderCommit) { + this.nextRenderCommit.resolve(); + this.nextRenderCommit = undefined; + } + + return; + } + if (this.nextRenderCommit) { this.nextRenderCommit.resolve(); this.nextRenderCommit = undefined; @@ -665,6 +685,8 @@ export default class Ink { setCursorPosition={this.setCursorPosition} onExit={this.handleAppExit} onWaitUntilRenderFlush={this.waitUntilRenderFlush} + onSuspendTerminal={this.suspendTerminal} + onRegisterInputControl={this.registerInputControl} > {node} @@ -686,6 +708,13 @@ export default class Ink { return; } + // While suspended, the terminal belongs to a child process. Don't erase or + // repaint Ink's frame around console output; the forced redraw on resume + // restores the screen. + if (this.isSuspended) { + return; + } + if (this.options.debug) { this.options.stdout.write(data + this.fullStaticOutput + this.lastOutput); return; @@ -715,6 +744,11 @@ export default class Ink { return; } + // See writeToStdout: stay off the terminal while suspended. + if (this.isSuspended) { + return; + } + if (this.options.debug) { this.options.stderr.write(data); this.options.stdout.write(this.fullStaticOutput + this.lastOutput); @@ -973,6 +1007,35 @@ export default class Ink { }); } + registerInputControl(pauseInput: () => void, resumeInput: () => void): void { + this.pauseInput = pauseInput; + this.resumeInput = resumeInput; + } + + async suspendTerminal(callback: () => void | Promise): Promise; + async suspendTerminal(): Promise; + async suspendTerminal( + callback?: () => void | Promise, + ): Promise { + this.beginSuspend(); + + if (callback) { + try { + await callback(); + } finally { + await this.endSuspend(); + } + + return undefined; + } + + const resume = async (): Promise => { + await this.endSuspend(); + }; + + return {resume, [Symbol.asyncDispose]: resume}; + } + private setAlternateScreen(enabled: boolean): void { this.alternateScreen = this.resolveAlternateScreenOption( enabled, @@ -1201,5 +1264,113 @@ export default class Ink { private enableKittyProtocol(flags: KittyFlagName[]): void { this.options.stdout.write(`\u001B[>${resolveFlags(flags)}u`); this.kittyProtocolEnabled = true; + // Remember the flags so suspendTerminal() can re-enable the same protocol + // after a child process has had the terminal. + this.kittyFlags = flags; + } + + private beginSuspend(): void { + if (this.isSuspended) { + throw new Error( + 'The terminal is already suspended. Resume the current suspension before suspending again.', + ); + } + + this.isSuspended = true; + + if (!this.interactive || this.isUnmounted || this.isUnmounting) { + return; + } + + try { + const stdout = this.options.stdout as MaybeWritableStream; + const {canWriteToStdout} = getWritableStreamState(stdout); + + // Flush any pending render/log so the child starts from a settled screen. + settleThrottle(this.throttledOnRender, canWriteToStdout); + settleThrottle(this.throttledLog, canWriteToStdout); + + if (canWriteToStdout) { + // Erase Ink's current frame, then show the cursor and re-arm the hide. + // The forced redraw on resume hides the cursor again. + this.log.clear(); + this.log.done(); + + if (this.kittyProtocolEnabled) { + this.writeBestEffort(this.options.stdout, '\u001B[ { + if (!this.isSuspended) { + return; + } + + this.isSuspended = false; + + // Reclaim input even mid-unmount: pauseInput already ran in beginSuspend, so + // restoring it is symmetric regardless of any state change during suspension. + this.resumeInput?.(); + + if (!this.interactive || this.isUnmounted || this.isUnmounting) { + return; + } + + const stdout = this.options.stdout as MaybeWritableStream; + const {canWriteToStdout} = getWritableStreamState(stdout); + + if (canWriteToStdout) { + if (this.alternateScreen) { + this.writeBestEffort( + this.options.stdout, + ansiEscapes.enterAlternativeScreen, + ); + } + + if (this.kittyProtocolEnabled && this.kittyFlags) { + this.writeBestEffort( + this.options.stdout, + `\u001B[>${resolveFlags(this.kittyFlags)}u`, + ); + } + } + + // Force a full redraw instead of diffing against the stale pre-suspension + // frame, which the child process may have overwritten. A redraw failure here + // is best-effort: it must not mask a callback error propagating through the + // caller's finally block. + this.lastOutput = ''; + this.lastOutputToRender = ''; + this.lastOutputHeight = 0; + this.log.reset(); + + try { + this.calculateLayout(); + this.onRender(); + await this.waitUntilRenderFlush(); + } catch {} } } diff --git a/test/fixtures/suspend-terminal.tsx b/test/fixtures/suspend-terminal.tsx new file mode 100644 index 000000000..12015f088 --- /dev/null +++ b/test/fixtures/suspend-terminal.tsx @@ -0,0 +1,30 @@ +import process from 'node:process'; +import React, {useEffect} from 'react'; +import {render, Text, useApp, useInput} from '../../src/index.js'; + +function Test() { + const {suspendTerminal, exit} = useApp(); + // Enable raw mode + the input pipeline so suspendTerminal has real terminal + // ownership to release. + useInput(() => {}); + + useEffect(() => { + void (async () => { + await suspendTerminal(async () => { + // Simulate a child process drawing directly to the terminal while Ink + // has handed it over. + process.stdout.write('CHILD_OUTPUT'); + }); + + // The resume redraw was already awaited inside suspendTerminal; this delay + // just keeps the process alive briefly so the PTY captures it before exit. + setTimeout(exit, 100); + })(); + }, [suspendTerminal, exit]); + + return Ink frame; +} + +const app = render(); + +await app.waitUntilExit(); diff --git a/test/suspend-terminal.tsx b/test/suspend-terminal.tsx new file mode 100644 index 000000000..01f615921 --- /dev/null +++ b/test/suspend-terminal.tsx @@ -0,0 +1,353 @@ +import React, {useEffect} from 'react'; +import test from 'ava'; +import stripAnsi from 'strip-ansi'; +import {render, useApp, useInput, useStdout, Text} from '../src/index.js'; +import {type SuspendTerminal} from '../src/components/AppContext.js'; +import createStdout, {type FakeStdout} from './helpers/create-stdout.js'; +import {createStdin} from './helpers/create-stdin.js'; +import term from './helpers/term.js'; + +const showCursor = '[?25h'; +const hideCursor = '[?25l'; +const enterAltScreen = '[?1049h'; +const exitAltScreen = '[?1049l'; + +const delay = async (ms: number) => + new Promise(resolve => { + setTimeout(resolve, ms); + }); + +const lastSetRawModeArg = (stdin: NodeJS.WriteStream): boolean | undefined => { + const calls = (stdin.setRawMode as unknown as {args: unknown[][]}).args; + return calls.at(-1)?.[0] as boolean | undefined; +}; + +// Renders an interactive app (raw mode on via useInput) and runs `run` with the +// app's suspendTerminal once mounted. Resolves after `run` settles. +const renderWithSuspend = async ( + run: ( + suspendTerminal: SuspendTerminal, + stdin: NodeJS.WriteStream, + ) => Promise, +): Promise<{stdout: FakeStdout; stdin: NodeJS.WriteStream}> => { + const stdout = createStdout(); + const stdin = createStdin(); + + let finished!: () => void; + const done = new Promise(resolve => { + finished = resolve; + }); + + function Example() { + const {suspendTerminal} = useApp(); + useInput(() => {}); + + useEffect(() => { + void (async () => { + try { + await run(suspendTerminal, stdin); + } finally { + finished(); + } + })(); + }, [suspendTerminal]); + + return hello; + } + + const {unmount} = render(, {stdout, stdin, interactive: true}); + await done; + await delay(50); + unmount(); + + return {stdout, stdin}; +}; + +// Note: raw mode is captured inside the run callback (before the harness +// unmounts), because unmount's cleanup disables raw mode and would otherwise be +// the last recorded setRawMode call. +test('suspendTerminal hands the terminal to the callback, then restores Ink', async t => { + let ranInsideCallback = false; + let rawModeDuringCallback: boolean | undefined; + let rawModeAfterCallback: boolean | undefined; + + const {stdout} = await renderWithSuspend(async (suspendTerminal, stdin) => { + await suspendTerminal(async () => { + ranInsideCallback = true; + rawModeDuringCallback = lastSetRawModeArg(stdin); + }); + rawModeAfterCallback = lastSetRawModeArg(stdin); + }); + + t.true(ranInsideCallback); + // Raw mode disabled for the child, re-enabled once Ink reclaimed the terminal. + t.false(rawModeDuringCallback); + t.true(rawModeAfterCallback); + // Cursor shown for the child, then hidden again by the forced redraw. + t.true(stdout.getWrites().some(write => write.includes(showCursor))); + t.true(stdout.getWrites().some(write => write.includes(hideCursor))); +}); + +test('suspendTerminal restores the terminal even if the callback throws', async t => { + let threw = false; + let rawModeAfterThrow: boolean | undefined; + + await renderWithSuspend(async (suspendTerminal, stdin) => { + try { + await suspendTerminal(async () => { + throw new Error('boom'); + }); + } catch { + threw = true; + } + + rawModeAfterThrow = lastSetRawModeArg(stdin); + }); + + t.true(threw); + // Raw mode was reclaimed despite the throw. + t.true(rawModeAfterThrow); +}); + +test('suspendTerminal returns a disposable that resumes on resume()', async t => { + let rawModeWhileSuspended: boolean | undefined; + let rawModeAfterResume: boolean | undefined; + + await renderWithSuspend(async (suspendTerminal, stdin) => { + const suspension = await suspendTerminal(); + rawModeWhileSuspended = lastSetRawModeArg(stdin); + await suspension.resume(); + rawModeAfterResume = lastSetRawModeArg(stdin); + }); + + t.false(rawModeWhileSuspended); + t.true(rawModeAfterResume); +}); + +test('suspendTerminal disposable resumes via Symbol.asyncDispose', async t => { + let rawModeWhileSuspended: boolean | undefined; + let rawModeAfterDispose: boolean | undefined; + + await renderWithSuspend(async (suspendTerminal, stdin) => { + const suspension = await suspendTerminal(); + rawModeWhileSuspended = lastSetRawModeArg(stdin); + await suspension[Symbol.asyncDispose](); + rawModeAfterDispose = lastSetRawModeArg(stdin); + }); + + t.false(rawModeWhileSuspended); + t.true(rawModeAfterDispose); +}); + +test('suspendTerminal keeps Ink off the terminal while suspended', async t => { + const stdout = createStdout(); + const stdin = createStdin(); + + let writesDuringSuspend: number | undefined; + + let finished!: () => void; + const done = new Promise(resolve => { + finished = resolve; + }); + + function Example() { + const {suspendTerminal} = useApp(); + const {write} = useStdout(); + useInput(() => {}); + + useEffect(() => { + void (async () => { + try { + await suspendTerminal(async () => { + const before = stdout.getWrites().length; + // A write through Ink's stdout context while suspended must be a + // no-op so it cannot corrupt the child process's screen. + write('output while suspended'); + writesDuringSuspend = stdout.getWrites().length - before; + }); + } finally { + finished(); + } + })(); + }, [suspendTerminal, write]); + + return hello; + } + + const {unmount} = render(, {stdout, stdin, interactive: true}); + await done; + await delay(50); + unmount(); + + t.is(writesDuringSuspend, 0); +}); + +test('suspendTerminal runs the callback but skips the handoff when not interactive', async t => { + const stdout = createStdout(); + const stdin = createStdin(); + + let ranCallback = false; + + let finished!: () => void; + const done = new Promise(resolve => { + finished = resolve; + }); + + function Example() { + const {suspendTerminal} = useApp(); + + useEffect(() => { + void (async () => { + try { + await suspendTerminal(async () => { + ranCallback = true; + }); + } finally { + finished(); + } + })(); + }, [suspendTerminal]); + + return hello; + } + + const {unmount} = render(, {stdout, stdin, interactive: false}); + await done; + await delay(20); + unmount(); + + // The callback still runs, but Ink performs no terminal handoff (no cursor + // reveal) in non-interactive mode. + t.true(ranCallback); + t.false(stdout.getWrites().some(write => write.includes(showCursor))); +}); + +test('suspendTerminal rejects a nested suspend while already suspended', async t => { + let nestedRejected = false; + + await renderWithSuspend(async suspendTerminal => { + await suspendTerminal(async () => { + await t.throwsAsync( + suspendTerminal(async () => {}), + { + message: /already suspended/, + }, + ); + nestedRejected = true; + }); + }); + + t.true(nestedRejected); +}); + +test.serial( + 'suspendTerminal hands the terminal to a child process, then redraws (PTY)', + async t => { + const ps = term('suspend-terminal'); + await ps.waitForExit(); + + const {output} = ps; + + // The child process wrote directly to the terminal during suspension. + t.true(output.includes('CHILD_OUTPUT')); + // Ink showed the cursor when handing the terminal over. + t.true(output.includes(showCursor)); + // Ink reclaimed the terminal and repainted its frame after the child output, + // re-hiding the cursor as part of the redraw. + const afterChild = output.slice( + output.lastIndexOf('CHILD_OUTPUT') + 'CHILD_OUTPUT'.length, + ); + t.true(stripAnsi(afterChild).includes('Ink frame')); + t.true(afterChild.includes(hideCursor)); + }, +); + +test('suspendTerminal exits and re-enters the alternate screen', async t => { + const stdout = createStdout(); + const stdin = createStdin(); + + let exitedAltDuringSuspend: boolean | undefined; + let reEnteredAltAfterResume: boolean | undefined; + + let finished!: () => void; + const done = new Promise(resolve => { + finished = resolve; + }); + + function Example() { + const {suspendTerminal} = useApp(); + useInput(() => {}); + + useEffect(() => { + void (async () => { + try { + let writesAtCallbackEnd = 0; + await suspendTerminal(async () => { + exitedAltDuringSuspend = stdout + .getWrites() + .some(write => write.includes(exitAltScreen)); + writesAtCallbackEnd = stdout.getWrites().length; + }); + reEnteredAltAfterResume = stdout + .getWrites() + .slice(writesAtCallbackEnd) + .some(write => write.includes(enterAltScreen)); + } finally { + finished(); + } + })(); + }, [suspendTerminal]); + + return hello; + } + + const {unmount} = render(, { + stdout, + stdin, + alternateScreen: true, + interactive: true, + }); + await done; + await delay(50); + unmount(); + + t.true(exitedAltDuringSuspend); + t.true(reEnteredAltAfterResume); +}); + +test('suspendTerminal rolls back so a later suspend works if handover throws', async t => { + let firstRejected = false; + let secondSucceeded = false; + + await renderWithSuspend(async (suspendTerminal, stdin) => { + const rawModeController = stdin as unknown as { + setRawMode: (value: boolean) => unknown; + }; + const originalSetRawMode = rawModeController.setRawMode; + // Force the pause's setRawMode(false) to throw so beginSuspend's rollback runs. + rawModeController.setRawMode = (value: boolean) => { + if (!value) { + throw new Error('handover boom'); + } + + return stdin; + }; + + try { + await suspendTerminal(async () => {}); + } catch { + firstRejected = true; + } + + // Stop throwing; the app must not be stuck in a suspended state. + rawModeController.setRawMode = originalSetRawMode; + + try { + await suspendTerminal(async () => {}); + secondSucceeded = true; + } catch {} + }); + + t.true(firstRejected); + t.true(secondSucceeded); +});