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