1// @effect-diagnostics globalDate:off globalConsole:off - Vendored @clo/lib has no Effect dependency.
2/**
3 * by using `@clo/lib/log`, an application gets easy scoped logging as well
4 * as integration with terminal widgets such as `progress.ts`. even when these
5 * widgets are active, using the logging interface is optional; global I/O with
6 * `console.*` and `process.std{out/err}` are automatically patched to play nice
7 * so nearly any attempts at writing to the terminal should display fine.
8 * additionally, the clover library writes to a global symbol to communicate with
9 * other copies of this library (even across versions) to coordinate drawing.
10 *
11 * the pattern for using this module is to shadow the global `console` with a
12 * per-file logging scope, which makes it impossible to use the wrong logger.
13 *
14 * ```ts
15 * import * as log from "@clo/lib/log";
16 * const console = log.scoped("http");
17 * console.info("Hello world!"); // info message
18 * console.log("hi!"); // debug message only
19 * ```
20 *
21 * or to use the global scope, import the module's namespace as `console`.
22 *
23 * ```ts
24 * import * as console from "@clo/lib/log";
25 * ```
26 *
27 * now code reads familiarly (`console.log` is universally understood),
28 * but the output is organized into relevant scopes.
29 *
30 * in addition to static log messages, a system for interactive I/O via the
31 * {@linkcode Widget} interface can be started with {@linkcode startWidget}.
32 * these allow showing temporary or interactive information, such as program
33 * status or input prompts. a powerful example of this system in action is
34 * `lib/progress.ts`, which uses a widget as it's default rendering backend.
35 * alongside this, a very complicated locking system is provided to ensure that
36 * widgets to not interfere with globals like `process.stdout.write`. a widget
37 * that defines `input` receives the keyboard, which `lib/repl.ts` builds a
38 * line editor on.
39 *
40 * `lib/log.ts` offers two environment integrations:
41 * - in node.js, log messages are colored depending on the level and show
42 * widgets directly under the long using ANSI cursor controls. when the
43 * terminal is not a TTY, widgets are silent.
44 * - in browsers and other, logs are surfaced using the global `console` API
45 * and widgets are disabled.
46 *
47 * custom log integrations can be built on top of this module by calling
48 * `log.tee()` to duplicate all messages elsewhere. for example, a project may
49 * configure logs to upload to a telemetry service. these logs can carry custom
50 * metadata as well as source code traces -- amazing tools for debugging.
51 *
52 * if bundling for the browser, see the details in `./node.ts` on how to
53 * trigger tree-shaking to eliminate the node.js bindings in your web bundle.
54 *
55 * @module
56 */
57
58/**
59 * logging scopes implement part of the `Console` API. the `log` module itself
60 * satisfies this interface.
61 */
62export interface Scope {
63 /** emit an informational message */
64 info(...args: unknown[]): void;
65 /** emit an advisory message */
66 warn(...args: unknown[]): void;
67 /** emit a failure message */
68 error(...args: unknown[]): void;
69
70 /**
71 * emit a debugging message.
72 */
73 log(...args: unknown[]): void;
74 /**
75 * emit a debugging message. this method is in place for compatibility with
76 * the `console` API. prefer calling `.log` directly.
77 * @internal
78 */
79 debug(...args: unknown[]): void;
80
81 /** write a Message object directly. */
82 writeMessage(message: Message): void;
83
84 /** create a nested sub-scope */
85 scoped(name: string, custom?: Message["custom"]): Scope;
86 /** redirect the logging output of this scope somewhere else */
87 tee(writer: (message: Message) => void): ts.Dispose;
88}
89
90export interface RootScope extends Scope {
91 /** write a partial line to the output. */
92 write(text: string): void;
93}
94
95export const originalLogArgs = Symbol("originalLogArgs");
96
97/**
98 * a message written in a log scope.
99 * this API will never be altered in a breaking way.
100 */
101export interface Message {
102 /** ANSI-styled text */
103 text: string;
104 /** datetime in milliseconds since UNIX epoch */
105 time: number;
106 /**
107 * type of message, if known.
108 * @default info
109 */
110 level?: MessageLevel | undefined;
111 /** scope name */
112 scope?: string | null | undefined;
113 /** captured stack, if available */
114 stack?: stack.Frame[] | undefined;
115 /** arbitrary data from the logging source. */
116 custom?: Partial<Record<string, ts.Json>> | undefined;
117 /**
118 * if there is a newline at the end of this log line.
119 * @default true
120 */
121 newline?: boolean | undefined;
122 /**
123 * original logging arguments, if present. this field is indexed by a symbol
124 * so that it is lost during JSON serialization, as callers are allowed to log
125 * non-serializable data.
126 */
127 [originalLogArgs]?: unknown[];
128}
129/** this API will never be altered in a breaking way. */
130type MessageLevel = "error" | "warn" | "info" | "debug";
131
132// these functions implement `Scope` for the module's namespace. that means if
133// a function takes in `Scope`, this file's namespace satisfies that.
134
135/** emit an informational message on the global log scope */
136export function info(...args: unknown[]) {
137 globalLog.info(...args);
138}
139/** emit an advisory message on the global log scope */
140export function warn(...args: unknown[]) {
141 globalLog.warn(...args);
142}
143/** emit an failure message on the global log scope */
144export function error(...args: unknown[]) {
145 globalLog.error(...args);
146}
147/** emit an debug message on the global log scope */
148export function log(...args: unknown[]) {
149 globalLog.log(...args);
150}
151/**
152 * emit an debug message on the global log scope. this method is in place for
153 * compatibility with the `console` API. prefer calling `.log` directly.
154 * @internal
155 */
156export function debug(...args: unknown[]) {
157 globalLog.debug(...args);
158}
159/** create a named logging scope, optionally carrying custom message fields */
160export function scoped(name: string, custom?: Message["custom"]): Scope {
161 return globalLog.scoped(name, custom);
162}
163/** redirect all log messages to another writer */
164export function tee(destination: DispatchFunction): ts.Dispose {
165 global.tees.add(destination);
166 return ts.defer(() => global.tees.delete(destination));
167}
168
169/** replace the default message writer */
170export function replaceGlobalMessageDestination(destination: DispatchFunction) {
171 global.writeMessage = destination;
172}
173
174/**
175 * replace the default interactive widget host. this applies for all separately
176 * installed copies of the library, across versions. once the widget host has
177 * been activated, it must be preserved forever.
178 */
179export function replaceGlobalWidgetHost(host: WidgetHost) {
180 if (global.widget?.frozen) {
181 throw new Error(
182 `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`,
183 );
184 }
185 global.widget = {
186 frozen: false,
187 source:
188 stack.capture()[0]?.file ??
189 "untracable call to replaceGlobalWidgetHost in " + import.meta.url,
190 host,
191 version: 0,
192 };
193}
194
195/**
196 * replace the default message formatter. does not affect browsers because that
197 * code path does not use `formatAnsiMessage`
198 */
199export function replaceGlobalFormatAnsiMessage(format: (msg: Message, colors: boolean) => string) {
200 global.formatAnsiMessage = format;
201}
202
203/** includes the trailing newline for standard log messages */
204export function formatAnsiMessage(msg: Message, colors: boolean): string {
205 return (global.formatAnsiMessage ?? defaultFormatAnsiMessage)(msg, colors);
206}
207
208/**
209 * a widget is an interactive display that persists at the end
210 * of the log. this can be used to implement status bars, progress
211 * indicators, and other human I/O. only 'format' is required.
212 *
213 * returns `null` if the terminal is not interactive or the global renderer is
214 * incapable of displaying this log (such as in a browser)
215 *
216 * ```ts
217 * using _ = log.startWidget({
218 * format: (now) => `It is ${new Date().toString()} right now\n`
219 * + `A random number: ${Math.random()}`,
220 * // no trailing '\n' is needed.
221 * });
222 * await async.delay(10000);
223 *
224 * import * as log from "@clo/lib/log";
225 * import * as async from "@clo/lib/async";
226 * ```
227 */
228export function startWidget<T extends WidgetOptions>(widget: T): WidgetInstance<T> | null {
229 return globalWidgetHost().startWidget(widget);
230}
231
232/**
233 * no built-in prefix, formatting, or newlline. ensures the text does not interweave.
234 * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.
235 */
236export function writeOutput(text: string) {
237 globalWidgetHost().writeOutput(text);
238}
239
240/**
241 * like {@linkcode writeOutput}, but the text is written to the error stream
242 * (stderr in node.js). relative ordering between output and error text is
243 * preserved through the shared flush buffer.
244 */
245export function writeError(text: string) {
246 globalWidgetHost().writeError(text);
247}
248
249/** write a {@linkcode Message} object directly. */
250export function writeMessage(m: Message) {
251 globalLog.writeMessage(m);
252}
253
254/**
255 * hold a lock that stops `@clo/lib` from drawing to the terminal. this is an
256 * advanced api that is likely unneeded unless messing with external APIs that
257 * freely write to the terminal.
258 *
259 * NOTE: if the lock will be held for an extremely short amount of time, pass
260 * `"short"` which will allow more optimized use of ansi synchronization codes.
261 */
262export function getDrawLock(mode: "long" | "short"): DrawLock {
263 return globalWidgetHost().getDrawLock(mode);
264}
265
266/**
267 * call this if you are doing silly things with `process.stderr` and you want to
268 * make sure that starting a log widget (such as `@clo/lib/progress`) doesn't
269 * have interweaving bugs. in particular, you need this if your program directly
270 * writes incomplete lines to the terminal (missing a trailing `\n`).
271 *
272 * the globals are patched to track if widget text must move to a fresh line
273 * before drawing, as well as knowing to return to the original position
274 * afterwards.
275 *
276 * you most likely do not need to call this API.
277 */
278export function ensureGlobalsArePatched(): ts.Dispose {
279 if (global.widget?.frozen && global.widget?.version !== version) {
280 throw new Error(
281 `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`,
282 );
283 }
284 const w = globalWidgetHost().startWidget?.({ format: () => "" });
285 return ts.defer(() => w?.stop());
286}
287
288/**
289 * a non-exclusive lock to drawing
290 * this API will never be altered in a breaking way.
291 */
292export interface DrawLock {
293 /**
294 * decides if widget drawing requires an extra newline, which is needed if
295 * there is extra text on the line that the lock is being released on.
296 */
297 release(endState: "cursor-start-of-line" | "cursor-middle-of-line"): void;
298 /** Assumes worst case `cursor-middle-of-line` */
299 [Symbol.dispose](): void;
300}
301
302export function headlessScope(dispatch: DispatchFunction): RootScope {
303 return new ScopeImpl(dispatch);
304}
305
306/**
307 * see {@linkcode startWidget}
308 * this API will never be altered in a breaking way.
309 */
310export interface WidgetOptions {
311 /**
312 * return the widget's text. return null to detach the widget.
313 * may get called more often than the specified `fps`.
314 * supports color codes but not ansi cursor movements.
315 */
316 format(ctx: WidgetFormatContext): string | WidgetFrame | null;
317 /** 'null' to never update automatically */
318 fps?: number | null;
319 /**
320 * receive keyboard input. the terminal is only read while some widget
321 * defines this, and events go to the most recently started one. reading
322 * puts the terminal in raw mode, so `ctrl+c` arrives here as an event
323 * instead of interrupting the process.
324 */
325 input?(event: WidgetInput): void;
326}
327
328/**
329 * one key press or paste, as given to {@linkcode WidgetOptions.input}.
330 * this API will never be altered in a breaking way.
331 */
332export interface WidgetInput {
333 /**
334 * the key, prefixed by its modifiers in the order
335 * `ctrl+alt+shift+super+hyper+meta+`. printable keys are named by the
336 * grapheme they type (`"a"`, `"A"`, `"é"`), except `"space"`. other keys
337 * are named in lowercase: `"enter"`, `"tab"`, `"backspace"`, `"delete"`,
338 * `"escape"`, `"up"`, `"down"`, `"left"`, `"right"`, `"home"`, `"end"`,
339 * `"pageup"`, `"pagedown"`, `"insert"`, `"f1"` and up. a letter combined
340 * with `ctrl`, `alt`, or `super` is lowercase with an explicit `shift+`.
341 * a bracketed paste is the key `"paste"`.
342 */
343 key: string;
344 /** what this input types, with `"\n"` for line breaks. `""` if nothing. */
345 text: string;
346}
347
348/**
349 * the object form of a widget's text.
350 * this API will never be altered in a breaking way.
351 */
352export interface WidgetFrame {
353 text: string;
354 /**
355 * where the terminal cursor rests: `line` indexes the lines of `text`, and
356 * `column` counts cells. honored for the widget receiving input.
357 */
358 cursor?: { line: number; column: number };
359}
360
361/**
362 * control for a widget (see {@linkcode startWidget})
363 * this API will never be altered in a breaking way.
364 */
365export interface WidgetInstance<T extends WidgetOptions = WidgetOptions> {
366 /** the provided widget options */
367 options: T;
368 get fps(): number | null;
369 set fps(fps: number | null);
370 /** schedules a new frame to be drawn as soon as possible */
371 redraw(): void;
372 /** remove the widget from the screen */
373 stop(): void;
374 /** alias of `stop` */
375 [Symbol.dispose](): void;
376}
377
378/**
379 * this API will never be altered in a breaking way.
380 */
381export interface WidgetFormatContext {
382 /** the current time according to the widget host */
383 now: ReturnType<typeof performance.now>;
384 /** advisory */
385 width: number;
386 /** advisory */
387 height: number;
388 host?: WidgetHost;
389 /** whether this widget is the one receiving keyboard input */
390 focused?: boolean;
391}
392
393/** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */
394export interface TerminalWidgetHostOptions {
395 /**
396 * an exclusive lock on the terminal is held whenever widgets are active. a
397 * secondary purpose of this is to instrument/deinstrument other code to
398 * integrate with `log.ts`'s widget lock. for example, the node.js adapter
399 * will patch `process.std{out,err}` to ensure write calls properly get a
400 * draw lock.
401 *
402 * this lock can be cleared by deactiving all widgets, or by calling
403 * {@linkcode getDrawLock} to force `@clo/lib` to stop drawing.
404 *
405 * currently, this lock must be able to be synchronously aquired at any
406 * point. if you desire an async locking function, please contact me so we
407 * can design how it would work.
408 */
409 lockTerminal: () => TerminalLock;
410 /** fast path for writing output without widgets */
411 writeOutputTemporaryLock?: (buffer: string) => void;
412 /** monotonic milliseconds */
413 now: () => ReturnType<typeof performance.now>;
414 /** after resolving, `now()` should have increased by the delay time */
415 delay: typeof async.delay;
416 /** is there color support? */
417 color: boolean;
418 /**
419 * whether text passed to `writeOutput` lands on the same screen as the
420 * interactive output. pass `false` when output is redirected to a file or
421 * pipe while the interactive stream remains a terminal; the widget cursor
422 * math then ignores log output entirely, since written rows do not
423 * displace the widget block.
424 * @default true
425 */
426 outputSharesScreen?: boolean;
427 /**
428 * read the keyboard until the returned function is called, passing along
429 * what the terminal sends as decoded text. only called while the terminal
430 * is locked. the terminal must not echo or line-buffer in the meantime
431 * (raw mode). leave undefined if there is no keyboard to read.
432 */
433 observeInput?: ((callback: (data: string) => void) => () => void) | undefined;
434}
435
436/**
437 * when `@clo/lib` requests a lock on the terminal, the adapter provides this
438 * interface to communicate everything about the terminal state correctly.
439 */
440export interface TerminalLock {
441 /**
442 * recieves ANSI escape sequences for interactive data, as well as error log
443 * content from `writeError` (should flush immediately). the interactive
444 * stream and the error stream are the same: stderr.
445 */
446 writeInteractive(text: string): void;
447 /** recieves log content from `write` (pre-buffered; should flush immediately) */
448 writeOutput(text: string): void;
449 /**
450 * subscribe to the terminal size. the callback must fire synchronously
451 * with the current size before this function returns, and again whenever
452 * the size changes (SIGWINCH). returns an unsubscribe function. on a size
453 * change, the widget host erases its stale drawing and repaints, since a
454 * resize rewraps previously drawn rows and invalidates all relative
455 * cursor math.
456 */
457 observeSize(callback: (size: { columns: number; rows: number }) => void): () => void;
458 /** temporarily free the lock */
459 temporaryUnlock?(): () => void;
460 /** completely free the lock */
461 close(): void;
462}
463
464/**
465 * an implementation of an ANSI-based widget host.
466 * this structure must not be altered in a breaking way.
467 */
468export interface WidgetHost {
469 /** see the top-level {@linkcode writeOutput} function */
470 writeOutput(text: string): void;
471 /** see the top-level {@linkcode writeError} function */
472 writeError(text: string): void;
473 /** see the top-level {@linkcode getDrawLock} function */
474 getDrawLock(mode: "long" | "short"): DrawLock;
475 /** see the top-level {@linkcode startWidget} function */
476 startWidget<T extends WidgetOptions>(widget: T): WidgetInstance<T> | null;
477 /** stop all widgets and remove all timers. */
478 cancel(): void;
479 /** generic delay function */
480 delay?: typeof async.delay;
481 /** generic now function */
482 now?: typeof performance.now;
483 /**
484 * what does this widget host support?
485 * - color: ANSI escape sequences are displayed and not stripped
486 * - widget: `startWidget` can meaninglyful display the widget
487 */
488 capabilities: ReadonlyArray<"widget" | "color">;
489}
490
491/** @internal state */
492interface WidgetState {
493 frameTime: number;
494}
495
496// thresholds for flushing the log buffer outside the redraw timer. the 0ms
497// timer batches a synchronous burst of writes into one flush, but waiting on
498// it is wrong in two situations: the buffer growing toward the engine string
499// length limit, and a timer starved by cpu-bound work blocking the event
500// loop (it may never fire).
501const flushSyncBytes = 65536;
502const flushSyncMs = 50;
503
504/** how long a lone escape byte may wait to become the start of a sequence */
505const escapeMs = 50;
506
507/**
508 * terminal widget rendering is done by specifying all system APIs up front in
509 * an interface, creating an instance of the "widget host".
510 */
511export function createTerminalWidgetHost(env: TerminalWidgetHostOptions): WidgetHost {
512 const { lockTerminal, now, delay, writeOutputTemporaryLock, color, observeInput } = env;
513 const outputOnScreen = env.outputSharesScreen ?? true;
514
515 let timer: async.Cancelable<void> | null = null;
516
517 let rendering = false;
518 let pendingRedraw = false;
519 let locks = 0;
520 let redrawTime = 0;
521 let lastFlush = 0;
522 // log text awaiting a flush. error text shares the queue so that relative
523 // write order is preserved, but flushes to the interactive stream (stderr)
524 // and always lands on the widget screen, while plain output rows only
525 // count when `outputSharesScreen`. consecutive same-stream writes merge
526 // into one chunk.
527 let buffer: { text: string; err: boolean }[] = [];
528 // visible width of the trailing partial log line (text since the last "\n"
529 // written to output). the cursor column is derived as `partialWidth %
530 // columns` at draw time, so the value survives resizes and wrapped lines.
531 let partialWidth = 0;
532 let needsToSaveCursor = false;
533 let needsToRestoreCursor = false;
534 const widgets: WidgetOptions[] = [];
535 const internals: WidgetState[] = [];
536 let lines: string[] = [];
537 let hasSyncStart = false;
538 let terminal: TerminalLock | null = null;
539 let size: { columns: number; rows: number } | null = null;
540 let unwatchSize: (() => void) | null = null;
541 let tempUnlock: (() => void) | null = null;
542 // how many rows above its resting place (the line under the widgets) the
543 // cursor was left by a widget's `cursor`, and in which column. all other
544 // cursor math assumes the resting place, so every write begins with
545 // `unpark()`.
546 let parked = 0;
547 let parkedColumn = 0;
548 let unwatchInput: (() => void) | null = null;
549 let escapeTimer: async.Cancelable<void> | null = null;
550 // the terminal is asked once, since an answer that comes after the
551 // keyboard was given back is typed into whatever reads it next
552 let keyboard: "unknown" | "asked" | "enhanced" = "unknown";
553 let xtermjs = false;
554
555 // the size subscription lives exactly as long as the terminal lock, so
556 // acquisition and closing are funneled through these two functions.
557 function acquireTerminal(): TerminalLock {
558 if (!terminal) {
559 let initial = true;
560 size = null;
561 terminal = lockTerminal();
562 unwatchSize = terminal.observeSize((next) => {
563 size = next;
564 if (!initial) handleResize();
565 });
566 ASSERT(size, "TerminalLock.observeSize must call back synchronously with the current size");
567 initial = false;
568 }
569 return terminal;
570 }
571 function closeTerminal() {
572 endInput();
573 unwatchSize?.();
574 unwatchSize = null;
575 terminal?.close();
576 terminal = null;
577 }
578
579 function unpark(): string {
580 const text = parked ? ansi.cursorDown(parked) + "\r" : "";
581 parked = 0;
582 return text;
583 }
584
585 function dispatchInput(events: WidgetInput[]) {
586 for (const event of events) {
587 widgets.findLast((widget) => widget.input)?.input?.(event);
588 }
589 }
590
591 function startInput(term: TerminalLock) {
592 const decoder = new InputDecoder((params, final) => {
593 if (keyboard === "asked" && final === "u" && params.startsWith("?")) {
594 keyboard = "enhanced";
595 term.writeInteractive(keyboardEnhance);
596 }
597 if (final === "c" && params === xtermjsVersion) xtermjs = true;
598 });
599 const unwatch = UNWRAP(observeInput)((data) => {
600 escapeTimer?.cancel();
601 escapeTimer = null;
602 dispatchInput(decoder.push(data));
603 // a widget may have given up the keyboard in response
604 if (decoder.pending && unwatchInput === unwatch) {
605 (escapeTimer = delay(escapeMs)).then(() => {
606 escapeTimer = null;
607 dispatchInput(decoder.flush());
608 });
609 }
610 });
611 unwatchInput = unwatch;
612
613 term.writeInteractive(
614 pasteEnable +
615 (keyboard === "enhanced" ? keyboardEnhance : "") +
616 (keyboard === "unknown" ? keyboardQuery + versionQuery : ""),
617 );
618 if (keyboard === "unknown") keyboard = "asked";
619 }
620
621 function endInput() {
622 if (!unwatchInput) return;
623 escapeTimer?.cancel();
624 escapeTimer = null;
625 UNWRAP(terminal).writeInteractive(
626 (keyboard === "enhanced" ? keyboardUnenhance : "") + pasteDisable,
627 );
628 unwatchInput();
629 unwatchInput = null;
630 }
631
632 function handleResize() {
633 if (lines.length > 0 && terminal) {
634 // the terminal has wrapped what was drawn to its new width. the widths
635 // that were drawn say how many rows that put between the cursor and
636 // the first row. should that row have left the screen, this stops at
637 // the screen's.
638 const { columns } = UNWRAP(size);
639 const wrapped = lines.map((line) =>
640 Math.max(1, Math.ceil(ansi.widthInTerminal(line) / columns)),
641 );
642 const at = lines.length - parked;
643 let up = wrapped.slice(0, at).reduce((sum, rows) => sum + rows, 0);
644 if (parked && !xtermjs) {
645 // the cursor is kept with the text that it was on
646 up += Math.floor(parkedColumn / columns);
647 } else if (parked) {
648 // xterm.js cuts the cursor's line off instead, and moves the cursor
649 // down for every row that it adds, including those under the cursor
650 up += wrapped.slice(at + 1).reduce((sum, rows) => sum + rows - 1, 0);
651 }
652 terminal.writeInteractive(
653 ansi.cursorUp(up) +
654 "\r" +
655 // short of the first cell, since tmux moves a screen that is
656 // cleared from its top left corner into the scrollback
657 ansi.clearFullLine +
658 ansi.cursorRight(1) +
659 ansi.clearToEndOfScreen +
660 "\r",
661 );
662 lines = [];
663 parked = 0;
664 }
665 // the save register holds pre-resize coordinates; restoring it would
666 // jump somewhere unrelated.
667 needsToRestoreCursor = false;
668 requestRedraw();
669 }
670
671 function redrawCallback() {
672 timer = null;
673 ASSERT(!rendering);
674 rendering = true;
675 // the finally is load-bearing: if a render callback or terminal write
676 // throws, `rendering` must reset, or the exit path's cancel() asserts
677 // and masks the original error.
678 try {
679 redrawCallbackInner();
680 } finally {
681 rendering = false;
682 if (pendingRedraw) {
683 pendingRedraw = false;
684 redrawSoon(0);
685 }
686 // cancel() during this render pass (an exit handler unwinding through
687 // a crashed format callback) skips its teardown; finish it here.
688 if (widgets.length === 0 && terminal && !timer && !buffer.length) {
689 lines = [];
690 closeTerminal();
691 }
692 }
693 }
694
695 function redrawCallbackInner() {
696 redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround
697
698 // trivial path when not using widgets
699 if (!lines.length && !widgets.length) {
700 if (buffer.length) {
701 needsToRestoreCursor = false;
702 needsToSaveCursor = false;
703 if (!terminal && writeOutputTemporaryLock && !buffer.some((c) => c.err)) {
704 const text = buffer.map((c) => c.text).join("");
705 buffer = [];
706 writeOutputTemporaryLock(text);
707 if (outputOnScreen) trackPartialWidth(text);
708 } else {
709 trackPartialWidth(flushChunks(acquireTerminal()));
710 }
711 }
712 // with nothing to write, this still has to end what a draw lock left
713 // open when it cleared the last widgets
714 if (hasSyncStart) {
715 acquireTerminal().writeInteractive(ansi.syncEnd);
716 hasSyncStart = false;
717 }
718 closeTerminal();
719 return;
720 }
721
722 const term = acquireTerminal();
723 const { columns, rows } = UNWRAP(size);
724 let newWidgetLines: string[] = [];
725 let next = Infinity;
726 const focused = widgets.findLast((widget) => widget.input);
727 // the widget with the keyboard comes first: it gets the rows it needs
728 // before the others share what is left, and it stays where it is while
729 // they come and go under it
730 const order = widgets.filter((widget) => widget !== focused);
731 if (focused) order.unshift(focused);
732 let cursor: WidgetFrame["cursor"];
733 for (const widget of order) {
734 // a format callback may stop another widget, or its own
735 if (!widgets.includes(widget)) continue;
736 let out: string | WidgetFrame | null;
737 try {
738 out = widget.format({
739 now: lastFlush,
740 width: columns,
741 height: rows,
742 focused: widget === focused,
743 });
744 } catch (e) {
745 out = e instanceof Error ? stack.format(e, color) : exceptions.message(e);
746 }
747 const w = widgets.indexOf(widget);
748 if (w === -1) continue;
749 if (out == null) {
750 widgets.splice(w, 1);
751 internals.splice(w, 1);
752 continue;
753 }
754 next = Math.min(next, UNWRAP(internals[w]).frameTime);
755 const outText = typeof out === "string" ? out : out.text;
756 // an empty string is a live widget that currently displays nothing;
757 // only `null` detaches.
758 if (outText === "") continue;
759 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
760 if (rowsLeft === 1) continue;
761 const widgetLines = outText
762 .split("\n")
763 .slice(0, rowsLeft)
764 .map((line) => ansi.trimForTerminal(line, columns - 1));
765 newWidgetLines.push(...widgetLines);
766 const at = widget === focused && typeof out !== "string" ? out.cursor : undefined;
767 if (at && Number.isInteger(at.line) && at.line >= 0 && at.line < widgetLines.length) {
768 cursor = {
769 line: at.line,
770 column: Math.max(0, Math.min(Math.trunc(at.column), columns - 1)),
771 };
772 }
773 }
774 newWidgetLines = newWidgetLines.slice(0, rows - 1);
775 if (next < Infinity) redrawSoon(next);
776
777 if (observeInput && widgets.some((widget) => widget.input)) {
778 if (!unwatchInput) startInput(term);
779 } else {
780 endInput();
781 }
782
783 // a cursor on a line that did not fit on screen stays at rest
784 const park =
785 cursor && cursor.line < newWidgetLines.length ? newWidgetLines.length - cursor.line : 0;
786 const parkText = park ? ansi.cursorUp(park) + ansi.cursorRight(UNWRAP(cursor).column) : "";
787
788 const pCol = partialWidth % columns;
789
790 if (newWidgetLines.length === 0) {
791 if (lines.length > 0) {
792 term.writeInteractive(
793 (hasSyncStart ? "" : ansi.syncStart) +
794 unpark() +
795 // clear the widget space
796 (ansi.cursorUp(1) + ansi.clearFullLine).repeat(lines.length) +
797 (pCol ? ansi.cursorUp(1) + ansi.cursorRight(pCol) : "") +
798 (needsToRestoreCursor ? ansi.cursorRestore : ""),
799 );
800 hasSyncStart = true;
801 needsToRestoreCursor = false;
802 lines = [];
803 }
804 if (buffer.length) {
805 trackPartialWidth(flushChunks(term));
806 }
807 if (hasSyncStart) {
808 term.writeInteractive(ansi.syncEnd);
809 hasSyncStart = false;
810 }
811 // the last widget may have detached this frame; release the terminal
812 // (and its patches) instead of holding the lock until the next flush
813 if (widgets.length === 0) closeTerminal();
814 return;
815 }
816
817 // concatenation of the buffered text that lands on the widget screen,
818 // which is what the cursor math must account for
819 const screenText = buffer
820 .filter((c) => c.err || outputOnScreen)
821 .map((c) => c.text)
822 .join("");
823 if (buffer.length && screenText === "") {
824 // off-screen output (e.g. stdout redirected to a file) does not
825 // interact with the widget block; flush it plainly and fall through
826 // to a pure widget redraw
827 flushChunks(term);
828 }
829
830 if (buffer.length) {
831 // when writing a buffer alongside widgets, the screen may look like this
832 // > [existing log]
833 // > [optional partial line]
834 // > [widget line 1]
835 // > [widget line 2]
836 // > [widget line 3]
837 // > [cursor is start of this line]
838 //
839 // first, clear out the space where new lines are going to intersect.
840 // `span.rows` measures the cursor descent in physical rows, so log
841 // lines wider than the terminal are accounted for correctly.
842 const span = measureTerminalSpan(screenText, pCol, columns);
843 // if more rows are buffered than there are widgets, only some are
844 // needed. when a partial line exists, the buffer starts on its row
845 // (one above the widget block), hence the -1.
846 const clearLinesTop = Math.min(
847 lines.length,
848 span.rows + (span.endWidth % columns > 0 ? 1 : 0) + (pCol ? -1 : 0),
849 );
850 const oldLines = lines.slice(clearLinesTop);
851 term.writeInteractive(
852 (needsToSaveCursor ? ansi.cursorSave + "\n" : "") +
853 (hasSyncStart ? "" : ansi.syncStart) +
854 unpark() +
855 (clearLinesTop > 0 || (pCol && lines.length > 0)
856 ? // clear the lines for buffer
857 (clearLinesTop > 0
858 ? ansi.cursorUp(lines.length - clearLinesTop + 1) +
859 ansi.clearFullLine +
860 (ansi.cursorUp(1) + ansi.clearFullLine).repeat(clearLinesTop - 1)
861 : "") +
862 (pCol && lines.length > 0
863 ? ansi.cursorUp(clearLinesTop > 0 ? 1 : lines.length + 1) + ansi.cursorRight(pCol)
864 : "")
865 : ""),
866 );
867 // then write the buffered log content to its streams
868 flushChunks(term);
869 term.writeInteractive(
870 // if the buffer leaves a partial line, the widgets have to go on the
871 // next line. to avoid breaking stdout, the newline gets emitted on
872 // the interactive out. (a line ending exactly on the terminal edge
873 // leaves the cursor wrap-deferred; this newline lands on the next
874 // row, which is also where `span.rows` placed the continuation.)
875 (span.endWidth > 0 ? "\n" : "") +
876 widgetText(newWidgetLines, oldLines) +
877 // rows of a taller widget block that nothing was written over
878 (oldLines.length > newWidgetLines.length ? ansi.clearToEndOfScreen : "") +
879 parkText +
880 ansi.syncEnd,
881 );
882 partialWidth = span.endWidth;
883 } else {
884 const clearLinesBottom = Math.min(
885 lines.length,
886 Math.max(0, lines.length - newWidgetLines.length),
887 );
888 term.writeInteractive(
889 (needsToSaveCursor ? ansi.cursorSave + "\n" : "") +
890 (hasSyncStart ? "" : ansi.syncStart) +
891 unpark() +
892 // the first draw can land just after a partial log line; widgets
893 // must move below it. (`partialWidth` is zero whenever
894 // `needsToSaveCursor` is set, since releasing a draw lock resets
895 // it, so this never combines with the cursorSave newline.)
896 (lines.length === 0 && partialWidth > 0 ? "\n" : "") +
897 // clear the bottom lines
898 (clearLinesBottom
899 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine).repeat(clearLinesBottom)
900 : "") +
901 // skip up the widget space
902 ansi.cursorUp(lines.length - clearLinesBottom) +
903 widgetText(newWidgetLines, lines) +
904 parkText +
905 ansi.syncEnd,
906 );
907 }
908 hasSyncStart = false;
909 needsToRestoreCursor ||= needsToSaveCursor;
910 needsToSaveCursor = false;
911 lines = newWidgetLines;
912 parked = park;
913 parkedColumn = cursor?.column ?? 0;
914 }
915
916 /** `next` drawn from the cursor down, over rows that hold `previous` */
917 function widgetText(next: string[], previous: string[]): string {
918 const rows = next
919 .map(
920 (line, i) =>
921 // a row that is only erased from where the shorter line ends is still
922 // as long as before to tmux, which wraps it on a narrower screen
923 (previous[i] && ansi.widthInTerminal(previous[i]) > ansi.widthInTerminal(line)
924 ? ansi.clearFullLine
925 : "") +
926 (line.includes("\x1b") && !line.endsWith(ansi.reset) ? line + ansi.reset : line) +
927 "\n",
928 )
929 .join("");
930 // a terminal can be narrower than it last said, as its pty is told of a
931 // resize after its screen is. a row that wrapped there would be two.
932 return unwatchInput ? wrapOff + rows + wrapOn : rows;
933 }
934
935 /**
936 * write every buffered chunk to its stream in order, returning the
937 * concatenation of the chunks that landed on the widget screen. error
938 * chunks flush through `writeInteractive` since the interactive stream is
939 * the error stream; plain output rows land on screen only when
940 * `outputSharesScreen`.
941 */
942 function flushChunks(term: TerminalLock): string {
943 let screen = "";
944 for (const chunk of buffer) {
945 if (chunk.err) {
946 term.writeInteractive(chunk.text);
947 screen += chunk.text;
948 } else {
949 term.writeOutput(chunk.text);
950 if (outputOnScreen) screen += chunk.text;
951 }
952 }
953 buffer = [];
954 return screen;
955 }
956
957 /**
958 * update `partialWidth` after `screenText` (already filtered to the chunks
959 * that landed on the widget screen) was written.
960 */
961 function trackPartialWidth(screenText: string) {
962 const i = screenText.lastIndexOf("\n");
963 partialWidth = ansi.widthInTerminal(screenText.slice(i + 1)) + (i === -1 ? partialWidth : 0);
964 }
965
966 function redrawSoon(ms: number) {
967 if (locks > 0 || (ms === 0 && rendering)) return;
968 const newRedrawTime = now() + ms;
969 if (timer) {
970 if (redrawTime < newRedrawTime) return;
971 timer.cancel(); // cancel previous
972 }
973 // 1ms wiggle room, generally runtimes have much larger variance on timers
974 redrawTime = newRedrawTime - 1;
975 timer = delay(ms);
976 timer.then(redrawCallback);
977 }
978
979 function requestRedraw() {
980 if (rendering) pendingRedraw = true;
981 else redrawSoon(0);
982 }
983
984 function flushAndClear(shortTermDrawLock: boolean) {
985 timer?.cancel();
986 timer = null;
987 if (lines.length > 0) {
988 const term = UNWRAP(terminal);
989 const pCol = partialWidth % UNWRAP(size).columns;
990 term.writeInteractive(
991 ansi.syncStart +
992 unpark() +
993 // clear the widget space
994 (ansi.cursorUp(1) + ansi.clearFullLine).repeat(lines.length) +
995 (pCol ? ansi.cursorUp(1) + ansi.cursorRight(pCol) : "") +
996 (shortTermDrawLock ? "" : ansi.syncEnd) +
997 (needsToRestoreCursor ? ansi.cursorRestore : ""),
998 );
999 needsToRestoreCursor = false;
1000 lines = [];
1001 hasSyncStart = shortTermDrawLock;
1002 }
1003 if (buffer.length > 0) {
1004 if (
1005 widgets.length === 0 &&
1006 !terminal &&
1007 writeOutputTemporaryLock &&
1008 !buffer.some((c) => c.err)
1009 ) {
1010 const text = buffer.map((c) => c.text).join("");
1011 buffer = [];
1012 writeOutputTemporaryLock(text);
1013 if (outputOnScreen) trackPartialWidth(text);
1014 } else {
1015 trackPartialWidth(flushChunks(acquireTerminal()));
1016 if (widgets.length === 0) closeTerminal();
1017 }
1018 }
1019 }
1020
1021 /** append a write to the flush buffer, scheduling the flush */
1022 function bufferChunk(chunk: string, err: boolean) {
1023 const last = buffer[buffer.length - 1];
1024 if (last && last.err === err) last.text += chunk;
1025 else buffer.push({ text: chunk, err });
1026 redrawSoon(0);
1027 if (locks > 0 || rendering) return;
1028 if (buffer.reduce((n, c) => n + c.text.length, 0) >= flushSyncBytes) {
1029 flushSync(true);
1030 } else if (timer && now() >= redrawTime + flushSyncMs) {
1031 // the scheduled flush is long overdue, so the event loop is blocked;
1032 // flushing inline keeps logs streaming through cpu-bound work
1033 flushSync(false);
1034 }
1035 }
1036
1037 /**
1038 * run the redraw flush immediately instead of waiting for the timer. with
1039 * `keepPartialLine`, text after the last buffered newline stays in the
1040 * buffer so a line under construction is not displayed mid-way; if the
1041 * buffer holds no newline at all, everything flushes to bound memory.
1042 */
1043 function flushSync(keepPartialLine: boolean) {
1044 let tail: typeof buffer = [];
1045 if (keepPartialLine) {
1046 let i = buffer.length - 1;
1047 while (i >= 0 && !UNWRAP(buffer[i]).text.includes("\n")) i -= 1;
1048 if (i >= 0) {
1049 const edge = UNWRAP(buffer[i]);
1050 const split = edge.text.lastIndexOf("\n") + 1;
1051 tail = buffer.splice(i + 1);
1052 if (split < edge.text.length) {
1053 tail.unshift({ text: edge.text.slice(split), err: edge.err });
1054 edge.text = edge.text.slice(0, split);
1055 }
1056 }
1057 }
1058 timer?.cancel();
1059 timer = null;
1060 redrawCallback();
1061 buffer = tail;
1062 if (tail.length) redrawSoon(0);
1063 }
1064
1065 return {
1066 writeOutput(chunk) {
1067 if (chunk) bufferChunk(chunk, false);
1068 },
1069 writeError(chunk) {
1070 if (chunk) bufferChunk(chunk, true);
1071 },
1072 getDrawLock(mode) {
1073 if (rendering) ASSERT(locks === 0);
1074 if (locks === 0) flushAndClear(mode === "short");
1075 // whoever holds a long lock may read the terminal themselves
1076 if (mode === "long") endInput();
1077 if (locks === 0 && widgets.length > 0 && terminal) {
1078 if (terminal.temporaryUnlock) {
1079 tempUnlock = terminal.temporaryUnlock();
1080 } else {
1081 closeTerminal();
1082 }
1083 }
1084 locks += 1;
1085 let disposed = false;
1086 return {
1087 release(mode) {
1088 if (disposed) return;
1089 disposed = true;
1090 locks -= 1;
1091 needsToSaveCursor ||= mode === "cursor-middle-of-line";
1092 // whatever the lock holder wrote has detached the cursor from any
1093 // previously tracked partial log line. "cursor-start-of-line"
1094 // states the cursor is on a fresh line; "cursor-middle-of-line"
1095 // engages the save/restore dance instead.
1096 partialWidth = 0;
1097 if (locks === 0) {
1098 tempUnlock?.();
1099 tempUnlock = null;
1100 // a redraw is also what lets go of a terminal that the lock
1101 // took from widgets which are gone by now
1102 if (buffer.length > 0 || widgets.length > 0 || terminal || hasSyncStart) {
1103 redrawSoon(0);
1104 }
1105 }
1106 },
1107 [Symbol.dispose]() {
1108 this.release("cursor-middle-of-line");
1109 },
1110 };
1111 },
1112 startWidget(options) {
1113 ASSERT(!widgets.includes(options), "Cannot start the same widget twice.");
1114 let fps = options.fps ?? null;
1115 const state: WidgetState = {
1116 frameTime: 1000 / (fps ?? 0),
1117 };
1118 widgets.push(options);
1119 internals.push(state);
1120 requestRedraw();
1121 return {
1122 options,
1123 get fps() {
1124 return fps;
1125 },
1126 set fps(value) {
1127 fps = value;
1128 state.frameTime = 1000 / (fps ?? 0);
1129 // a pending frame may sit beyond the new cadence; draw to reanchor
1130 requestRedraw();
1131 },
1132 redraw: requestRedraw,
1133 stop() {
1134 const i = widgets.indexOf(options);
1135 if (i === -1) return;
1136 widgets.splice(i, 1);
1137 UNWRAP(internals.splice(i, 1)[0]);
1138 requestRedraw();
1139 },
1140 [Symbol.dispose]() {
1141 this.stop();
1142 },
1143 };
1144 },
1145 cancel() {
1146 // cancel may be reached from exit handlers while a redraw is on the
1147 // stack (a crash inside a render callback unwinds through here); skip
1148 // the flush in that case and just tear down, so the original error
1149 // is the one that surfaces.
1150 if (!rendering) flushAndClear(false);
1151 widgets.splice(0, widgets.length);
1152 internals.splice(0, internals.length);
1153 timer?.cancel();
1154 timer = null;
1155 // the keyboard is given back even then, as this may be the exit
1156 endInput();
1157 if (!rendering) closeTerminal();
1158 },
1159 delay,
1160 now,
1161 capabilities: color ? ["widget", "color"] : ["widget"],
1162 };
1163}
1164
1165/**
1166 * compute the cursor descent (`rows`) and trailing line width (`endWidth`)
1167 * from writing `text` to a terminal `columns` wide, with the cursor starting
1168 * `startWidth` cells into a line. wrapping follows the DECAWM deferred-wrap
1169 * convention shared by modern terminals: a line of exactly `columns` cells
1170 * leaves the cursor pending on the same row, so a newline after it descends
1171 * only one row. a trailing line ending exactly on the boundary reports its
1172 * continuation point at column 0 of the next row (`endWidth % columns === 0`
1173 * with the descended row included in `rows`).
1174 */
1175function measureTerminalSpan(
1176 text: string,
1177 startWidth: number,
1178 columns: number,
1179): { rows: number; endWidth: number } {
1180 let rows = 0;
1181 let width = startWidth;
1182 const parts = text.split("\n");
1183 for (let i = 0; i < parts.length - 1; i += 1) {
1184 width += ansi.widthInTerminal(UNWRAP(parts[i]));
1185 rows += Math.max(1, Math.ceil(width / columns));
1186 width = 0;
1187 }
1188 width += ansi.widthInTerminal(UNWRAP(parts[parts.length - 1]));
1189 rows += Math.floor(width / columns);
1190 return { rows, endWidth: width };
1191}
1192
1193/** starts and stops bracketed paste, around reading the keyboard */
1194const pasteEnable = "\x1b[?2004h";
1195const pasteDisable = "\x1b[?2004l";
1196/**
1197 * asks if the terminal supports the kitty keyboard protocol. one that does
1198 * answers with a report of `"?"` parameters ending in `"u"`.
1199 */
1200const keyboardQuery = "\x1b[?u";
1201/**
1202 * written once the terminal answers, to have it encode keys unambiguously.
1203 * this is what tells `shift+enter` from `enter`.
1204 */
1205const keyboardEnhance = "\x1b[>1u";
1206const keyboardUnenhance = "\x1b[<u";
1207/**
1208 * asks which terminal this is, as xterm would tell its version. xterm.js
1209 * has a fixed answer, and no other way of telling that it is the one.
1210 */
1211const versionQuery = "\x1b[>c";
1212const xtermjsVersion = ">0;276;0";
1213/** stops and starts the terminal continuing on the next row at its last column */
1214const wrapOff = "\x1b[?7l";
1215const wrapOn = "\x1b[?7h";
1216const pasteEnd = "\x1b[201~";
1217
1218/**
1219 * turns what a terminal sends for keyboard input into {@linkcode WidgetInput}:
1220 * legacy xterm sequences, the [kitty keyboard protocol], `modifyOtherKeys`,
1221 * and bracketed paste. stateful, as any of those can be split across reads.
1222 *
1223 * [kitty keyboard protocol]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
1224 */
1225class InputDecoder {
1226 #report: (params: string, final: string) => void;
1227 /** an escape sequence, or a paste terminator, that is cut off */
1228 #pending = "";
1229 #paste: string | null = null;
1230
1231 /**
1232 * `report` receives control sequences where the terminal describes itself
1233 * instead of a key, split into parameter bytes and the final byte.
1234 */
1235 constructor(report: (params: string, final: string) => void = () => {}) {
1236 this.#report = report;
1237 }
1238
1239 /**
1240 * true when input ended inside an escape sequence. a lone escape byte is
1241 * either the escape key or the start of a sequence, and only time can tell
1242 * them apart: if nothing else arrives shortly, call {@linkcode flush}.
1243 */
1244 get pending(): boolean {
1245 return this.#pending !== "" && this.#paste === null;
1246 }
1247
1248 /** decode the next chunk of terminal input */
1249 push(chunk: string): WidgetInput[] {
1250 const events: WidgetInput[] = [];
1251 const data = this.#pending + chunk;
1252 this.#pending = "";
1253
1254 let i = 0;
1255 while (i < data.length) {
1256 if (this.#paste !== null) {
1257 const end = data.indexOf(pasteEnd, i);
1258 if (end === -1) {
1259 const escape = data.lastIndexOf("\x1b");
1260 const cut = escape >= i && pasteEnd.startsWith(data.slice(escape)) ? escape : data.length;
1261 this.#paste += data.slice(i, cut);
1262 this.#pending = data.slice(cut);
1263 break;
1264 }
1265 const text = this.#paste + data.slice(i, end);
1266 events.push({ key: "paste", text: text.replace(/\r\n?/g, "\n") });
1267 this.#paste = null;
1268 i = end + pasteEnd.length;
1269 continue;
1270 }
1271
1272 const code = data.charCodeAt(i);
1273 if (code === 0x1b) {
1274 const length = this.#escape(data, i, events);
1275 if (length === 0) {
1276 this.#pending = data.slice(i);
1277 break;
1278 }
1279 i += length;
1280 } else if (isControlCode(code)) {
1281 const event = controlKey(code, 0);
1282 if (event) events.push(event);
1283 i += 1;
1284 } else {
1285 let end = i + 1;
1286 while (end < data.length && !isControlCode(data.charCodeAt(end))) end += 1;
1287 graphemes ??= new Intl.Segmenter();
1288 for (const { segment } of graphemes.segment(data.slice(i, end))) {
1289 events.push(printableKey(segment, 0));
1290 }
1291 i = end;
1292 }
1293 }
1294 return events;
1295 }
1296
1297 /** decode input that {@linkcode pending} was waiting on, as it stands */
1298 flush(): WidgetInput[] {
1299 if (!this.pending) return [];
1300 const data = this.#pending;
1301 this.#pending = "";
1302 // a held escape key, or one pressed twice
1303 if (/^\x1b+$/.test(data)) return Array.from(data, () => namedKey("escape", 0));
1304 // anything longer than an alt-prefixed key is a sequence that was cut
1305 // off, which is dropped instead of being keyText out as text
1306 if (data.length > 2) return [];
1307 const code = data.charCodeAt(1);
1308 const event = isControlCode(code) ? controlKey(code, modAlt) : printableKey(data[1]!, modAlt);
1309 return event ? [event] : [];
1310 }
1311
1312 /**
1313 * decode the escape sequence at `data[start]`, returning its length, or
1314 * `0` if it is cut off by the end of `data`.
1315 */
1316 #escape(data: string, start: number, events: WidgetInput[]): number {
1317 // an escape prefix is how legacy terminals encode alt
1318 let last = start;
1319 while (data[last + 1] === "\x1b") last += 1;
1320 const mods = last > start ? modAlt : 0;
1321 const introducer = data[last + 1];
1322 if (introducer === undefined) return 0;
1323
1324 if (introducer === "[") {
1325 let i = last + 2;
1326 // the linux console sends its first function keys as `CSI [ A`
1327 if (data[i] === "[") {
1328 const name = linuxKeys[data[i + 1] ?? ""];
1329 if (name) events.push(namedKey(name, mods));
1330 return i + 1 < data.length ? i + 2 - start : 0;
1331 }
1332 while (inRange(data.charCodeAt(i), 0x30, 0x3f)) i += 1;
1333 const params = data.slice(last + 2, i);
1334 while (inRange(data.charCodeAt(i), 0x20, 0x2f)) i += 1;
1335 if (i >= data.length) return 0;
1336 // a byte that cannot end the sequence aborts it, and is decoded next
1337 if (!inRange(data.charCodeAt(i), 0x40, 0x7e)) return i - start;
1338
1339 const final = data[i]!;
1340 if (params === "200" && final === "~") {
1341 this.#paste = "";
1342 } else if (/^[<=>?]/.test(params)) {
1343 this.#report(params, final);
1344 } else {
1345 const event = csiKey(params, final, mods);
1346 if (event) events.push(event);
1347 }
1348 return i + 1 - start;
1349 }
1350
1351 if (introducer === "O") {
1352 // some terminals put a modifier in front of the final byte
1353 let i = last + 2;
1354 while (inRange(data.charCodeAt(i), 0x30, 0x3b)) i += 1;
1355 if (i >= data.length) return 0;
1356 const name = ss3Keys[data[i]!];
1357 if (name) {
1358 const modifier =
1359 Number(
1360 data
1361 .slice(last + 2, i)
1362 .split(";")
1363 .at(-1),
1364 ) || 1;
1365 events.push(namedKey(name, mods | ((modifier - 1) & keyModifierMask)));
1366 return i + 1 - start;
1367 }
1368 } else if ("]P_^X".includes(introducer)) {
1369 // a string sequence is only recognized when whole, since waiting for
1370 // a terminator would swallow what is keyText after alt+]
1371 const bell = data.indexOf("\x07", last + 2);
1372 const terminator = data.indexOf("\x1b\\", last + 2);
1373 if (bell !== -1 && (terminator === -1 || bell < terminator)) {
1374 return bell + 1 - start;
1375 }
1376 if (terminator !== -1) return terminator + 2 - start;
1377 }
1378
1379 const code = data.codePointAt(last + 1)!;
1380 const event = isControlCode(code)
1381 ? controlKey(code, mods | modAlt)
1382 : printableKey(String.fromCodePoint(code), mods | modAlt);
1383 if (event) events.push(event);
1384 return last - start + (code > 0xffff ? 3 : 2);
1385 }
1386}
1387
1388// modifier bits, numbered as the kitty protocol encodes them
1389const modShift = 1;
1390const modAlt = 2;
1391const modCtrl = 4;
1392/** the lock keys are reported in higher bits, and are not keyModifiers */
1393const keyModifierMask = 0b111111;
1394const keyModifiers: [bit: number, prefix: string][] = [
1395 [modCtrl, "ctrl+"],
1396 [modAlt, "alt+"],
1397 [modShift, "shift+"],
1398 [8, "super+"],
1399 [16, "hyper+"],
1400 [32, "meta+"],
1401];
1402
1403const keyText: Partial<Record<string, string>> = {
1404 enter: "\n",
1405 tab: "\t",
1406 space: " ",
1407};
1408
1409const csiKeys: Partial<Record<string, string>> = {
1410 A: "up",
1411 B: "down",
1412 C: "right",
1413 D: "left",
1414 H: "home",
1415 F: "end",
1416 P: "f1",
1417 Q: "f2",
1418 S: "f4",
1419};
1420
1421const ss3Keys: Partial<Record<string, string>> = { ...csiKeys, R: "f3" };
1422
1423const linuxKeys: Partial<Record<string, string>> = {
1424 A: "f1",
1425 B: "f2",
1426 C: "f3",
1427 D: "f4",
1428 E: "f5",
1429};
1430
1431const tildeKeys: Partial<Record<number, string>> = {
1432 1: "home",
1433 2: "insert",
1434 3: "delete",
1435 4: "end",
1436 5: "pageup",
1437 6: "pagedown",
1438 7: "home",
1439 8: "end",
1440 11: "f1",
1441 12: "f2",
1442 13: "f3",
1443 14: "f4",
1444 15: "f5",
1445 17: "f6",
1446 18: "f7",
1447 19: "f8",
1448 20: "f9",
1449 21: "f10",
1450 23: "f11",
1451 24: "f12",
1452};
1453
1454/** code points that the kitty protocol assigns to keys without text */
1455const functionalKeys: Partial<Record<number, string>> = {
1456 9: "tab",
1457 13: "enter",
1458 27: "escape",
1459 32: "space",
1460 127: "backspace",
1461 57414: "enter",
1462 57417: "left",
1463 57418: "right",
1464 57419: "up",
1465 57420: "down",
1466 57421: "pageup",
1467 57422: "pagedown",
1468 57423: "home",
1469 57424: "end",
1470 57425: "insert",
1471 57426: "delete",
1472};
1473
1474let graphemes: Intl.Segmenter | null = null;
1475
1476function inRange(code: number, min: number, max: number) {
1477 return code >= min && code <= max;
1478}
1479
1480function isControlCode(code: number) {
1481 return code < 0x20 || inRange(code, 0x7f, 0x9f);
1482}
1483
1484function makeKey(base: string, mods: number, text: string): WidgetInput {
1485 const prefix = keyModifiers.map(([bit, name]) => (mods & bit ? name : "")).join("");
1486 return { key: prefix + base, text: mods & ~modShift ? "" : text };
1487}
1488
1489function namedKey(name: string, mods: number): WidgetInput {
1490 // shift+tab goes backwards, which is not a tab to type
1491 return makeKey(name, mods, name === "tab" && mods ? "" : (keyText[name] ?? ""));
1492}
1493
1494function printableKey(grapheme: string, mods: number): WidgetInput {
1495 if (grapheme === " ") return namedKey("space", mods);
1496 if (/^[a-zA-Z]$/.test(grapheme)) {
1497 if (mods & ~modShift) {
1498 if (grapheme !== grapheme.toLowerCase()) mods |= modShift;
1499 grapheme = grapheme.toLowerCase();
1500 } else if (mods) {
1501 mods = 0;
1502 grapheme = grapheme.toUpperCase();
1503 }
1504 }
1505 return makeKey(grapheme, mods, grapheme);
1506}
1507
1508/** a C0 control byte, which is how legacy terminals encode ctrl */
1509function controlKey(code: number, mods: number): WidgetInput | null {
1510 if (code === 0x0d) return namedKey("enter", mods);
1511 if (code === 0x09) return namedKey("tab", mods);
1512 if (code === 0x7f) return namedKey("backspace", mods);
1513 if (code === 0x1b) return namedKey("escape", mods);
1514 if (code === 0x00) return namedKey("space", mods | modCtrl);
1515 if (code > 0x7f) return null;
1516 const letter = String.fromCharCode(code + (code <= 0x1a ? 0x60 : 0x40));
1517 return printableKey(letter, mods | modCtrl);
1518}
1519
1520function csiKey(params: string, final: string, mods: number): WidgetInput | null {
1521 const fields = params.split(";").map((field) => field.split(":").map(Number));
1522 const first = fields[0]?.[0] || 0;
1523 mods |= Math.max(0, (fields[1]?.[0] || 1) - 1) & keyModifierMask;
1524 // key releases are only sent if asked for, but are cheap to rule out
1525 if (fields[1]?.[1] === 3) return null;
1526
1527 if (final === "u" || (final === "~" && first === 27)) {
1528 const [code = 0, shifted = 0] = final === "u" ? fields[0]! : (fields[2] ?? []);
1529 const name =
1530 functionalKeys[code] ?? (inRange(code, 57376, 57398) ? `f${code - 57376 + 13}` : null);
1531 if (name) return namedKey(name, mods);
1532 // the private use area holds keys that have no name here
1533 if (!isCodePoint(code) || isControlCode(code) || inRange(code, 0xe000, 0xf8ff)) return null;
1534 const event =
1535 isCodePoint(shifted) && shifted && mods === modShift
1536 ? printableKey(String.fromCodePoint(shifted), 0)
1537 : printableKey(String.fromCodePoint(code), mods);
1538 const text = final === "u" ? fields[2] : undefined;
1539 if (text?.[0] && text.every(isCodePoint)) event.text = String.fromCodePoint(...text);
1540 return event;
1541 }
1542
1543 if (final === "~") {
1544 const name = tildeKeys[first];
1545 return name ? namedKey(name, mods) : null;
1546 }
1547 if (final === "Z") return namedKey("tab", mods | modShift);
1548 // `CSI row ; column R` is a cursor position report
1549 if (final === "R") return first <= 1 ? namedKey("f3", mods) : null;
1550 const name = csiKeys[final];
1551 return name ? namedKey(name, mods) : null;
1552}
1553
1554/** parameters are whatever digits the terminal sent */
1555function isCodePoint(code: number) {
1556 return Number.isInteger(code) && code <= 0x10ffff;
1557}
1558
1559const logColors = node.process?.stdout.isTTY ?? false;
1560
1561let formatLine = /* @__PURE__ */ (() => {
1562 const fwo = node.builtin("util")?.formatWithOptions;
1563 if (fwo) {
1564 return (...args: unknown[]) => {
1565 if (args[0] instanceof Error) return stack.format(args[0], logColors);
1566 return fwo({ colors: logColors }, ...args);
1567 };
1568 }
1569 return (...args: unknown[]) =>
1570 args
1571 .map((arg) => {
1572 if (typeof arg === "string") return arg;
1573 try {
1574 return JSON.stringify(arg);
1575 } catch {
1576 return "[incompatible json " + typeof arg + "]";
1577 }
1578 })
1579 .join(" ");
1580})();
1581
1582let withinDispatch = false;
1583let withinStackCapture = false;
1584
1585/** this class is an implementation detail */
1586const ScopeImpl = class Scope implements RootScope {
1587 name: string | undefined;
1588 /** attached to every emitted message; see `Scope.scoped` */
1589 #custom: Message["custom"];
1590 // TODO: this abstraction implementation has low performance. making every
1591 // scope define it's own dispatch is needed to correctly implement `tee`.
1592 // since it is possible to implement this in simple and non-recursive way, i
1593 // feel okay adding the `tee` API.
1594 #dispatches: DispatchFunction[] = [];
1595 #dispatch: DispatchFunction = (m) => this.#dispatches.forEach((cb) => cb(m));
1596
1597 constructor(
1598 dispatch: (m: Message) => void,
1599 name: string | undefined = undefined,
1600 custom: Message["custom"] = undefined,
1601 ) {
1602 this.name = name;
1603 this.#custom = custom;
1604 this.#dispatches = [dispatch];
1605 }
1606
1607 #log(level: Message["level"], args: unknown[]) {
1608 let frames;
1609 if (!withinStackCapture) {
1610 withinStackCapture = true;
1611 frames = stack.capture().slice(2);
1612 withinStackCapture = false;
1613 }
1614 this.writeMessage({
1615 level,
1616 scope: this.name,
1617 custom: this.#custom,
1618 get text() {
1619 const value = formatLine(...args);
1620 Object.defineProperty(this, "text", { value });
1621 return value;
1622 },
1623 time: Date.now(),
1624 stack: frames,
1625 [originalLogArgs]: args,
1626 });
1627 }
1628
1629 info: (...args: unknown[]) => void = (...args: unknown[]) => {
1630 this.#log("info", args);
1631 };
1632 warn: (...args: unknown[]) => void = (...args: unknown[]) => {
1633 this.#log("warn", args);
1634 };
1635 error: (...args: unknown[]) => void = (...args: unknown[]) => {
1636 this.#log("error", args);
1637 };
1638 log: (...args: unknown[]) => void = (...args: unknown[]) => {
1639 this.#log("debug", args);
1640 };
1641 debug: (...args: unknown[]) => void = (...args: unknown[]) => {
1642 this.#log("debug", args);
1643 };
1644
1645 writeMessage: (m: Message) => void = (m) => {
1646 if (withinDispatch) return void globalLog.#dispatch(m);
1647 withinDispatch = true;
1648 this.#dispatch(m);
1649 withinDispatch = false;
1650 };
1651
1652 write: (text: string) => void = (text) => {
1653 let frames;
1654 if (!withinStackCapture) {
1655 withinStackCapture = true;
1656 frames = stack.capture().slice(2);
1657 withinStackCapture = false;
1658 }
1659 this.writeMessage({
1660 level: "info",
1661 scope: "",
1662 custom: this.#custom,
1663 newline: false,
1664 text,
1665 time: Date.now(),
1666 stack: frames,
1667 });
1668 };
1669
1670 scoped(name: string, custom?: Message["custom"]): Scope {
1671 const current = this.name;
1672 return new Scope(
1673 this.#dispatch,
1674 name ? (current ? `${current}/${name}` : name) : current,
1675 custom ? { ...this.#custom, ...custom } : this.#custom,
1676 );
1677 }
1678
1679 tee(destination: DispatchFunction) {
1680 this.#dispatches.push(destination);
1681 return ts.defer(() => this.#dispatches.splice(this.#dispatches.indexOf(destination), 1));
1682 }
1683};
1684
1685/**
1686 * global integration is done in a special manner with IIFE expressions to
1687 * ensure that the logic is removed when a bundler tree-shakes this file.
1688 * additionally, this ensures that replacing a global function properly affects
1689 * other installations. this is safe to do because these shared interfaces have
1690 * a commitment to never change in a breaking way.
1691 */
1692interface GlobalCommunication {
1693 readme: string;
1694 /** urls / file paths */
1695 instances: string[];
1696 widget?: {
1697 version: number;
1698 frozen: boolean;
1699 host: WidgetHost;
1700 /** url / file path / line number / identifying information */
1701 source: string;
1702 };
1703 /** overwrite the message writer */
1704 writeMessage?: DispatchFunction;
1705 /** overwrite the message formatter */
1706 formatAnsiMessage?: (m: Message, colors: boolean) => string;
1707 /** calls to the global `tee()` */
1708 tees: Set<DispatchFunction>;
1709}
1710
1711const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log");
1712const version = 6;
1713let global: GlobalCommunication = /* @__PURE__ */ (() => {
1714 const global = ((globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= {
1715 readme:
1716 'this global holds shared state for "@clo/lib/log", to allow different instances of itself to coordinate with each other',
1717 instances: [],
1718 tees: new Set(),
1719 });
1720 global.instances.push(import.meta.url);
1721 return global;
1722})();
1723
1724function defaultFormatAnsiMessage({ level, scope, text, newline }: Message, colors: boolean) {
1725 if (!text) return "";
1726 if (newline === false) return text;
1727 const prefix = colors
1728 ? // colorful
1729 `${levelToAnsi[level ?? "info"]}${scope ? `(${scope})` : ""}${ansi.fgReset}${ansi.dim}:${ansi.reset} `
1730 : // colorless
1731 scope
1732 ? `${level}(${scope}): `
1733 : `${level}: `;
1734 return prefix + text + "\n";
1735}
1736
1737let initWidgetHost = false;
1738function globalWidgetHost(): WidgetHost {
1739 if (
1740 global.widget &&
1741 (initWidgetHost ||
1742 global.widget.frozen ||
1743 // By default, pick the latest version of the widget host implementation.
1744 // This is most likely to resolve the most issues as possible. To opt out of
1745 // this, import the desired implementation and call its
1746 // `ensureGlobalsArePatched` or `replaceGlobalWidgetHost` method.
1747 global.widget.version >= version)
1748 ) {
1749 initWidgetHost = true;
1750 return global.widget.host;
1751 }
1752 initWidgetHost = true;
1753 global.widget = {
1754 frozen: false,
1755 host: node.process ? defaultNodeProcessWidgetHost(node.process) : defaultFallbackWidgetHost(),
1756 source: import.meta.url,
1757 version,
1758 };
1759 return global.widget.host;
1760}
1761
1762export function defaultNodeProcessWidgetHost(
1763 process: NonNullable<typeof node.process>,
1764 forceWidgetSupport = false,
1765): WidgetHost {
1766 // fallback
1767 if (!forceWidgetSupport && !process.stderr.isTTY) {
1768 return {
1769 writeOutput: (string) => process.stdout.write(string),
1770 writeError: (string) => process.stderr.write(string),
1771 getDrawLock: () => ({
1772 release() {},
1773 [Symbol.dispose]() {},
1774 }),
1775 startWidget: () => null,
1776 cancel: () => {},
1777 capabilities: [],
1778 };
1779 }
1780
1781 const host = createTerminalWidgetHost({
1782 lockTerminal() {
1783 const { stdout, stderr } = process;
1784 let disposed = false;
1785
1786 // patch calls to `process.std{out,err}`
1787 // note: `pipe` uses managed calls to `write`, so this is plenty
1788 const stdoutWrite = stdout.write;
1789 const stderrWrite = stderr.write;
1790 const stdoutEnd = stdout.end;
1791 const stderrEnd = stderr.end;
1792 function patchWriteMethod<T, R, A extends [string | Uint8Array]>(
1793 fn: (this: T, ...args: A) => R,
1794 touchesScreen: boolean,
1795 ) {
1796 return function (this: T, ...args: A) {
1797 using lock = disposed ? null : host.getDrawLock("short");
1798 const ret = fn.apply(this, args);
1799 if (lock) {
1800 // writes to a redirected stream never move the screen cursor
1801 lock.release(
1802 !touchesScreen ||
1803 (typeof args[0] === "string"
1804 ? args[0].endsWith("\n")
1805 : bufferEndsInNewline(args[0]))
1806 ? "cursor-start-of-line"
1807 : "cursor-middle-of-line",
1808 );
1809 }
1810 return ret;
1811 };
1812 }
1813 const newStdoutWrite = (stdout.write = patchWriteMethod(stdoutWrite, stdout.isTTY));
1814 const newStderrWrite = (stderr.write = patchWriteMethod(stderrWrite, true));
1815 function patchEndMethod<T, R, A extends unknown[]>(fn: (this: T, ...args: A) => R) {
1816 return function (this: T, ...args: A) {
1817 using lock = disposed ? null : host.getDrawLock("short");
1818 const ret = fn.apply(this, args);
1819 // TODO: this is not handled correctly, but people rarely close their
1820 // standard I/O! this likely should just disable the library if you
1821 // call end. i'm not particularly worried.
1822 if (lock) lock.release("cursor-start-of-line");
1823 return ret;
1824 };
1825 }
1826 const newStdoutEnd = (stdout.end = patchEndMethod(stdoutEnd));
1827 const newStderrEnd = (stderr.end = patchEndMethod(stderrEnd));
1828
1829 // non-node runtimes will typically implement console in a way that
1830 // doesn't use `node:process`, so it must also get patched. this is
1831 // okay because the lock is re-enterant.
1832 function patchSyncMethod<T, A extends unknown[]>(fn: (this: T, ...args: A) => void) {
1833 return function (this: T, ...args: A) {
1834 using lock = disposed ? null : host.getDrawLock("short");
1835 fn.apply(this, args);
1836 if (lock) lock.release("cursor-start-of-line");
1837 };
1838 }
1839 const console = globalThis.console as Console & Record<string, () => void>;
1840 const restoreConsole: [string, old: () => void, patch: () => void][] = [];
1841 for (const [key, old] of Object.entries(console)) {
1842 if (typeof old !== "function") continue;
1843 try {
1844 const patched = (console[key] = patchSyncMethod(old));
1845 restoreConsole.push([key, old, patched]);
1846 } catch {
1847 /* skip */
1848 }
1849 }
1850
1851 return {
1852 // log output goes to real stdout so it stays redirectable; the
1853 // `outputSharesScreen` option tells the host whether those rows
1854 // land on the widget screen and must be counted by the cursor math
1855 writeOutput: (string) => stdoutWrite.call(stdout, string),
1856 writeInteractive: (string) => stderrWrite.call(stderr, string),
1857 observeSize(callback) {
1858 const emit = () =>
1859 callback({
1860 columns: stderr.columns ?? 80,
1861 rows: stderr.rows ?? 24,
1862 });
1863 emit();
1864 stderr.addListener("resize", emit);
1865 return () => void stderr.removeListener("resize", emit);
1866 },
1867 temporaryUnlock() {
1868 // no action needed: the patched write methods re-enter the draw
1869 // lock, which is re-entrant, so external writes flow correctly
1870 // while the lock is held.
1871 return () => {};
1872 },
1873 close() {
1874 disposed = true;
1875 // leave patches in place if something else tampered with it.
1876 if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite;
1877 if (stderr.write === newStderrWrite) stderr.write = stderrWrite;
1878 if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd;
1879 if (stderr.end === newStderrEnd) stderr.end = stderrEnd;
1880 for (const [key, old, patched] of restoreConsole) {
1881 if (console[key] === patched) console[key] = old;
1882 }
1883 },
1884 };
1885 },
1886 now: () => performance.now(),
1887 delay: async.delay,
1888 color: process.stderr.isTTY,
1889 outputSharesScreen: process.stdout.isTTY,
1890 observeInput: nodeProcessInput(process, (mode) => host.getDrawLock(mode)),
1891 });
1892 process.addListener("beforeExit", () => host.cancel());
1893 process.addListener("exit", () => host.cancel());
1894 return host;
1895}
1896
1897/** reads `process.stdin` in raw mode, if it is a terminal */
1898function nodeProcessInput(
1899 process: NonNullable<typeof node.process>,
1900 getDrawLock: WidgetHost["getDrawLock"],
1901): TerminalWidgetHostOptions["observeInput"] {
1902 // asking `process.stdin` would open the stream for every program
1903 if (!node.builtin("tty")?.isatty(0)) return undefined;
1904
1905 return (callback) => {
1906 const { stdin } = process;
1907 const decoder = new TextDecoder();
1908 const onData = (chunk: Uint8Array | string) =>
1909 callback(typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }));
1910
1911 // dying to a signal skips the exit handlers, which would leave the
1912 // terminal reading input. a long draw lock puts the terminal back, and
1913 // also removes these listeners, so that listeners running after them
1914 // (and the check below) see what the program itself installed.
1915 const signals = ["SIGINT", "SIGTERM"].map((signal) => ({
1916 signal,
1917 listener() {
1918 const lock = getDrawLock("long");
1919 if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
1920 async.delay(0).then(() => lock.release("cursor-start-of-line"));
1921 },
1922 }));
1923
1924 const wasRaw = stdin.isRaw;
1925 stdin.setRawMode(true);
1926 stdin.addListener("data", onData);
1927 // a listener alone does not restart a stream that was paused, which is
1928 // how the previous reader left it
1929 stdin.resume();
1930 for (const { signal, listener } of signals) process.prependListener(signal, listener);
1931
1932 return () => {
1933 for (const { signal, listener } of signals) process.removeListener(signal, listener);
1934 stdin.removeListener("data", onData);
1935 stdin.setRawMode(wasRaw);
1936 // a flowing stdin keeps the process alive
1937 if (stdin.listenerCount("data") === 0) stdin.pause();
1938 };
1939 };
1940}
1941
1942function defaultFallbackWidgetHost(): WidgetHost {
1943 let warned = false;
1944 return {
1945 writeOutput: (line: string) => console.log(ansi.strip(line)),
1946 writeError: (line: string) => console.error(ansi.strip(line)),
1947 getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }),
1948 startWidget: (options) => {
1949 if (!warned) {
1950 console.warn(
1951 '"@clo/lib/log"\'s startWidget was called in an environment ' +
1952 "that does not support the Node.js 'process' API. Widgets " +
1953 "will not be visible.",
1954 );
1955 warned = true;
1956 }
1957 return {
1958 options,
1959 fps: options.fps ?? null,
1960 redraw() {},
1961 stop() {},
1962 [Symbol.dispose]() {},
1963 };
1964 },
1965 cancel: () => {},
1966 capabilities: [],
1967 };
1968}
1969
1970/**
1971 * returns a WidgetHost from `node:process`, but without patching its methods.
1972 * as it leaves the process alone, it does not read the keyboard either.
1973 */
1974export function simpleNodeProcessWidgetHost(
1975 process: NonNullable<typeof node.process>,
1976 forceWidgetSupport = false,
1977): WidgetHost {
1978 return forceWidgetSupport || process.stderr.isTTY
1979 ? createTerminalWidgetHost({
1980 lockTerminal: () => ({
1981 writeOutput: (string) => process.stdout.write(string),
1982 writeInteractive: (string) => process.stderr.write(string),
1983 observeSize(callback) {
1984 const emit = () =>
1985 callback({
1986 columns: process.stderr.columns ?? 80,
1987 rows: process.stderr.rows ?? 24,
1988 });
1989 emit();
1990 process.stderr.addListener("resize", emit);
1991 return () => void process.stderr.removeListener("resize", emit);
1992 },
1993 close() {
1994 // no action needed
1995 },
1996 }),
1997 now: () => performance.now(),
1998 delay: async.delay,
1999 color: process.stderr.isTTY,
2000 outputSharesScreen: process.stdout.isTTY,
2001 })
2002 : {
2003 writeOutput: (string) => process.stdout.write(string),
2004 writeError: (string) => process.stderr.write(string),
2005 getDrawLock: () => ({
2006 release() {},
2007 [Symbol.dispose]() {},
2008 }),
2009 startWidget: () => null,
2010 cancel: () => {},
2011 capabilities: [],
2012 };
2013}
2014
2015function bufferEndsInNewline(buffer: ArrayBufferView | undefined) {
2016 return buffer
2017 ? buffer instanceof Uint8Array
2018 ? buffer.at(-1) === 0xa
2019 : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength).at(-1) === 0xa
2020 : false;
2021}
2022
2023const levelToAnsi: Record<MessageLevel, string> = {
2024 info: `${ansi.fgBlue}info`,
2025 warn: `${ansi.fgYellow}warn`,
2026 error: `${ansi.fgRed}error`,
2027 debug: `${ansi.dim}dbg`,
2028};
2029
2030const globalLog = /* @__PURE__ */ (() =>
2031 new ScopeImpl((m) => {
2032 if (global.writeMessage) {
2033 global.writeMessage(m);
2034 } else if (node.process) {
2035 // info/debug levels land on stdout, warnings and errors on stderr;
2036 // colors are keyed off the destination stream
2037 const err = (m.level ?? "info") !== "info" && m.level !== "debug";
2038 globalWidgetHost()[err ? "writeError" : "writeOutput"](
2039 formatAnsiMessage(m, (err ? node.process.stderr : node.process.stdout).isTTY),
2040 );
2041 } else {
2042 let { level = "info", [originalLogArgs]: args = [m.text], scope } = m;
2043 if (scope) {
2044 const arg0 = args[0];
2045 const prefix = `[${scope}]`;
2046 if (typeof arg0 === "string") args[0] = `${prefix} ${arg0}`;
2047 else args.unshift(prefix);
2048 }
2049 console[level](...args);
2050 }
2051 global.tees.forEach((cb) => cb(m));
2052 }))();
2053
2054export type DispatchFunction = (message: Message) => void;
2055/**
2056 * agnostic to the backend. should be able to format `newline: false`
2057 * messages without a trailing newline, but not all backends may
2058 * support this.
2059 *
2060 * TODO: change this to a `write`+`flush` pattern?
2061 */
2062export type MessageFormatFunction = (message: Message, colors: boolean) => string;
2063
2064import { ASSERT, UNWRAP } from "./assert.ts";
2065import * as async from "./async.ts";
2066import * as exceptions from "./exception.ts";
2067import * as stack from "./log/stack.ts";
2068import * as node from "./node.ts";
2069import * as ansi from "./string/ansi.ts";
2070import * as ts from "./ts.ts";