Need
An app sometimes has to hand the terminal to another program: open $VISUAL / $EDITOR on a file, run a pager, shell out to git commit. A terminal editor needs the real terminal — the normal screen, cooked mode, no mouse reporting, no bracketed paste, no modifyOtherKeys — and the app needs all of it back afterwards, with a full redraw. The same sequence is what Ctrl+Z (SIGTSTP / SIGCONT) requires.
Today there is no way to do this short of unmount() + a fresh render(), which loses all component state. Ctrl+Z is not handled either: in raw mode it arrives as a key and nothing suspends.
Proposal
- Backend capability (optional, feature-detected like the others):
suspend(): void / resume(): void on TtyBackend and InlineTtyBackend.
suspend: turn off mouse reporting, bracketed paste and modifyOtherKeys, show the cursor, leave the alt screen (inline: clear the live region), leave raw mode, pause input decoding and detach the stdin listener so the child's keystrokes never reach useInput handlers.
resume: the reverse, then invalidate the frame-diff baseline so the next paint is a full frame; re-read size() and notify resize subscribers (the terminal may have been resized meanwhile).
- Render handle:
await app.suspend(async () => { … }) — calls backend.suspend(), awaits the callback, calls backend.resume() in a finally, forces a repaint. Also reachable from components: useApp().suspend(fn). While suspended, draw() is a no-op (state updates still apply; the frame is painted on resume). Nested calls reject.
- Ctrl+Z: in raw mode decode
\x1a as a key as today, but make the default (no app handler consumed it — needs a decision on how to express "consumed") suspend the backend and process.kill(process.pid, 'SIGTSTP'); on SIGCONT, resume and repaint. At minimum, handle SIGCONT so a kill -STOP / fg cycle does not leave a broken screen.
Notes
TestBackend: record suspend / resume calls so an app can test its editor flow.
- Signals during suspension (
SIGTERM, SIGINT) must still restore the terminal exactly once — dispose() after suspend() must not re-emit "leave alt screen".
- Windows is out of scope here (tracked with the other hostile-environment work).
- Docs:
docs/app.md (a section on handing the terminal over), docs/writing-a-backend.md (the optional capability).
Done when
await app.suspend(() => spawnSync('vim', [file], { stdio: 'inherit' })) from a key handler opens vim on the normal screen and returns to an intact, fully repainted app with its state.
- Keys typed into the child never reach the app.
- A pty test covers the byte sequence on suspend and resume.
Need
An app sometimes has to hand the terminal to another program: open
$VISUAL/$EDITORon a file, run a pager, shell out togit commit. A terminal editor needs the real terminal — the normal screen, cooked mode, no mouse reporting, no bracketed paste, no modifyOtherKeys — and the app needs all of it back afterwards, with a full redraw. The same sequence is what Ctrl+Z (SIGTSTP/SIGCONT) requires.Today there is no way to do this short of
unmount()+ a freshrender(), which loses all component state. Ctrl+Z is not handled either: in raw mode it arrives as a key and nothing suspends.Proposal
suspend(): void/resume(): voidonTtyBackendandInlineTtyBackend.suspend: turn off mouse reporting, bracketed paste and modifyOtherKeys, show the cursor, leave the alt screen (inline: clear the live region), leave raw mode, pause input decoding and detach the stdin listener so the child's keystrokes never reachuseInputhandlers.resume: the reverse, then invalidate the frame-diff baseline so the next paint is a full frame; re-readsize()and notify resize subscribers (the terminal may have been resized meanwhile).await app.suspend(async () => { … })— callsbackend.suspend(), awaits the callback, callsbackend.resume()in afinally, forces a repaint. Also reachable from components:useApp().suspend(fn). While suspended,draw()is a no-op (state updates still apply; the frame is painted on resume). Nested calls reject.\x1aas a key as today, but make the default (no app handler consumed it — needs a decision on how to express "consumed") suspend the backend andprocess.kill(process.pid, 'SIGTSTP'); onSIGCONT, resume and repaint. At minimum, handleSIGCONTso akill -STOP/fgcycle does not leave a broken screen.Notes
TestBackend: recordsuspend/resumecalls so an app can test its editor flow.SIGTERM,SIGINT) must still restore the terminal exactly once —dispose()aftersuspend()must not re-emit "leave alt screen".docs/app.md(a section on handing the terminal over),docs/writing-a-backend.md(the optional capability).Done when
await app.suspend(() => spawnSync('vim', [file], { stdio: 'inherit' }))from a key handler opens vim on the normal screen and returns to an intact, fully repainted app with its state.