@alchemy.run/sigil 0.0.0-alpha.4 → 0.0.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +21 -9
  2. package/THIRD_PARTY_NOTICES.md +39 -1
  3. package/dist/Text-BobFKi74.d.ts +452 -0
  4. package/dist/ansi.d.ts +122 -131
  5. package/dist/ansi.js +86 -6
  6. package/dist/capabilities.d.ts +5 -0
  7. package/dist/capabilities.js +3 -0
  8. package/dist/cell-_ZVhbfl0.js +44 -0
  9. package/dist/color-CkbalRqK.js +2 -0
  10. package/dist/color-policy-BMzMwV7Q.d.ts +22 -0
  11. package/dist/color-policy-SVj1pYTA.js +560 -0
  12. package/dist/color-profile-DHhQHY55.js +36 -0
  13. package/dist/color-profile-u0Nhe9Nv.d.ts +97 -0
  14. package/dist/color.d.ts +21 -0
  15. package/dist/color.js +3 -0
  16. package/dist/cursor-position-D2LAkRG0.d.ts +7 -0
  17. package/dist/detect-Bh4yGP6w.d.ts +186 -0
  18. package/dist/detect-BuTXtY6e.js +373 -0
  19. package/dist/env-YVw64yZS.js +9 -0
  20. package/dist/escapes-CB_6CWOE.d.ts +72 -0
  21. package/dist/geometry-BxXOzJgo.d.ts +11 -0
  22. package/dist/index-DZ88EXJv.d.ts +21 -0
  23. package/dist/index.d.ts +143 -498
  24. package/dist/index.js +1026 -2244
  25. package/dist/osc-CCH7xDoS.js +71 -0
  26. package/dist/osc-Cn0fw77g.d.ts +23 -0
  27. package/dist/paint-C19minOS.d.ts +81 -0
  28. package/dist/query-vaIeGOkH.d.ts +152 -0
  29. package/dist/router.d.ts +392 -0
  30. package/dist/router.js +709 -0
  31. package/dist/sample-Cqw1bjUL.js +445 -0
  32. package/dist/screen-BOSLQ8fF.d.ts +49 -0
  33. package/dist/screen-CiPytswf.js +342 -0
  34. package/dist/screen.d.ts +5 -0
  35. package/dist/screen.js +5 -0
  36. package/dist/semantic-text-style-DIMzC7xt.js +91 -0
  37. package/dist/serialize-BTkAZgw1.js +79 -0
  38. package/dist/session-aZr9O8h3.js +665 -0
  39. package/dist/sgr-BhwaWAJB.js +246 -0
  40. package/dist/store-CgrG9K4y.d.ts +72 -0
  41. package/dist/string-width-CijQwpIk.js +69 -0
  42. package/dist/strip-BvU4toXG.js +6 -0
  43. package/dist/terminal.d.ts +118 -0
  44. package/dist/terminal.js +2 -0
  45. package/dist/tokenize-AjqbvtiT.js +1242 -0
  46. package/dist/tokenize-Dx1y_l5H.d.ts +57 -0
  47. package/dist/truncate-D31fhU6i.js +562 -0
  48. package/dist/use-focus-BzqAJi0n.js +1337 -0
  49. package/package.json +41 -9
  50. package/src/ansi/chalk.ts +5 -3
  51. package/src/ansi/escapes.ts +14 -0
  52. package/src/ansi/graphemes.ts +8 -0
  53. package/src/ansi/hyperlink.ts +44 -0
  54. package/src/ansi/index.ts +3 -1
  55. package/src/ansi/osc.ts +77 -0
  56. package/src/ansi/tokenize.ts +3 -4
  57. package/src/capabilities/color-policy.ts +34 -0
  58. package/src/capabilities/detect.ts +594 -0
  59. package/src/capabilities/index.ts +37 -0
  60. package/src/capabilities/query.ts +657 -0
  61. package/src/capabilities/store.ts +379 -0
  62. package/src/color/index.ts +3 -0
  63. package/src/color/paint.ts +169 -0
  64. package/src/color/palette.ts +48 -0
  65. package/src/color/sample.ts +323 -0
  66. package/src/color.ts +1 -0
  67. package/src/components/AnsiText.tsx +42 -0
  68. package/src/components/App.tsx +98 -10
  69. package/src/components/BackgroundContext.ts +2 -3
  70. package/src/components/Box.tsx +0 -8
  71. package/src/components/CursorContext.ts +1 -1
  72. package/src/components/Hyperlink.tsx +56 -0
  73. package/src/components/TerminalOscContext.ts +25 -0
  74. package/src/components/Text.tsx +22 -45
  75. package/src/components/Transform.tsx +1 -1
  76. package/src/dom.ts +11 -2
  77. package/src/global.d.ts +3 -0
  78. package/src/hooks/use-capabilities.ts +73 -0
  79. package/src/hooks/use-cursor.ts +2 -2
  80. package/src/hooks/use-terminal-osc.ts +59 -0
  81. package/src/index.ts +45 -1
  82. package/src/ink.tsx +213 -153
  83. package/src/{render-node-to-output.ts → paint-tree.ts} +75 -48
  84. package/src/reconciler.ts +24 -3
  85. package/src/render-background.ts +36 -15
  86. package/src/render-border.ts +94 -61
  87. package/src/render-frame.ts +83 -0
  88. package/src/render-to-string.ts +21 -6
  89. package/src/render.ts +15 -6
  90. package/src/router/components.tsx +343 -0
  91. package/src/router/context.ts +41 -0
  92. package/src/router/history.ts +194 -0
  93. package/src/router/hooks.tsx +391 -0
  94. package/src/router/index.ts +34 -0
  95. package/src/router/matcher.ts +571 -0
  96. package/src/screen/ansi.ts +184 -0
  97. package/src/screen/canvas.ts +160 -0
  98. package/src/screen/cell.ts +138 -0
  99. package/src/screen/color-profile.ts +47 -0
  100. package/src/screen/geometry.ts +9 -0
  101. package/src/screen/index.ts +6 -0
  102. package/src/screen/screen.ts +305 -0
  103. package/src/screen/serialize.ts +129 -0
  104. package/src/screen.ts +1 -0
  105. package/src/semantic-text-style.ts +118 -0
  106. package/src/squash-text-nodes.ts +2 -5
  107. package/src/structured-text.ts +325 -0
  108. package/src/styles.ts +19 -14
  109. package/src/terminal/index.ts +2 -0
  110. package/src/terminal/inline-presenter.ts +120 -0
  111. package/src/terminal/input.ts +86 -0
  112. package/src/terminal/render-scheduler.ts +37 -0
  113. package/src/terminal/screen-presenter.ts +188 -0
  114. package/src/terminal/session.ts +407 -0
  115. package/src/terminal.ts +1 -0
  116. package/src/testing/browser.ts +588 -0
  117. package/src/testing/emulators.ts +205 -0
  118. package/src/testing/explorer-app/index.html +12 -0
  119. package/src/testing/explorer-app/main.ts +381 -0
  120. package/src/testing/explorer-app/style.css +194 -0
  121. package/src/testing/explorer-app/tsconfig.json +15 -0
  122. package/src/testing/explorer-app/vite-env.d.ts +1 -0
  123. package/src/testing/index.ts +26 -0
  124. package/src/testing/keys.ts +56 -0
  125. package/src/testing/live.ts +85 -0
  126. package/src/testing/matchers.ts +70 -0
  127. package/src/testing/public.ts +94 -0
  128. package/src/testing/terminal.ts +349 -0
  129. package/src/testing/vitest.ts +157 -0
  130. package/src/transform-adapter.ts +14 -0
  131. package/src/wrap-text.ts +4 -0
  132. package/dist/sgr-CMfEpjSk.d.ts +0 -91
  133. package/dist/truncate-Cr6xVFMa.js +0 -2330
  134. package/src/ansi/supports-color.ts +0 -207
  135. package/src/colorize.ts +0 -60
  136. package/src/log-update.ts +0 -370
  137. package/src/output.ts +0 -308
  138. package/src/renderer.ts +0 -73
package/src/ink.tsx CHANGED
@@ -7,11 +7,13 @@ import { type FiberRoot } from "react-reconciler";
7
7
  import { LegacyRoot, ConcurrentRoot } from "react-reconciler/constants.js";
8
8
 
9
9
  import { ansiEscapes, bsu, esu } from "#/ansi/escapes.ts";
10
+ import type { ClipboardSelection, TerminalProgressState } from "#/ansi/osc.ts";
10
11
  import { wrapAnsi } from "#/ansi/wrap.ts";
11
12
  import { accessibilityContext as AccessibilityContext } from "#/components/AccessibilityContext.ts";
12
13
  import { App } from "#/components/App.tsx";
13
14
  import { type TerminalSuspension } from "#/components/AppContext.ts";
14
- import { hideCursorEscape, showCursorEscape } from "#/cursor-position.ts";
15
+ import { TerminalOscContext } from "#/components/TerminalOscContext.ts";
16
+ import type { CursorPosition } from "#/cursor-position.ts";
15
17
  import * as dom from "#/dom.ts";
16
18
  import { isSigilDev, isInCi, isScreenReader, isTty, isWindows } from "#/env.ts";
17
19
  import { instances } from "#/instances.ts";
@@ -21,17 +23,46 @@ import {
21
23
  resolveFlags,
22
24
  detectKittySupport,
23
25
  } from "#/kitty-keyboard.ts";
24
- import { logUpdate, type LogUpdate, type CursorPosition } from "#/log-update.ts";
25
26
  import { patchConsole, patchStreamWrite } from "#/patch-console.ts";
26
27
  import { reconciler } from "#/reconciler.ts";
27
- import { renderer } from "#/renderer.ts";
28
+ import { renderFrame } from "#/render-frame.ts";
29
+ import { Screen } from "#/screen/screen.ts";
28
30
  import { signalExit } from "#/signal-exit.ts";
29
31
  import { type OutputStream } from "#/stream.ts";
30
- import { throttle, type Throttled } from "#/throttle.ts";
32
+ import { createInlinePresenter } from "#/terminal/inline-presenter.ts";
33
+ import { createRenderScheduler } from "#/terminal/render-scheduler.ts";
34
+ import { TerminalSession } from "#/terminal/session.ts";
35
+ import { type Throttled } from "#/throttle.ts";
31
36
  import { getWindowSize } from "#/utils.ts";
32
37
  import { Yoga } from "#/yoga/index.ts";
33
38
 
34
39
  const noop = () => {};
40
+ const beforeExitCallbacks = new Set<() => void>();
41
+ const runBeforeExitCallbacks = (): void => {
42
+ for (const callback of beforeExitCallbacks) callback();
43
+ };
44
+
45
+ function registerBeforeExit(callback: () => void): () => void {
46
+ if (beforeExitCallbacks.size === 0) process.on("beforeExit", runBeforeExitCallbacks);
47
+ beforeExitCallbacks.add(callback);
48
+ return () => {
49
+ beforeExitCallbacks.delete(callback);
50
+ if (beforeExitCallbacks.size === 0) process.off("beforeExit", runBeforeExitCallbacks);
51
+ };
52
+ }
53
+
54
+ function bottomRows(screen: Screen, height: number): Screen {
55
+ if (screen.height <= height) return screen;
56
+ const cropped = new Screen(screen.width, height);
57
+ const offset = screen.height - height;
58
+ for (let y = 0; y < height; y++) {
59
+ for (let x = 0; x < screen.width; x++) {
60
+ const cell = screen.cellAt(x, y + offset);
61
+ if (cell && cell.width > 0) cropped.setCell(x, y, cell);
62
+ }
63
+ }
64
+ return cropped;
65
+ }
35
66
 
36
67
  const shouldClearTerminalForFrame = ({
37
68
  isTTY,
@@ -187,7 +218,7 @@ export type Options = {
187
218
  onRender?: (metrics: RenderMetrics) => void;
188
219
  isScreenReaderEnabled?: boolean;
189
220
  maxFps?: number;
190
- incrementalRendering?: boolean;
221
+ colorProfile?: import("#/screen/color-profile.ts").ColorProfile;
191
222
 
192
223
  /**
193
224
  Enable React Concurrent Rendering mode.
@@ -241,7 +272,7 @@ export type Options = {
241
272
  };
242
273
 
243
274
  /**
244
- A live Ink renderer for one stdout stream, created by `createInk`.
275
+ A live React terminal runtime for one stdout stream, created by `createInk`.
245
276
  */
246
277
  export type Ink = {
247
278
  /**
@@ -269,6 +300,12 @@ export type Ink = {
269
300
  Clear output.
270
301
  */
271
302
  clear: () => void;
303
+
304
+ /** Copy text through the renderer-owned terminal session. */
305
+ copyToClipboard: (text: string, selection?: ClipboardSelection) => boolean;
306
+
307
+ /** Update terminal-native progress through the renderer-owned session. */
308
+ setProgress: (state: TerminalProgressState, value?: number) => boolean;
272
309
  };
273
310
 
274
311
  export const createInk = (options: Options): Ink => {
@@ -297,54 +334,42 @@ export const createInk = (options: Options): Ink => {
297
334
  // where the property is absent (e.g. `node app.js | cat`).
298
335
  const interactive = options.interactive ?? (!isInCi && Boolean(options.stdout.isTTY));
299
336
 
300
- let alternateScreen = false;
337
+ const terminal = new TerminalSession({
338
+ stdin: options.stdin,
339
+ stdout: options.stdout,
340
+ stderr: options.stderr,
341
+ colorPolicy: options.colorProfile ?? "auto",
342
+ onCapabilitiesChange: () => rootNode.onRender?.(),
343
+ });
344
+ const terminalOsc = {
345
+ publishProgress: (
346
+ owner: symbol,
347
+ state: import("#/ansi/osc.ts").TerminalProgressState,
348
+ value?: number,
349
+ ) => {
350
+ terminal.publishProgress(owner, state, value);
351
+ },
352
+ copyToClipboard: (text: string, selection?: import("#/ansi/osc.ts").ClipboardSelection) => {
353
+ terminal.copyToClipboard(text, selection);
354
+ },
355
+ publishTitle: (owner: symbol, title?: string) => terminal.publishTitle(owner, title),
356
+ setWorkingDirectory: (directory: URL | string) => terminal.setWorkingDirectory(directory),
357
+ notify: (title: string) => terminal.notify(title),
358
+ setPointerShape: (shape: string) => terminal.setPointerShape(shape),
359
+ };
360
+ const capabilitiesStore = terminal.capabilities;
301
361
 
302
362
  const unthrottled = options.debug || isScreenReaderEnabled;
303
- const maxFps = options.maxFps ?? 30;
304
- // Treat non-positive maxFps as an internal fallback case, not a supported
305
- // "disable throttling" mode. Keep animation scheduling on a normal cadence
306
- // so future changes don't accidentally reintroduce zero-delay loops.
307
- const frameIntervalMs = maxFps > 0 ? Math.max(1, Math.ceil(1000 / maxFps)) : 0;
308
- const renderThrottleMs = unthrottled ? 0 : frameIntervalMs;
309
-
310
- let hasPendingThrottledRender = false;
311
- let throttledOnRender: Throttled<never[]> | undefined;
312
-
313
- if (unthrottled) {
314
- rootNode.onRender = onRender;
315
- } else {
316
- const throttled = throttle(onRender, frameIntervalMs);
317
- rootNode.onRender = () => {
318
- hasPendingThrottledRender = true;
319
- throttled();
320
- };
321
-
322
- throttledOnRender = throttled;
323
- }
324
-
325
- rootNode.onImmediateRender = onRender;
326
- rootNode.onStaticChange = handleStaticChange;
327
- const log = logUpdate.create(options.stdout, {
328
- incremental: options.incrementalRendering,
363
+ const renderScheduler = createRenderScheduler(onRender, {
364
+ unthrottled,
365
+ maxFps: options.maxFps ?? 30,
329
366
  });
330
- let cursorPosition: CursorPosition | undefined;
331
- const logThrottle = unthrottled
332
- ? undefined
333
- : throttle((output: string) => {
334
- const shouldWrite = log.willRender(output);
335
- const sync = shouldSync();
336
- if (sync && shouldWrite) {
337
- options.stdout.write(bsu);
338
- }
339
-
340
- log(output);
341
-
342
- if (sync && shouldWrite) {
343
- options.stdout.write(esu);
344
- }
345
- });
346
- const throttledLog: LogUpdate | Throttled<[output: string]> = logThrottle ?? log;
347
-
367
+ rootNode.onRender = renderScheduler.schedule;
368
+ rootNode.onImmediateRender = renderScheduler.immediate;
369
+ rootNode.onStaticChange = handleStaticChange;
370
+ const accessiblePresenter = isScreenReaderEnabled
371
+ ? createInlinePresenter(options.stdout, { showCursor: true })
372
+ : undefined;
348
373
  // Ignore last render after unmounting a tree to prevent empty output before exit
349
374
  let isUnmounted = false;
350
375
  let isUnmounting = false;
@@ -355,6 +380,7 @@ export const createInk = (options: Options): Ink => {
355
380
  let lastOutput = "";
356
381
  let lastOutputToRender = "";
357
382
  let lastOutputHeight = 0;
383
+ let lastScreen: Screen | undefined;
358
384
  let lastTerminalWidth = getWindowSize(options.stdout).columns;
359
385
  let lastTerminalHeight = getWindowSize(options.stdout).rows;
360
386
 
@@ -363,7 +389,7 @@ export const createInk = (options: Options): Ink => {
363
389
  let fullStaticOutput = "";
364
390
 
365
391
  let exitResult: unknown;
366
- let beforeExitHandler: (() => void) | undefined;
392
+ let unsubscribeBeforeExit: (() => void) | undefined;
367
393
  let restoreConsole: (() => void) | undefined;
368
394
  // Partial trailing lines from captured direct writes, held until a newline.
369
395
  const capturedStdioTails = { stdout: "", stderr: "" };
@@ -372,8 +398,6 @@ export const createInk = (options: Options): Ink => {
372
398
  let kittyFlags: KittyFlagName[] | undefined;
373
399
  let cancelKittyDetection: (() => void) | undefined;
374
400
  let nextRenderCommit: { promise: Promise<void>; resolve: () => void } | undefined;
375
- // Set while suspendTerminal() has handed the terminal to a child process.
376
- let isSuspended = false;
377
401
  // Input pause/resume hooks registered by the App component, which owns raw
378
402
  // mode and bracketed paste state.
379
403
  let pauseInput: (() => void) | undefined;
@@ -436,11 +460,12 @@ export const createInk = (options: Options): Ink => {
436
460
  // render to be a full rewrite instead of an incremental diff that
437
461
  // would skip "unchanged" lines over stale screen content.
438
462
  if (currentWidth < lastTerminalWidth || currentHeight !== lastTerminalHeight) {
439
- // `log.clear()` erases the full previous frame line count from
463
+ // Clearing erases the full previous frame line count from
440
464
  // the cursor upward — after a height grow that also covers frame
441
465
  // lines the emulator pulled back from scrollback, so no extra
442
466
  // erase is needed for them.
443
- log.clear();
467
+ clearLiveOutput();
468
+ resetLiveOutput();
444
469
  lastOutput = "";
445
470
  lastOutputToRender = "";
446
471
  // Also forget the previous frame height: it described a frame
@@ -473,8 +498,8 @@ export const createInk = (options: Options): Ink => {
473
498
  }
474
499
 
475
500
  function setCursorPosition(position: CursorPosition | undefined): void {
476
- cursorPosition = position;
477
- log.setCursorPosition(position);
501
+ terminal.setCursor(position);
502
+ accessiblePresenter?.setCursorPosition(position);
478
503
  }
479
504
 
480
505
  function restoreLastOutput(): void {
@@ -482,10 +507,31 @@ export const createInk = (options: Options): Ink => {
482
507
  return;
483
508
  }
484
509
 
485
- // Clear() resets log-update's cursor state, so replay the latest cursor intent
486
- // before restoring output after external stdout/stderr writes.
487
- log.setCursorPosition(cursorPosition);
488
- log(lastOutputToRender || lastOutput + "\n");
510
+ // Replay the latest cursor intent when restoring after external output.
511
+ if (isScreenReaderEnabled) {
512
+ accessiblePresenter!.setCursorPosition(terminal.cursor.position);
513
+ accessiblePresenter!(lastOutputToRender || lastOutput + "\n");
514
+ } else if (lastScreen) {
515
+ terminal.present(lastScreen, {
516
+ fullscreen: lastOutputToRender === lastOutput,
517
+ forceRewrite: true,
518
+ });
519
+ }
520
+ }
521
+
522
+ function clearLiveOutput(): void {
523
+ if (isScreenReaderEnabled) accessiblePresenter!.clear();
524
+ else terminal.clearFrame();
525
+ }
526
+
527
+ function finishLiveOutput(): void {
528
+ if (isScreenReaderEnabled) accessiblePresenter!.done();
529
+ else terminal.finishFrame();
530
+ }
531
+
532
+ function resetLiveOutput(): void {
533
+ if (isScreenReaderEnabled) accessiblePresenter!.reset();
534
+ else terminal.resetFrame();
489
535
  }
490
536
 
491
537
  function calculateLayout(): void {
@@ -502,7 +548,7 @@ export const createInk = (options: Options): Ink => {
502
548
  }
503
549
 
504
550
  function onRender(): void {
505
- hasPendingThrottledRender = false;
551
+ renderScheduler.markRendered();
506
552
 
507
553
  if (isUnmounted) {
508
554
  return;
@@ -511,7 +557,7 @@ export const createInk = (options: Options): Ink => {
511
557
  // While suspended, the terminal belongs to a child process. Discard queued
512
558
  // renders; resume() forces a full redraw once Ink reclaims the terminal.
513
559
  // Resolve any awaited render commit so callers don't hang during suspension.
514
- if (isSuspended) {
560
+ if (terminal.suspended) {
515
561
  if (nextRenderCommit) {
516
562
  nextRenderCommit.resolve();
517
563
  nextRenderCommit = undefined;
@@ -526,7 +572,25 @@ export const createInk = (options: Options): Ink => {
526
572
  }
527
573
 
528
574
  const startTime = performance.now();
529
- const { output, outputHeight, staticOutput } = renderer(rootNode, isScreenReaderEnabled);
575
+ const rendered = renderFrame(rootNode, isScreenReaderEnabled, {
576
+ colorProfile: terminal.colorProfile,
577
+ paintContext: {
578
+ appearance: capabilitiesStore.current.theme.appearance,
579
+ palette: capabilitiesStore.current.theme.palette,
580
+ },
581
+ });
582
+ const screen = rendered.screen;
583
+ const output = rendered.accessibleText ?? (screen ? terminal.encode(screen) : "");
584
+ const outputHeight =
585
+ rendered.accessibleText === undefined
586
+ ? (screen?.height ?? 0)
587
+ : rendered.accessibleText === ""
588
+ ? 0
589
+ : rendered.accessibleText.split("\n").length;
590
+ const staticBody =
591
+ rendered.staticAccessibleText ??
592
+ (rendered.staticScreen ? terminal.encode(rendered.staticScreen) : "");
593
+ const staticOutput = staticBody ? `${staticBody}\n` : "";
530
594
 
531
595
  options.onRender?.({ renderTime: performance.now() - startTime });
532
596
 
@@ -608,29 +672,38 @@ export const createInk = (options: Options): Ink => {
608
672
  fullStaticOutput += staticOutput;
609
673
  }
610
674
 
611
- renderInteractiveFrame(output, outputHeight, hasStaticOutput ? staticOutput : "");
675
+ renderInteractiveFrame(
676
+ output,
677
+ outputHeight,
678
+ hasStaticOutput ? staticOutput : "",
679
+ screen,
680
+ false,
681
+ );
612
682
  }
613
683
 
614
684
  function render(node: ReactNode): void {
615
685
  const tree = (
616
686
  <AccessibilityContext.Provider value={{ isScreenReaderEnabled }}>
617
- <App
618
- stdin={options.stdin}
619
- stdout={options.stdout}
620
- stderr={options.stderr}
621
- exitOnCtrlC={options.exitOnCtrlC}
622
- interactive={interactive}
623
- renderThrottleMs={renderThrottleMs}
624
- writeToStdout={writeToStdout}
625
- writeToStderr={writeToStderr}
626
- setCursorPosition={setCursorPosition}
627
- onExit={handleAppExit}
628
- onWaitUntilRenderFlush={waitUntilRenderFlush}
629
- onSuspendTerminal={suspendTerminal}
630
- onRegisterInputControl={registerInputControl}
631
- >
632
- {node}
633
- </App>
687
+ <TerminalOscContext.Provider value={terminalOsc}>
688
+ <App
689
+ stdin={options.stdin}
690
+ stdout={options.stdout}
691
+ stderr={options.stderr}
692
+ exitOnCtrlC={options.exitOnCtrlC}
693
+ interactive={interactive}
694
+ renderThrottleMs={renderScheduler.intervalMs}
695
+ terminalInput={terminal.input}
696
+ writeToStdout={writeToStdout}
697
+ writeToStderr={writeToStderr}
698
+ setCursorPosition={setCursorPosition}
699
+ onExit={handleAppExit}
700
+ onWaitUntilRenderFlush={waitUntilRenderFlush}
701
+ onSuspendTerminal={suspendTerminal}
702
+ onRegisterInputControl={registerInputControl}
703
+ >
704
+ {node}
705
+ </App>
706
+ </TerminalOscContext.Provider>
634
707
  </AccessibilityContext.Provider>
635
708
  );
636
709
 
@@ -652,7 +725,7 @@ export const createInk = (options: Options): Ink => {
652
725
  // While suspended, the terminal belongs to a child process. Don't erase or
653
726
  // repaint Ink's frame around console output; the forced redraw on resume
654
727
  // restores the screen.
655
- if (isSuspended) {
728
+ if (terminal.suspended) {
656
729
  return;
657
730
  }
658
731
 
@@ -671,7 +744,7 @@ export const createInk = (options: Options): Ink => {
671
744
  options.stdout.write(bsu);
672
745
  }
673
746
 
674
- log.clear();
747
+ clearLiveOutput();
675
748
  options.stdout.write(data);
676
749
  restoreLastOutput();
677
750
 
@@ -686,7 +759,7 @@ export const createInk = (options: Options): Ink => {
686
759
  }
687
760
 
688
761
  // See writeToStdout: stay off the terminal while suspended.
689
- if (isSuspended) {
762
+ if (terminal.suspended) {
690
763
  return;
691
764
  }
692
765
 
@@ -706,7 +779,7 @@ export const createInk = (options: Options): Ink => {
706
779
  options.stdout.write(bsu);
707
780
  }
708
781
 
709
- log.clear();
782
+ clearLiveOutput();
710
783
  options.stderr.write(data);
711
784
  restoreLastOutput();
712
785
 
@@ -723,10 +796,8 @@ export const createInk = (options: Options): Ink => {
723
796
 
724
797
  isUnmounting = true;
725
798
 
726
- if (beforeExitHandler) {
727
- process.off("beforeExit", beforeExitHandler);
728
- beforeExitHandler = undefined;
729
- }
799
+ unsubscribeBeforeExit?.();
800
+ unsubscribeBeforeExit = undefined;
730
801
 
731
802
  const { canWriteToStdout } = getWritableStreamState(options.stdout);
732
803
 
@@ -737,14 +808,14 @@ export const createInk = (options: Options): Ink => {
737
808
 
738
809
  // Clear any pending throttled render timer on unmount. When stdout is writable,
739
810
  // flush so the final frame is emitted; otherwise cancel to avoid delayed callbacks.
740
- settleThrottle(throttledOnRender, canWriteToStdout);
811
+ settleThrottle(renderScheduler.throttled, canWriteToStdout);
741
812
 
742
813
  if (canWriteToStdout) {
743
814
  // If throttling is enabled and there is already a pending render, flushing above
744
815
  // is sufficient. Also avoid calling onRender() again when static output already
745
816
  // exists, as that can duplicate <Static> children output on exit (see issue #397).
746
817
  const shouldRenderFinalFrame =
747
- !throttledOnRender || (!hasPendingThrottledRender && fullStaticOutput === "");
818
+ !renderScheduler.throttled || (!renderScheduler.pending && fullStaticOutput === "");
748
819
 
749
820
  if (shouldRenderFinalFrame) {
750
821
  calculateLayout();
@@ -757,10 +828,8 @@ export const createInk = (options: Options): Ink => {
757
828
  isUnmounted = true;
758
829
 
759
830
  unsubscribeExit();
831
+ terminal.cleanup();
760
832
 
761
- // Flush any pending throttled log writes if possible, otherwise cancel to
762
- // prevent delayed callbacks from writing to a closed stream.
763
- settleThrottle(logThrottle, canWriteToStdout);
764
833
  if (typeof restoreConsole === "function") {
765
834
  // Once unmount starts, Ink stops trying to manage teardown-time
766
835
  // console output. Restoring the native console before React cleanup keeps
@@ -790,10 +859,9 @@ export const createInk = (options: Options): Ink => {
790
859
  // diagnostics onto it. Trying to preserve teardown output across the
791
860
  // buffer switch adds fragile lifecycle-specific behavior, so Ink keeps
792
861
  // alternate-screen teardown intentionally simple and best-effort.
793
- if (alternateScreen) {
794
- writeBestEffort(options.stdout, ansiEscapes.exitAlternativeScreen);
795
- writeBestEffort(options.stdout, showCursorEscape);
796
- alternateScreen = false;
862
+ if (terminal.alternateScreen) {
863
+ terminal.setAlternateScreen(false);
864
+ terminal.setCursorAppearance({ visible: true });
797
865
  }
798
866
 
799
867
  if (!interactive) {
@@ -803,7 +871,7 @@ export const createInk = (options: Options): Ink => {
803
871
  // deferred during rendering).
804
872
  options.stdout.write(options.debug ? "\n" : lastOutput + "\n");
805
873
  } else if (!options.debug) {
806
- log.done();
874
+ finishLiveOutput();
807
875
  }
808
876
  }
809
877
 
@@ -857,12 +925,10 @@ export const createInk = (options: Options): Ink => {
857
925
  }
858
926
 
859
927
  async function waitUntilExit(): Promise<unknown> {
860
- if (!beforeExitHandler) {
861
- beforeExitHandler = () => {
928
+ if (!unsubscribeBeforeExit) {
929
+ unsubscribeBeforeExit = registerBeforeExit(() => {
862
930
  unmount();
863
- };
864
-
865
- process.once("beforeExit", beforeExitHandler);
931
+ });
866
932
  }
867
933
 
868
934
  return exitPromise;
@@ -899,9 +965,8 @@ export const createInk = (options: Options): Ink => {
899
965
 
900
966
  const { canWriteToStdout } = getWritableStreamState(options.stdout);
901
967
 
902
- // Flush pending throttled render/log timers so their output is included in this wait.
903
- settleThrottle(throttledOnRender, canWriteToStdout);
904
- settleThrottle(logThrottle, canWriteToStdout);
968
+ // Flush pending scheduled rendering so its output is included in this wait.
969
+ settleThrottle(renderScheduler.throttled, canWriteToStdout);
905
970
 
906
971
  if (canWriteToStdout) {
907
972
  await new Promise<void>((resolve) => {
@@ -917,10 +982,10 @@ export const createInk = (options: Options): Ink => {
917
982
 
918
983
  function clear(): void {
919
984
  if (interactive && !options.debug) {
920
- log.clear();
985
+ clearLiveOutput();
921
986
  // Sync lastOutput so that unmount's final onRender
922
- // sees it as unchanged and log-update skips it
923
- log.sync(lastOutputToRender || lastOutput + "\n");
987
+ // sees it as unchanged and the presenter skips it
988
+ if (isScreenReaderEnabled) accessiblePresenter!.sync(lastOutputToRender || lastOutput + "\n");
924
989
  }
925
990
  }
926
991
 
@@ -1049,12 +1114,9 @@ export const createInk = (options: Options): Ink => {
1049
1114
  }
1050
1115
 
1051
1116
  function setAlternateScreen(enabled: boolean): void {
1052
- alternateScreen = enabled && interactive && Boolean(options.stdout.isTTY);
1053
-
1054
- if (alternateScreen) {
1055
- writeBestEffort(options.stdout, ansiEscapes.enterAlternativeScreen);
1056
- writeBestEffort(options.stdout, hideCursorEscape);
1057
- }
1117
+ terminal.setAlternateScreen(enabled && interactive && Boolean(options.stdout.isTTY), {
1118
+ hideCursor: true,
1119
+ });
1058
1120
  }
1059
1121
 
1060
1122
  function shouldSync(): boolean {
@@ -1092,7 +1154,10 @@ export const createInk = (options: Options): Ink => {
1092
1154
  output: string,
1093
1155
  outputHeight: number,
1094
1156
  staticOutput: string,
1157
+ screen: Screen | undefined,
1158
+ hasOverflow: boolean,
1095
1159
  ): void {
1160
+ if (!screen) return;
1096
1161
  const hasStaticOutput = staticOutput !== "";
1097
1162
  const isTTY = Boolean(options.stdout.isTTY);
1098
1163
 
@@ -1110,6 +1175,7 @@ export const createInk = (options: Options): Ink => {
1110
1175
  const lines = output.split("\n");
1111
1176
  output = lines.slice(lines.length - viewportRows).join("\n");
1112
1177
  outputHeight = viewportRows;
1178
+ screen = bottomRows(screen, viewportRows);
1113
1179
  }
1114
1180
 
1115
1181
  const isFullscreen = isTTY && outputHeight >= viewportRows;
@@ -1133,7 +1199,8 @@ export const createInk = (options: Options): Ink => {
1133
1199
  lastOutput = output;
1134
1200
  lastOutputToRender = outputToRender;
1135
1201
  lastOutputHeight = outputHeight;
1136
- log.sync(outputToRender);
1202
+ lastScreen = screen;
1203
+ terminal.resetFrame();
1137
1204
 
1138
1205
  if (sync) {
1139
1206
  options.stdout.write(esu);
@@ -1142,28 +1209,26 @@ export const createInk = (options: Options): Ink => {
1142
1209
  return;
1143
1210
  }
1144
1211
 
1145
- // To ensure static output is cleanly rendered before main output, clear main output first
1212
+ const willPresent = terminal.willPresent(screen, {
1213
+ fullscreen: isFullscreen,
1214
+ forceRewrite: hasStaticOutput || hasOverflow,
1215
+ });
1216
+ const sync = shouldSync() && willPresent;
1217
+ if (sync) terminal.write(bsu);
1146
1218
  if (hasStaticOutput) {
1147
- const sync = shouldSync();
1148
- if (sync) {
1149
- options.stdout.write(bsu);
1150
- }
1151
-
1152
- log.clear();
1153
- options.stdout.write(staticOutput);
1154
- log(outputToRender);
1155
-
1156
- if (sync) {
1157
- options.stdout.write(esu);
1158
- }
1159
- } else if (output !== lastOutput || log.isCursorDirty()) {
1160
- // ThrottledLog manages its own bsu/esu at actual write time
1161
- throttledLog(outputToRender);
1219
+ terminal.clearFrame();
1220
+ terminal.write(staticOutput);
1162
1221
  }
1222
+ terminal.present(screen, {
1223
+ fullscreen: isFullscreen,
1224
+ forceRewrite: hasStaticOutput || hasOverflow,
1225
+ });
1226
+ if (sync) terminal.write(esu);
1163
1227
 
1164
1228
  lastOutput = output;
1165
1229
  lastOutputToRender = outputToRender;
1166
1230
  lastOutputHeight = outputHeight;
1231
+ lastScreen = screen;
1167
1232
  }
1168
1233
 
1169
1234
  function initKittyKeyboard(): void {
@@ -1215,13 +1280,7 @@ export const createInk = (options: Options): Ink => {
1215
1280
  }
1216
1281
 
1217
1282
  function beginSuspend(): void {
1218
- if (isSuspended) {
1219
- throw new Error(
1220
- "The terminal is already suspended. Resume the current suspension before suspending again.",
1221
- );
1222
- }
1223
-
1224
- isSuspended = true;
1283
+ terminal.beginSuspension();
1225
1284
 
1226
1285
  if (!interactive || isUnmounted || isUnmounting) {
1227
1286
  return;
@@ -1230,9 +1289,8 @@ export const createInk = (options: Options): Ink => {
1230
1289
  try {
1231
1290
  const { canWriteToStdout } = getWritableStreamState(options.stdout);
1232
1291
 
1233
- // Flush any pending render/log so the child starts from a settled screen.
1234
- settleThrottle(throttledOnRender, canWriteToStdout);
1235
- settleThrottle(logThrottle, canWriteToStdout);
1292
+ // Flush any pending render so the child starts from a settled screen.
1293
+ settleThrottle(renderScheduler.throttled, canWriteToStdout);
1236
1294
 
1237
1295
  if (canWriteToStdout) {
1238
1296
  flushCapturedStdio();
@@ -1241,14 +1299,14 @@ export const createInk = (options: Options): Ink => {
1241
1299
  if (canWriteToStdout) {
1242
1300
  // Erase Ink's current frame, then show the cursor and re-arm the hide.
1243
1301
  // The forced redraw on resume hides the cursor again.
1244
- log.clear();
1245
- log.done();
1302
+ clearLiveOutput();
1303
+ finishLiveOutput();
1246
1304
 
1247
1305
  if (kittyProtocolEnabled) {
1248
1306
  writeBestEffort(options.stdout, ansiEscapes.popKittyKeyboard);
1249
1307
  }
1250
1308
 
1251
- if (alternateScreen) {
1309
+ if (terminal.alternateScreen) {
1252
1310
  writeBestEffort(options.stdout, ansiEscapes.exitAlternativeScreen);
1253
1311
  }
1254
1312
  }
@@ -1259,7 +1317,7 @@ export const createInk = (options: Options): Ink => {
1259
1317
  // If handing over the terminal fails partway, don't strand the app in a
1260
1318
  // suspended state with no way back. Best-effort reclaim input, clear the
1261
1319
  // flag, and rethrow so the caller sees the failure.
1262
- isSuspended = false;
1320
+ terminal.resume();
1263
1321
 
1264
1322
  try {
1265
1323
  resumeInput?.();
@@ -1270,11 +1328,11 @@ export const createInk = (options: Options): Ink => {
1270
1328
  }
1271
1329
 
1272
1330
  async function endSuspend(): Promise<void> {
1273
- if (!isSuspended) {
1331
+ if (!terminal.suspended) {
1274
1332
  return;
1275
1333
  }
1276
1334
 
1277
- isSuspended = false;
1335
+ terminal.resume();
1278
1336
 
1279
1337
  // Reclaim input even mid-unmount: pauseInput already ran in beginSuspend, so
1280
1338
  // restoring it is symmetric regardless of any state change during suspension.
@@ -1287,7 +1345,7 @@ export const createInk = (options: Options): Ink => {
1287
1345
  const { canWriteToStdout } = getWritableStreamState(options.stdout);
1288
1346
 
1289
1347
  if (canWriteToStdout) {
1290
- if (alternateScreen) {
1348
+ if (terminal.alternateScreen) {
1291
1349
  writeBestEffort(options.stdout, ansiEscapes.enterAlternativeScreen);
1292
1350
  }
1293
1351
 
@@ -1303,7 +1361,7 @@ export const createInk = (options: Options): Ink => {
1303
1361
  lastOutput = "";
1304
1362
  lastOutputToRender = "";
1305
1363
  lastOutputHeight = 0;
1306
- log.reset();
1364
+ resetLiveOutput();
1307
1365
 
1308
1366
  try {
1309
1367
  calculateLayout();
@@ -1318,5 +1376,7 @@ export const createInk = (options: Options): Ink => {
1318
1376
  waitUntilExit,
1319
1377
  waitUntilRenderFlush,
1320
1378
  clear,
1379
+ copyToClipboard: (text, selection) => terminal.copyToClipboard(text, selection),
1380
+ setProgress: (state, value) => terminal.setProgress(state, value),
1321
1381
  };
1322
1382
  };