UNPKG

@inquirer/testing

Version:
151 lines (150 loc) 6.09 kB
import { stripVTControlCharacters } from 'node:util'; import MuteStream from 'mute-stream'; import { BufferedStream } from "./buffered-stream.js"; import { interpretTerminalOutput } from "./terminal.js"; export class Screen { #input; #outputs = []; #currentOutput = null; #activePromise = null; #rendersConsumed = 0; #renderResolve = null; constructor() { this.#input = new MuteStream(); this.#input.unmute(); } get input() { return this.#input; } createOutput() { const output = new BufferedStream(); this.#outputs.push(output); this.#currentOutput = output; this.#rendersConsumed = 0; // Forward render events for cross-output listening output.on('render', () => { if (this.#renderResolve) { this.#renderResolve(); } }); return output; } setActivePromise(promise) { this.#activePromise = promise; // Auto-consume the initial render. Since createPrompt renders synchronously, // getScreen() works immediately after starting a prompt — no next() needed. this.#rendersConsumed = this.#currentOutput?.writeCount ?? 0; } /** * Wait for the next screen update. * * Handles re-renders within the same prompt (e.g., validation errors, * async updates) and prompt transitions in multi-prompt flows * (automatically waits for the next prompt). * * Note: The initial prompt render is available immediately via getScreen() * — no next() call is needed before reading it. */ async next() { if (this.#activePromise) { const currentPromise = this.#activePromise; // Consume any renders that happened synchronously (e.g., loading state). // We want to wait for the next meaningful state change, not an intermediate render. this.#rendersConsumed = this.#currentOutput?.writeCount ?? 0; // Race: a future render (validation error, async update) vs the promise settling // (prompt completed, possibly synchronously before any new render arrives). const renderPromise = this.#waitForNextRender(); const settlePromise = currentPromise.then(() => 'settled', () => 'settled'); const result = await Promise.race([ renderPromise.then(() => 'render'), settlePromise, ]); if (result === 'settled') { if (this.#activePromise !== currentPromise) { // New prompt already started — its render was caught by the race's renderPromise. this.#rendersConsumed = this.#currentOutput?.writeCount ?? 0; } else { // Prompt settled but no new prompt yet. Wait for the next prompt's first render. await this.#waitForNextRender(); } } else { // Got a render. Check if the prompt also completed (making this a "done" render). // Microtasks (promise settlement) always drain before macrotasks (setImmediate), // so if the prompt resolved, its .then wins the race. const settled = await Promise.race([ currentPromise.then(() => true, () => true), new Promise((resolve) => setImmediate(() => resolve(false))), ]); if (settled) { if (this.#activePromise !== currentPromise) { // New prompt already started — its render was caught by the race. this.#rendersConsumed = this.#currentOutput?.writeCount ?? 0; } else { // Prompt completed but no new prompt yet. Wait for it. await this.#waitForNextRender(); } } } } else { // No active promise — wait for a render (e.g., prompt hasn't started yet) await this.#waitForNextRender(); } } async #waitForNextRender() { const writeCount = this.#currentOutput?.writeCount ?? 0; if (writeCount > this.#rendersConsumed) { this.#rendersConsumed = writeCount; return; } await new Promise((resolve) => { const handler = () => { this.#renderResolve = null; resolve(); }; // Listen on current output (for re-renders within same prompt) this.#currentOutput?.once('render', handler); // Also set up cross-output listener (for prompt transitions and initial render) this.#renderResolve = handler; }); this.#rendersConsumed = this.#currentOutput?.writeCount ?? 0; } getScreen({ raw } = {}) { const lastScreen = this.#currentOutput?.getLastChunk({ raw: Boolean(raw) }) ?? ''; return raw ? lastScreen : stripVTControlCharacters(lastScreen).trim(); } async getFullOutput({ raw } = {}) { const output = this.#outputs.map((o) => o.getFullOutput()).join(''); if (raw) return output; return interpretTerminalOutput(output); } keypress(key) { if (typeof key === 'string') { this.#input.emit('keypress', null, { name: key }); } else { this.#input.emit('keypress', null, key); } } type(text) { this.#input.write(text); for (const char of text) { this.#input.emit('keypress', null, { name: char }); } } clear() { // Recreate input stream to ensure clean state this.#input = new MuteStream(); this.#input.unmute(); // Reset output tracking this.#outputs = []; this.#currentOutput = null; this.#activePromise = null; this.#rendersConsumed = 0; this.#renderResolve = null; } }