authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-12 14:42:28-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-12 18:10:53-07:00
log6f72c75c17126db8f199d81e8e58650a45ed131e
tree2653b4417a73b91e6a53854e9b5e734609935d77
parentcccf69cbca01bafa974afa80c256a86437141d47
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

fix: many clover log bugs

closes #94 Assisted-by: Claude:claude-fable-5

8 files changed, 943 insertions(+), 151 deletions(-)

lib/log.test.ts+390
......@@ -524,6 +524,394 @@ describe("log widgets", () => {
524524 host.cancel();
525525 });
526526
527 test("empty string keeps the widget alive", () => {
528 const host = new testing.MockScreen();
529 let text = "";
530 const w = UNWRAP(host.startWidget({ format: () => text }));
531 // an empty frame draws nothing, but the widget is not detached
532 host.expectFrame(0, { merged: "" });
533 text = "now!";
534 w.redraw();
535 host.expectFrame(0, {
536 stderr: testing.MockScreen.sync(["now!\n"]),
537 });
538 text = "";
539 w.redraw();
540 host.expectFrame(0, {
541 stderr: testing.MockScreen.sync([
542 ansi.cursorUp(1),
543 ansi.clearFullLine,
544 ]),
545 });
546 w.stop();
547 host.expectFrame(0, { merged: "" });
548 host.cancel();
549 });
550
551 test("leading blank line in widget output", () => {
552 const host = new testing.MockScreen();
553 using _ = host.startWidget({ format: () => "\nfoo" });
554 host.expectFrame(0, {
555 stderr: testing.MockScreen.sync([
556 "\n",
557 "foo\n",
558 ]),
559 });
560 host.cancel();
561 });
562
563 test("empty widget frame with a mid-line draw lock release", () => {
564 // regression: this combination used to crash an assertion, because an
565 // empty first widget line was treated as "no widgets on screen"
566 const host = new testing.MockScreen();
567 let text = "";
568 const w = UNWRAP(host.startWidget({ format: () => ({ text }) }));
569 host.expectFrame(0, { merged: "" });
570 const lock = host.getDrawLock("short");
571 lock.release("cursor-middle-of-line");
572 host.expectFrame(0, { merged: "" });
573 // the foreign partial line is respected once the widget renders
574 text = "hi";
575 w.redraw();
576 host.expectFrame(0, {
577 stderr: ansi.cursorSave + "\n" + testing.MockScreen.sync(["hi\n"]),
578 });
579 host.cancel();
580 });
581
582 test("temporary unlock is used when available", () => {
583 const host = new testing.MockScreen({ temporaryUnlocking: true });
584 using _ = host.startWidget({ format: () => "w" });
585 host.expectFrame(0, {
586 stderr: testing.MockScreen.sync(["w\n"]),
587 });
588 ASSERT(host.hasTerminalLock === "locked");
589 const lock = host.getDrawLock("short");
590 ASSERT(host.hasTerminalLock === "temporary-unlock");
591 host.expectFrame(null, {
592 stderr: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine,
593 });
594 lock.release("cursor-start-of-line");
595 ASSERT(host.hasTerminalLock === "locked");
596 host.expectFrame(0, {
597 stderr: "w\n" + ansi.syncEnd,
598 });
599 host.cancel();
600 });
601
602 test("cursor restore is not replayed for stale saves", () => {
603 const host = new testing.MockScreen();
604 using _ = host.startWidget({ format: () => "w" });
605 host.expectFrame(0, {
606 stderr: testing.MockScreen.sync(["w\n"]),
607 });
608
609 // a foreign partial line forces a cursor save
610 {
611 const lock = host.getDrawLock("short");
612 host.expectFrame(null, {
613 merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine,
614 });
615 lock.release("cursor-middle-of-line");
616 host.expectFrame(0, {
617 merged: ansi.cursorSave + "\n" + "w\n" + ansi.syncEnd,
618 });
619 }
620
621 // clearing the widget restores the saved cursor, exactly once
622 {
623 const lock = host.getDrawLock("short");
624 host.expectFrame(null, {
625 merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine
626 + ansi.cursorRestore,
627 });
628 lock.release("cursor-start-of-line");
629 host.expectFrame(0, {
630 merged: "w\n" + ansi.syncEnd,
631 });
632 }
633
634 // regression: the stale save register must not be restored again
635 {
636 const lock = host.getDrawLock("short");
637 host.expectFrame(null, {
638 merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine,
639 });
640 lock.release("cursor-start-of-line");
641 host.expectFrame(0, {
642 merged: "w\n" + ansi.syncEnd,
643 });
644 }
645 host.cancel();
646 });
647
648 test("log lines wider than the terminal", () => {
649 const host = new testing.MockScreen();
650 host.columns = 10;
651 using _ = host.startWidget({ format: () => "w1\nw2" });
652 host.expectFrame(0, {
653 stderr: testing.MockScreen.sync(["w1\nw2\n"]),
654 });
655 // a 15 cell line occupies two rows, so both widget rows get cleared
656 host.writeOutput("123456789012345\n");
657 host.expectFrame(0, {
658 merged: testing.MockScreen.sync([
659 ansi.cursorUp(1),
660 ansi.clearFullLine,
661 ansi.cursorUp(1),
662 ansi.clearFullLine,
663 "123456789012345\n",
664 "w1\n",
665 "w2\n",
666 ]),
667 });
668 host.cancel();
669 });
670
671 test("partial line wider than the terminal", () => {
672 const host = new testing.MockScreen();
673 host.columns = 10;
674 using _ = host.startWidget({ format: () => "w1\nw2" });
675 host.expectFrame(0, {
676 stderr: testing.MockScreen.sync(["w1\nw2\n"]),
677 });
678 // 14 cells: one wrapped row plus a partial row at column 4
679 host.writeOutput("12345678901234");
680 host.expectFrame(0, {
681 merged: testing.MockScreen.sync([
682 ansi.cursorUp(1),
683 ansi.clearFullLine,
684 ansi.cursorUp(1),
685 ansi.clearFullLine,
686 "12345678901234",
687 "\n",
688 "w1\n",
689 "w2\n",
690 ]),
691 });
692 // the continuation point is column 4 of the partial row, not column 14
693 host.writeOutput(" more\n");
694 host.expectFrame(0, {
695 merged: testing.MockScreen.sync([
696 ansi.cursorUp(3) + ansi.cursorRight(4),
697 " more\n",
698 "w1\n",
699 "w2\n",
700 ]),
701 });
702 host.cancel();
703 });
704
705 test("partial line with ansi codes measures visible width", () => {
706 const host = new testing.MockScreen();
707 using _ = host.startWidget({ format: () => "w" });
708 host.expectFrame(0, {
709 stderr: testing.MockScreen.sync(["w\n"]),
710 });
711 host.writeOutput(ansi.fgBlue + "blue" + ansi.fgReset);
712 host.expectFrame(0, {
713 merged: testing.MockScreen.sync([
714 ansi.cursorUp(1),
715 ansi.clearFullLine,
716 ansi.fgBlue + "blue" + ansi.fgReset,
717 "\n",
718 "w\n",
719 ]),
720 });
721 // "blue" is 4 cells wide; the escape sequences take none
722 host.writeOutput(" sky\n");
723 host.expectFrame(0, {
724 merged: testing.MockScreen.sync([
725 ansi.cursorUp(2) + ansi.cursorRight(4),
726 " sky\n",
727 "w\n",
728 ]),
729 });
730 host.cancel();
731 });
732
733 test("off-screen output does not move widgets", () => {
734 // models stdout redirected to a file while stderr stays the terminal:
735 // log output flushes to stdout, but the widget block is untouched by it
736 const host = new testing.MockScreen({ outputSharesScreen: false });
737 using _ = host.startWidget({ format: () => "w1\nw2" });
738 host.expectFrame(0, {
739 stderr: testing.MockScreen.sync(["w1\nw2\n"]),
740 });
741 host.writeOutput("a long log line that would have wrapped on screen\n");
742 host.expectFrame(0, {
743 stdout: "a long log line that would have wrapped on screen\n",
744 stderr: testing.MockScreen.sync([
745 ansi.cursorUp(2),
746 "w1\nw2\n",
747 ]),
748 });
749 // partial lines in the redirected stream do not shift the widgets either
750 host.writeOutput("partial");
751 host.expectFrame(0, {
752 stdout: "partial",
753 stderr: testing.MockScreen.sync([
754 ansi.cursorUp(2),
755 "w1\nw2\n",
756 ]),
757 });
758 host.cancel();
759 });
760
761 test("widgets start below a partial line", () => {
762 const host = new testing.MockScreen();
763 let text = "";
764 const w = UNWRAP(host.startWidget({ format: () => text }));
765 host.expectFrame(0, { merged: "" });
766 host.writeOutput("part");
767 host.expectFrame(0, { stdout: "part", merged: "part" });
768 text = "W";
769 w.redraw();
770 host.expectFrame(0, {
771 stderr: testing.MockScreen.sync(["\n", "W\n"]),
772 });
773 // the partial line can still be continued afterwards
774 host.writeOutput(" two\n");
775 host.expectFrame(0, {
776 merged: testing.MockScreen.sync([
777 ansi.cursorUp(2) + ansi.cursorRight(4),
778 " two\n",
779 "W\n",
780 ]),
781 });
782 host.cancel();
783 });
784
785 test("resize invalidates the widget area", () => {
786 const host = new testing.MockScreen();
787 using _ = host.startWidget({ format: () => "w1\nw2" });
788 host.expectFrame(0, {
789 stderr: testing.MockScreen.sync(["w1\nw2\n"]),
790 });
791 host.resize(40);
792 host.expectFrame(0, {
793 merged: ansi.cursorUp(2) + "\r" + ansi.clearToEndOfScreen
794 + testing.MockScreen.sync(["w1\nw2\n"]),
795 });
796 host.cancel();
797 });
798
799 test("widget redraw during another widget's render", () => {
800 const host = new testing.MockScreen();
801 let kicked = false;
802 const b = UNWRAP(host.startWidget({ format: () => "b" }));
803 using _ = host.startWidget({
804 format: () => {
805 if (!kicked) {
806 kicked = true;
807 b.redraw();
808 }
809 return "a";
810 },
811 });
812 host.expectFrame(0, {
813 stderr: testing.MockScreen.sync(["b\na\n"]),
814 });
815 // the request from inside the render pass produces a follow-up frame
816 host.expectFrame(0, {
817 stderr: testing.MockScreen.sync([
818 ansi.cursorUp(2),
819 "b\na\n",
820 ]),
821 });
822 host.expectNone();
823 host.cancel();
824 });
825
826 test("widget stopped during another widget's render", () => {
827 const host = new testing.MockScreen();
828 let b: log.WidgetInstance | null = null;
829 using _ = host.startWidget({
830 format: () => {
831 b?.stop();
832 b = null;
833 return "a";
834 },
835 });
836 b = host.startWidget({ format: () => "b" });
837 host.expectFrame(0, {
838 stderr: testing.MockScreen.sync(["a\n"]),
839 });
840 // the stop request also schedules a follow-up frame; it draws no change
841 host.expectFrame(0, {
842 stderr: testing.MockScreen.sync([
843 ansi.cursorUp(1),
844 "a\n",
845 ]),
846 });
847 host.expectNone();
848 host.cancel();
849 });
850
851 test("fps setter schedules frames", () => {
852 const host = new testing.MockScreen();
853 const w = UNWRAP(host.startWidget({ format: ({ now }) => `t=${now}` }));
854 host.expectFrame(0, {
855 stderr: testing.MockScreen.sync(["t=0\n"]),
856 });
857 host.expectNone();
858 w.fps = 1;
859 host.expectFrame(0, {
860 stderr: testing.MockScreen.sync([
861 ansi.cursorUp(1),
862 "t=0\n",
863 ]),
864 });
865 host.expectWithoutConsume(1000);
866 w.fps = null;
867 host.expectFrame(0, {
868 stderr: testing.MockScreen.sync([
869 ansi.cursorUp(1),
870 "t=0\n",
871 ]),
872 });
873 host.expectNone();
874 w.stop();
875 host.expectFrame(0, {
876 stderr: testing.MockScreen.sync([
877 ansi.cursorUp(1),
878 ansi.clearFullLine,
879 ]),
880 });
881 host.cancel();
882 });
883
884 test("node host patches and restores std streams", async () => {
885 const proc = UNWRAP(node.process);
886 const origOut = proc.stdout.write;
887 const origErr = proc.stderr.write;
888 let stdoutData = "";
889 let stderrData = "";
890 proc.stdout.write = (chunk) => (stdoutData += chunk, true);
891 proc.stderr.write = (chunk) => (stderrData += chunk, true);
892 const myOut = proc.stdout.write;
893 const myErr = proc.stderr.write;
894 try {
895 const host = log.defaultNodeProcessWidgetHost(proc, true);
896 for (let i = 0; i < 3; i += 1) {
897 host.writeOutput(`line ${i}\n`);
898 await new Promise((resolve) => setTimeout(resolve, 2));
899 }
900 host.cancel();
901 // regression: the unpatch used to clobber stdout with stderr's methods
902 // and leave a permanently growing wrapper chain on stderr
903 ASSERT(proc.stdout.write === myOut, "stdout.write was not restored");
904 ASSERT(proc.stderr.write === myErr, "stderr.write was not restored");
905 // log output lands on real stdout, so it stays redirectable
906 ASSERT(stdoutData.includes("line 0\n"));
907 ASSERT(stdoutData.includes("line 2\n"));
908 ASSERT(stderrData === "");
909 } finally {
910 proc.stdout.write = origOut;
911 proc.stderr.write = origErr;
912 }
913 });
914
527915 test("writing during a render function is OK", () => {
528916 const host = new testing.MockScreen();
529917
......@@ -564,6 +952,8 @@ describe("log widgets", () => {
564952});
565953
566954import { describe, test } from "vitest";
955import { ASSERT, UNWRAP } from "./assert.ts";
567956import * as log from "./log.ts";
957import * as node from "./node.ts";
568958import * as ansi from "./string/ansi.ts";
569959import * as testing from "./testing.ts";
lib/log.ts+273-96
......@@ -374,6 +374,15 @@ export interface TerminalWidgetHostOptions {
374374 delay: typeof async.delay;
375375 /** is there color support? */
376376 color: boolean;
377 /**
378 * whether text passed to `writeOutput` lands on the same screen as the
379 * interactive output. pass `false` when output is redirected to a file or
380 * pipe while the interactive stream remains a terminal; the widget cursor
381 * math then ignores log output entirely, since written rows do not
382 * displace the widget block.
383 * @default true
384 */
385 outputSharesScreen?: boolean;
377386}
378387
379388/**
......@@ -385,9 +394,17 @@ export interface TerminalLock {
385394 writeInteractive(text: string): void;
386395 /** recieves log content from `write` (pre-buffered; should flush immediately) */
387396 writeOutput(text: string): void;
388 /** called often. TODO: convert this into a subscription */
389 getSize(): { columns: number; rows: number };
390 // size(cb: (value: { width: number, height: number }) => void): () => void;
397 /**
398 * subscribe to the terminal size. the callback must fire synchronously
399 * with the current size before this function returns, and again whenever
400 * the size changes (SIGWINCH). returns an unsubscribe function. on a size
401 * change, the widget host erases its stale drawing and repaints, since a
402 * resize rewraps previously drawn rows and invalidates all relative
403 * cursor math.
404 */
405 observeSize(
406 callback: (size: { columns: number; rows: number }) => void,
407 ): () => void;
391408 /** temporarily free the lock */
392409 temporaryUnlock?(): () => void;
393410 /** completely free the lock */
......@@ -424,7 +441,6 @@ export interface WidgetHost {
424441/** @internal state */
425442interface WidgetState {
426443 frameTime: number;
427 next: number;
428444}
429445
430446/**
......@@ -435,15 +451,20 @@ export function createTerminalWidgetHost(
435451 env: TerminalWidgetHostOptions,
436452): WidgetHost {
437453 const { lockTerminal, now, delay, writeOutputTemporaryLock, color } = env;
454 const outputOnScreen = env.outputSharesScreen ?? true;
438455
439456 let timer: async.Cancelable<void> | null = null;
440457
441458 let rendering = false;
459 let pendingRedraw = false;
442460 let locks = 0;
443461 let redrawTime = 0;
444462 let lastFlush = 0;
445463 let buffer = "";
446 let partialLineIndex = 0;
464 // visible width of the trailing partial log line (text since the last "\n"
465 // written to output). the cursor column is derived as `partialWidth %
466 // columns` at draw time, so the value survives resizes and wrapped lines.
467 let partialWidth = 0;
447468 let needsToSaveCursor = false;
448469 let needsToRestoreCursor = false;
449470 const widgets: WidgetOptions[] = [];
......@@ -451,8 +472,53 @@ export function createTerminalWidgetHost(
451472 let lines: string[] = [];
452473 let hasSyncStart = false;
453474 let terminal: TerminalLock | null = null;
475 let size: { columns: number; rows: number } | null = null;
476 let unwatchSize: (() => void) | null = null;
454477 let tempUnlock: (() => void) | null = null;
455478
479 // the size subscription lives exactly as long as the terminal lock, so
480 // acquisition and closing are funneled through these two functions.
481 function acquireTerminal(): TerminalLock {
482 if (!terminal) {
483 let initial = true;
484 size = null;
485 terminal = lockTerminal();
486 unwatchSize = terminal.observeSize((next) => {
487 size = next;
488 if (!initial) handleResize();
489 });
490 ASSERT(
491 size,
492 "TerminalLock.observeSize must call back synchronously with the current size",
493 );
494 initial = false;
495 }
496 return terminal;
497 }
498 function closeTerminal() {
499 unwatchSize?.();
500 unwatchSize = null;
501 terminal?.close();
502 terminal = null;
503 }
504
505 function handleResize() {
506 // previously drawn rows have rewrapped to the new width, so the stored
507 // line count no longer matches the screen. erasing from the widget top
508 // downward is best-effort: on shrink, rows that wrapped above the cursor
509 // cannot be recovered.
510 if (lines.length > 0 && terminal) {
511 terminal.writeInteractive(
512 ansi.cursorUp(lines.length) + "\r" + ansi.clearToEndOfScreen,
513 );
514 lines = [];
515 }
516 // the save register holds pre-resize coordinates; restoring it would
517 // jump somewhere unrelated.
518 needsToRestoreCursor = false;
519 requestRedraw();
520 }
521
456522 function redrawCallback() {
457523 timer = null;
458524 ASSERT(!rendering);
......@@ -464,6 +530,16 @@ export function createTerminalWidgetHost(
464530 redrawCallbackInner();
465531 } finally {
466532 rendering = false;
533 if (pendingRedraw) {
534 pendingRedraw = false;
535 redrawSoon(0);
536 }
537 // cancel() during this render pass (an exit handler unwinding through
538 // a crashed format callback) skips its teardown; finish it here.
539 if (widgets.length === 0 && terminal && !timer && !buffer) {
540 lines = [];
541 closeTerminal();
542 }
467543 }
468544 }
469545
......@@ -477,33 +553,27 @@ export function createTerminalWidgetHost(
477553 if (!buffer) return;
478554 needsToRestoreCursor = false;
479555 needsToSaveCursor = false;
480 if (writeOutputTemporaryLock) {
556 if (!terminal && writeOutputTemporaryLock) {
481557 writeOutputTemporaryLock(buffer);
482 if (hasSyncStart) {
483 terminal ??= lockTerminal();
484 terminal.writeInteractive(ansi.syncEnd);
485 }
486558 } else {
487 terminal ??= lockTerminal();
488 buffer && terminal.writeOutput(buffer);
489 if (hasSyncStart) {
490 terminal ??= lockTerminal();
491 terminal.writeInteractive(ansi.syncEnd);
492 }
493 terminal.close();
494 terminal = null;
559 acquireTerminal().writeOutput(buffer);
495560 }
496 partialLineIndex = partialLineLength(buffer);
561 if (hasSyncStart) {
562 acquireTerminal().writeInteractive(ansi.syncEnd);
563 hasSyncStart = false;
564 }
565 closeTerminal();
566 trackPartialWidth(buffer);
497567 buffer = "";
498568 return;
499569 }
500570
501 terminal ??= lockTerminal();
502
503 const { columns, rows } = terminal.getSize();
571 const term = acquireTerminal();
572 const { columns, rows } = UNWRAP(size);
504573 let newWidgetLines: string[] = [];
505574 let next = Infinity;
506 for (let w = 0, { length } = widgets; w < length; w += 1) {
575 // `widgets.length` is read live: a format callback may stop another widget
576 for (let w = 0; w < widgets.length; w += 1) {
507577 const widget = UNWRAP(widgets[w]);
508578 let out: string | { text: string } | null;
509579 try {
......@@ -515,50 +585,68 @@ export function createTerminalWidgetHost(
515585 } catch (e) {
516586 out = e instanceof Error ? stack.format(e, color) : errors.message(e);
517587 }
518 if (!out) {
588 if (out == null) {
519589 widgets.splice(w, 1);
520590 UNWRAP(internals.splice(w, 1)[0]);
521591 w -= 1;
522 length -= 1;
523592 continue;
524593 }
594 next = Math.min(next, UNWRAP(internals[w]).frameTime);
525595 const outText = typeof out === "string" ? out : out.text;
596 // an empty string is a live widget that currently displays nothing;
597 // only `null` detaches.
598 if (outText === "") continue;
526599 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
527 if (rowsLeft === 1) break;
528 const lines = outText.split("\n").slice(0, rowsLeft);
600 if (rowsLeft === 1) continue;
529601 newWidgetLines.push(
530 ...lines.map((line) => ansi.trimForTerminal(line, columns - 1)),
602 ...outText.split("\n").slice(0, rowsLeft)
603 .map((line) => ansi.trimForTerminal(line, columns - 1)),
531604 );
532
533 next = Math.min(next, UNWRAP(internals[w]).frameTime);
534605 }
535606 newWidgetLines = newWidgetLines.slice(0, rows - 1);
536607 if (next < Infinity) redrawSoon(next);
537608
538 terminal ??= lockTerminal();
609 const pCol = partialWidth % columns;
539610
540 if (!newWidgetLines[0]) {
541 ASSERT(!needsToSaveCursor);
611 if (newWidgetLines.length === 0) {
542612 if (lines.length > 0) {
543 terminal.writeInteractive(
613 term.writeInteractive(
544614 (hasSyncStart ? "" : ansi.syncStart)
545615 // clear the widget space
546616 + (ansi.cursorUp(1) + ansi.clearFullLine)
547617 .repeat(lines.length)
548 + (partialLineIndex
549 ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex)
618 + (pCol
619 ? ansi.cursorUp(1) + ansi.cursorRight(pCol)
550620 : "")
551621 + (needsToRestoreCursor ? ansi.cursorRestore : ""),
552622 );
553623 hasSyncStart = true;
624 needsToRestoreCursor = false;
554625 lines = [];
555626 }
556 if (buffer) terminal.writeOutput(buffer);
557 buffer = "";
558 if (hasSyncStart) terminal.writeInteractive(ansi.syncEnd);
627 if (buffer) {
628 term.writeOutput(buffer);
629 trackPartialWidth(buffer);
630 buffer = "";
631 }
632 if (hasSyncStart) {
633 term.writeInteractive(ansi.syncEnd);
634 hasSyncStart = false;
635 }
636 // the last widget may have detached this frame; release the terminal
637 // (and its patches) instead of holding the lock until the next flush
638 if (widgets.length === 0) closeTerminal();
559639 return;
560640 }
561641
642 if (buffer && !outputOnScreen) {
643 // off-screen output (e.g. stdout redirected to a file) does not
644 // interact with the widget block; flush it plainly and fall through
645 // to a pure widget redraw
646 term.writeOutput(buffer);
647 buffer = "";
648 }
649
562650 if (buffer) {
563651 // when writing a buffer alongside widgets, the screen may look like this
564652 // > [existing log]
......@@ -568,21 +656,24 @@ export function createTerminalWidgetHost(
568656 // > [widget line 3]
569657 // > [cursor is start of this line]
570658 //
571 // first, clear out the space where new lines are going to intersect
572 const createsPartialLine = partialLineLength(buffer);
573 // if more lines are buffered than there are widgets, only some are needed
659 // first, clear out the space where new lines are going to intersect.
660 // `span.rows` measures the cursor descent in physical rows, so log
661 // lines wider than the terminal are accounted for correctly.
662 const span = measureTerminalSpan(buffer, pCol, columns);
663 // if more rows are buffered than there are widgets, only some are
664 // needed. when a partial line exists, the buffer starts on its row
665 // (one above the widget block), hence the -1.
574666 const clearLinesTop = Math.min(
575667 lines.length,
576 string.countNewlines(buffer)
577 + (createsPartialLine ? 1 : 0)
578 + (partialLineIndex ? -1 : 0), // "consumes partial line"
668 span.rows
669 + (span.endWidth % columns > 0 ? 1 : 0)
670 + (pCol ? -1 : 0),
579671 );
580672 const oldLines = lines.slice(clearLinesTop);
581 terminal.writeInteractive(
673 term.writeInteractive(
582674 (needsToSaveCursor ? ansi.cursorSave + "\n" : "")
583675 + (hasSyncStart ? "" : ansi.syncStart)
584 + (partialLineIndex && !lines[0] ? "\n" : "")
585 + ((clearLinesTop > 0 || (partialLineIndex && lines[0]))
676 + ((clearLinesTop > 0 || (pCol && lines.length > 0))
586677 // clear the lines for buffer
587678 ? (clearLinesTop > 0
588679 ? ansi.cursorUp(lines.length - clearLinesTop + 1)
......@@ -590,22 +681,23 @@ export function createTerminalWidgetHost(
590681 + (ansi.cursorUp(1) + ansi.clearFullLine)
591682 .repeat(clearLinesTop - 1)
592683 : "")
593 + (partialLineIndex && lines[0]
684 + (pCol && lines.length > 0
594685 ? ansi.cursorUp(
595686 clearLinesTop > 0 ? 1 : lines.length + 1,
596687 )
597 + ansi.cursorRight(partialLineIndex)
688 + ansi.cursorRight(pCol)
598689 : "")
599690 : ""),
600691 );
601 partialLineIndex = createsPartialLine;
602692 // then write output lines on standard out
603 terminal.writeOutput(buffer);
604 terminal.writeInteractive(
605 // if a partial line is created, then the widgets
606 // have to go on the next line, to avoid breaking stdout,
607 // the newline gets emitted on the interactive out.
608 (createsPartialLine ? "\n" : "")
693 term.writeOutput(buffer);
694 term.writeInteractive(
695 // if the buffer leaves a partial line, the widgets have to go on the
696 // next line. to avoid breaking stdout, the newline gets emitted on
697 // the interactive out. (a line ending exactly on the terminal edge
698 // leaves the cursor wrap-deferred; this newline lands on the next
699 // row, which is also where `span.rows` placed the continuation.)
700 (span.endWidth > 0 ? "\n" : "")
609701 // the widget text
610702 + newWidgetLines.map((newLine, i) =>
611703 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
......@@ -620,14 +712,20 @@ export function createTerminalWidgetHost(
620712 + "\n"
621713 ).join("") + ansi.syncEnd,
622714 );
715 partialWidth = span.endWidth;
623716 } else {
624717 const clearLinesBottom = Math.min(
625718 lines.length,
626 Math.max(0, lines.length - (newWidgetLines?.length ?? 0)),
719 Math.max(0, lines.length - newWidgetLines.length),
627720 );
628 terminal.writeInteractive(
721 term.writeInteractive(
629722 (needsToSaveCursor ? ansi.cursorSave + "\n" : "")
630723 + (hasSyncStart ? "" : ansi.syncStart)
724 // the first draw can land just after a partial log line; widgets
725 // must move below it. (`partialWidth` is zero whenever
726 // `needsToSaveCursor` is set, since releasing a draw lock resets
727 // it, so this never combines with the cursorSave newline.)
728 + (lines.length === 0 && partialWidth > 0 ? "\n" : "")
631729 // clear the bottom lines
632730 + (clearLinesBottom
633731 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine)
......@@ -658,6 +756,17 @@ export function createTerminalWidgetHost(
658756 buffer = "";
659757 }
660758
759 /**
760 * update `partialWidth` after writing `text` to the output. rows written
761 * off-screen never displace the widget block, so they are not tracked.
762 */
763 function trackPartialWidth(text: string) {
764 if (!outputOnScreen) return;
765 const i = text.lastIndexOf("\n");
766 partialWidth = ansi.widthInTerminal(text.slice(i + 1))
767 + (i === -1 ? partialWidth : 0);
768 }
769
661770 function redrawSoon(ms: number) {
662771 if (locks > 0 || (ms === 0 && rendering)) return;
663772 const newRedrawTime = now() + ms;
......@@ -671,35 +780,40 @@ export function createTerminalWidgetHost(
671780 timer.then(redrawCallback);
672781 }
673782
783 function requestRedraw() {
784 if (rendering) pendingRedraw = true;
785 else redrawSoon(0);
786 }
787
674788 function flushAndClear(shortTermDrawLock: boolean) {
675789 timer?.cancel();
676790 timer = null;
677791 if (lines.length > 0) {
678 UNWRAP(terminal).writeInteractive(
792 const term = UNWRAP(terminal);
793 const pCol = partialWidth % UNWRAP(size).columns;
794 term.writeInteractive(
679795 ansi.syncStart
680796 // clear the widget space
681797 + (ansi.cursorUp(1) + ansi.clearFullLine)
682798 .repeat(lines.length)
683 + (partialLineIndex
684 ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex)
799 + (pCol
800 ? ansi.cursorUp(1) + ansi.cursorRight(pCol)
685801 : "")
686802 + (shortTermDrawLock ? "" : ansi.syncEnd)
687803 + (needsToRestoreCursor ? ansi.cursorRestore : ""),
688804 );
805 needsToRestoreCursor = false;
689806 lines = [];
690807 hasSyncStart = shortTermDrawLock;
691808 }
692809 if (buffer.length > 0) {
693 if (widgets.length === 0 && writeOutputTemporaryLock) {
810 if (widgets.length === 0 && !terminal && writeOutputTemporaryLock) {
694811 writeOutputTemporaryLock(buffer);
695812 } else {
696 terminal ??= lockTerminal();
697 terminal.writeOutput(buffer);
698 if (widgets.length === 0) {
699 terminal.close();
700 terminal = null;
701 }
813 acquireTerminal().writeOutput(buffer);
814 if (widgets.length === 0) closeTerminal();
702815 }
816 trackPartialWidth(buffer);
703817 buffer = "";
704818 }
705819 }
......@@ -720,8 +834,7 @@ export function createTerminalWidgetHost(
720834 if (terminal.temporaryUnlock) {
721835 tempUnlock = terminal.temporaryUnlock();
722836 } else {
723 terminal.close();
724 terminal = null;
837 closeTerminal();
725838 }
726839 }
727840 }
......@@ -730,14 +843,19 @@ export function createTerminalWidgetHost(
730843 return {
731844 release(mode) {
732845 if (disposed) return;
846 disposed = true;
733847 locks -= 1;
734848 needsToSaveCursor ||= mode === "cursor-middle-of-line";
849 // whatever the lock holder wrote has detached the cursor from any
850 // previously tracked partial log line. "cursor-start-of-line"
851 // states the cursor is on a fresh line; "cursor-middle-of-line"
852 // engages the save/restore dance instead.
853 partialWidth = 0;
735854 if (locks === 0) {
736855 tempUnlock?.();
737856 tempUnlock = null;
738857 if (buffer.length > 0 || widgets.length > 0) redrawSoon(0);
739858 }
740 disposed = true;
741859 },
742860 [Symbol.dispose]() {
743861 this.release("cursor-middle-of-line");
......@@ -748,12 +866,11 @@ export function createTerminalWidgetHost(
748866 ASSERT(!widgets.includes(options), "Cannot start the same widget twice.");
749867 let fps = options.fps ?? null;
750868 const state: WidgetState = {
751 next: 0,
752869 frameTime: 1000 / (fps ?? 0),
753870 };
754871 widgets.push(options);
755872 internals.push(state);
756 redrawSoon(0);
873 requestRedraw();
757874 return {
758875 options,
759876 get fps() {
......@@ -762,17 +879,16 @@ export function createTerminalWidgetHost(
762879 set fps(value) {
763880 fps = value;
764881 state.frameTime = 1000 / (fps ?? 0);
882 // a pending frame may sit beyond the new cadence; draw to reanchor
883 requestRedraw();
765884 },
766 redraw() {
767 state.next = 0;
768 redrawSoon(0);
769 },
885 redraw: requestRedraw,
770886 stop() {
771887 const i = widgets.indexOf(options);
772888 if (i === -1) return;
773889 widgets.splice(i, 1);
774890 UNWRAP(internals.splice(i, 1)[0]);
775 redrawSoon(0);
891 requestRedraw();
776892 },
777893 [Symbol.dispose]() {
778894 this.stop();
......@@ -787,6 +903,9 @@ export function createTerminalWidgetHost(
787903 if (!rendering) flushAndClear(false);
788904 widgets.splice(0, widgets.length);
789905 internals.splice(0, internals.length);
906 timer?.cancel();
907 timer = null;
908 if (!rendering) closeTerminal();
790909 },
791910 delay,
792911 now,
......@@ -794,13 +913,35 @@ export function createTerminalWidgetHost(
794913 };
795914}
796915
797function partialLineLength(text: string) {
798 const idx = text.lastIndexOf("\n");
799 if (idx === -1) return text.length;
800 return text.length - idx - 1;
916/**
917 * compute the cursor descent (`rows`) and trailing line width (`endWidth`)
918 * from writing `text` to a terminal `columns` wide, with the cursor starting
919 * `startWidth` cells into a line. wrapping follows the DECAWM deferred-wrap
920 * convention shared by modern terminals: a line of exactly `columns` cells
921 * leaves the cursor pending on the same row, so a newline after it descends
922 * only one row. a trailing line ending exactly on the boundary reports its
923 * continuation point at column 0 of the next row (`endWidth % columns === 0`
924 * with the descended row included in `rows`).
925 */
926function measureTerminalSpan(
927 text: string,
928 startWidth: number,
929 columns: number,
930): { rows: number; endWidth: number } {
931 let rows = 0;
932 let width = startWidth;
933 const parts = text.split("\n");
934 for (let i = 0; i < parts.length - 1; i += 1) {
935 width += ansi.widthInTerminal(UNWRAP(parts[i]));
936 rows += Math.max(1, Math.ceil(width / columns));
937 width = 0;
938 }
939 width += ansi.widthInTerminal(UNWRAP(parts[parts.length - 1]));
940 rows += Math.floor(width / columns);
941 return { rows, endWidth: width };
801942}
802943
803const logColors = node.process?.stderr.isTTY ?? false;
944const logColors = node.process?.stdout.isTTY ?? false;
804945
805946let formatLine = /* @__PURE__ */ (() => {
806947 const fwo = node.builtin("util")?.formatWithOptions;
......@@ -944,7 +1085,7 @@ interface GlobalCommunication {
9441085}
9451086
9461087const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log");
947const version = 4;
1088const version = 5;
9481089let global: GlobalCommunication = /* @__PURE__ */ (() => {
9491090 const global = (globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= {
9501091 readme:
......@@ -1032,13 +1173,16 @@ export function defaultNodeProcessWidgetHost(
10321173 const stderrEnd = stderr.end;
10331174 function patchWriteMethod<T, R, A extends [string | Uint8Array]>(
10341175 fn: (this: T, ...args: A) => R,
1176 touchesScreen: boolean,
10351177 ) {
10361178 return function(this: T, ...args: A) {
10371179 using lock = disposed ? null : host.getDrawLock("short");
10381180 const ret = fn.apply(this, args);
10391181 if (lock) {
1182 // writes to a redirected stream never move the screen cursor
10401183 lock.release(
1041 (typeof args[0] === "string"
1184 !touchesScreen
1185 || (typeof args[0] === "string"
10421186 ? args[0].endsWith("\n")
10431187 : bufferEndsInNewline(args[0]))
10441188 ? "cursor-start-of-line"
......@@ -1048,8 +1192,14 @@ export function defaultNodeProcessWidgetHost(
10481192 return ret;
10491193 };
10501194 }
1051 const newStdoutWrite = stdout.write = patchWriteMethod(stdoutWrite);
1052 const newStderrWrite = stderr.write = patchWriteMethod(stderrWrite);
1195 const newStdoutWrite = stdout.write = patchWriteMethod(
1196 stdoutWrite,
1197 stdout.isTTY,
1198 );
1199 const newStderrWrite = stderr.write = patchWriteMethod(
1200 stderrWrite,
1201 true,
1202 );
10531203 function patchEndMethod<T, R, A extends unknown[]>(
10541204 fn: (this: T, ...args: A) => R,
10551205 ) {
......@@ -1090,19 +1240,34 @@ export function defaultNodeProcessWidgetHost(
10901240 }
10911241
10921242 return {
1093 writeOutput: (string) => stdoutWrite.call(stderr, string),
1243 // log output goes to real stdout so it stays redirectable; the
1244 // `outputSharesScreen` option tells the host whether those rows
1245 // land on the widget screen and must be counted by the cursor math
1246 writeOutput: (string) => stdoutWrite.call(stdout, string),
10941247 writeInteractive: (string) => stderrWrite.call(stderr, string),
1095 getSize: () => process.stderr,
1096 temporarilyUnlock() {
1097 // no action needed
1248 observeSize(callback) {
1249 const emit = () =>
1250 callback({
1251 columns: stderr.columns ?? 80,
1252 rows: stderr.rows ?? 24,
1253 });
1254 emit();
1255 stderr.addListener("resize", emit);
1256 return () => void stderr.removeListener("resize", emit);
1257 },
1258 temporaryUnlock() {
1259 // no action needed: the patched write methods re-enter the draw
1260 // lock, which is re-entrant, so external writes flow correctly
1261 // while the lock is held.
1262 return () => {};
10981263 },
10991264 close() {
11001265 disposed = true;
11011266 // leave patches in place if something else tampered with it.
11021267 if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite;
1103 if (stderr.write === newStderrWrite) stdout.write = stderrWrite;
1268 if (stderr.write === newStderrWrite) stderr.write = stderrWrite;
11041269 if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd;
1105 if (stderr.end === newStderrEnd) stdout.end = stderrEnd;
1270 if (stderr.end === newStderrEnd) stderr.end = stderrEnd;
11061271 for (const [key, old, patched] of restoreConsole) {
11071272 if (console[key] === patched) console[key] = old;
11081273 }
......@@ -1112,6 +1277,7 @@ export function defaultNodeProcessWidgetHost(
11121277 now: () => performance.now(),
11131278 delay: async.delay,
11141279 color: process.stderr.isTTY,
1280 outputSharesScreen: process.stdout.isTTY,
11151281 });
11161282 process.addListener("beforeExit", () => host.cancel());
11171283 process.addListener("exit", () => host.cancel());
......@@ -1156,7 +1322,16 @@ export function simpleNodeProcessWidgetHost(
11561322 lockTerminal: () => ({
11571323 writeOutput: (string) => process.stdout.write(string),
11581324 writeInteractive: (string) => process.stderr.write(string),
1159 getSize: () => process.stderr,
1325 observeSize(callback) {
1326 const emit = () =>
1327 callback({
1328 columns: process.stderr.columns ?? 80,
1329 rows: process.stderr.rows ?? 24,
1330 });
1331 emit();
1332 process.stderr.addListener("resize", emit);
1333 return () => void process.stderr.removeListener("resize", emit);
1334 },
11601335 close() {
11611336 // no action needed
11621337 },
......@@ -1164,6 +1339,7 @@ export function simpleNodeProcessWidgetHost(
11641339 now: () => performance.now(),
11651340 delay: async.delay,
11661341 color: process.stderr.isTTY,
1342 outputSharesScreen: process.stdout.isTTY,
11671343 })
11681344 : {
11691345 writeOutput: (string) => process.stdout.write(string),
......@@ -1201,6 +1377,8 @@ const globalLog = /* @__PURE__ */ (() =>
12011377 } else if (node.process) {
12021378 globalWidgetHost()[
12031379 (m.level ?? "info") === "info" ? "writeOutput" : "writeError"
1380 // colors keyed off stdout, the destination of host log output;
1381 // `logColors` (used for inspect formatting) matches.
12041382 ](formatAnsiMessage(m, node.process.stdout.isTTY));
12051383 } else {
12061384 let { level = "info", [originalLogArgs]: args = [m.text], scope } = m;
......@@ -1233,6 +1411,5 @@ import * as async from "./async.ts";
12331411import * as errors from "./error.ts";
12341412import * as stack from "./log/stack.ts";
12351413import * as node from "./node.ts";
1236import * as string from "./string.ts";
12371414import * as ansi from "./string/ansi.ts";
12381415import * as ts from "./ts.ts";
lib/node.ts+5-2
......@@ -30,9 +30,12 @@ interface Process {
3030
3131interface Tty {
3232 isTTY: boolean;
33 columns: number;
34 rows: number;
33 /** only present when the stream is a tty */
34 columns: number | undefined;
35 /** only present when the stream is a tty */
36 rows: number | undefined;
3537 addListener(event: string, callback: () => void): this;
38 removeListener(event: string, callback: () => void): this;
3639 end(text?: string | Uint8Array): this;
3740 write(text: string | Uint8Array): boolean;
3841}
lib/progress.test.ts+155-6
......@@ -1,5 +1,5 @@
11// a trivial example of how to use progress
2test.skip("trivial end-to-end example", () => {
2test("trivial end-to-end example", () => {
33 const { fgBlue: FB, fgReset: FR, reset: R } = ansi;
44 const screen = new testing.MockScreen(); // create a mock terminal
55 const root = new progress.Root(screen); // sync with mock timers
......@@ -13,18 +13,20 @@ test.skip("trivial end-to-end example", () => {
1313 assert.equal(screen.timers.entries.length, 1);
1414
1515 screen.expectFrame(1, { merged: "" }); // debounce
16 // TODO: why the full resets?
16 // widget lines containing escapes get a trailing ansi.reset from the
17 // renderer, so a malformed format function cannot bleed styles
1718 screen.expectFrame(0, {
1819 merged: testing.MockScreen.sync([
19 `\r${FB}⠋${FR} hello${R}\n`,
20 `${FB}⠋${FR} hello${R}\n`,
2021 `${FB}⠋${FR} cats${R}\n`,
2122 "└─ meow\n",
2223 ]),
2324 });
2425
26 // spinners animate from the clock, so frames tick at the spinner rate
2527 screen.expectFrame(80, {
2628 merged: testing.MockScreen.sync([
27 `\r` + ansi.cursorUp(3),
29 ansi.cursorUp(3),
2830 `${FB}⠙${FR} hello${R}\n`,
2931 `${FB}⠙${FR} cats${R}\n`,
3032 "└─ meow\n",
......@@ -34,13 +36,66 @@ test.skip("trivial end-to-end example", () => {
3436 a.end();
3537 b.end();
3638
37 screen.expectFrame(80, {
39 // ending fires the change debounce, which stops the widget and clears it
40 screen.expectFrame(1, { merged: "" });
41 screen.expectFrame(0, {
3842 merged: testing.MockScreen.sync([
39 `\r` + ansi.cursorUp(1) + ansi.clearFullLine,
4043 ansi.cursorUp(1) + ansi.clearFullLine,
4144 ansi.cursorUp(1) + ansi.clearFullLine,
45 ansi.cursorUp(1) + ansi.clearFullLine,
46 ]),
47 });
48 screen.expectNone();
49});
50
51test("bars without estimates do not animate", () => {
52 const screen = new testing.MockScreen();
53 const root = new progress.Root(screen);
54 progress.attachToScreen(root, screen);
55
56 const a = root.start("download", { total: 4, value: 1 });
57 screen.expectFrame(1, { merged: "" }); // debounce
58 screen.expectFrame(0, {}); // initial draw
59 // a bar is static between value changes, so no frame timer is pending
60 screen.expectNone();
61
62 a.inc();
63 screen.expectFrame(1, { merged: "" });
64 screen.expectFrame(0, {});
65 screen.expectNone();
66
67 a.end();
68 screen.expectFrame(1, { merged: "" });
69 screen.expectFrame(0, {});
70 screen.expectNone();
71});
72
73test("attachToScreen dispose detaches from the root", () => {
74 const screen = new testing.MockScreen();
75 const root = new progress.Root(screen);
76 const detach = progress.attachToScreen(root, screen);
77
78 const a = root.start("hello");
79 screen.expectFrame(1, { merged: "" });
80 screen.expectFrame(0, {
81 stderr: testing.MockScreen.sync([
82 `${ansi.style(ansi.fgBlue, "⠋")} hello${ansi.reset}\n`,
83 ]),
84 });
85
86 detach[Symbol.dispose]();
87 // the widget is stopped and removed from the screen on detach
88 screen.expectFrame(0, {
89 stderr: testing.MockScreen.sync([
90 ansi.cursorUp(1),
91 ansi.clearFullLine,
4292 ]),
4393 });
94
95 // further progress activity no longer renders
96 a.inc();
97 screen.expectFrame(1, { merged: "" }); // root's own debounce still runs
98 screen.expectNone();
4499});
45100
46101describe("encodeEventStream", (t) => {
......@@ -73,6 +128,99 @@ describe("encodeEventStream", (t) => {
73128 [1],
74129 ]);
75130 });
131
132 test("end closes the stream and decode resolves", async () => {
133 const root = new progress.Root<number>();
134 const a = root.start("task");
135 const handle = progress.decodeEventStream<number>(
136 progress.encodeEventStream(root),
137 progress.nullNode,
138 );
139 a.end();
140 root.end(42);
141 assert.equal(await handle, 42);
142 });
143
144 test("error rejects the decode promise", async () => {
145 const root = new progress.Root();
146 root.start("task");
147 const handle = progress.decodeEventStream(
148 progress.encodeEventStream(root),
149 progress.nullNode,
150 );
151 root.error(new Error("compute exploded"));
152 await assert.rejects(handle, /compute exploded/);
153 });
154
155 test("custom events pass through", async () => {
156 const root = new progress.Root<number, { meow: [string] }>();
157 root.start("task");
158 const handle = progress.decodeEventStream<number, { meow: [string] }>(
159 progress.encodeEventStream(root),
160 progress.nullNode,
161 );
162 const got: string[] = [];
163 handle.on("meow", (s) => void got.push(s));
164 root.emit("meow", "purr");
165 root.end(1);
166 assert.equal(await handle, 1);
167 assert.deepEqual(got, ["purr"]);
168 });
169
170 test("a stream that closes without end rejects", async () => {
171 const encoded = new ReadableStream<progress.StreamEvent>({
172 start(c) {
173 c.enqueue([-1]);
174 c.close();
175 },
176 });
177 const handle = progress.decodeEventStream(encoded, progress.nullNode);
178 await assert.rejects(handle, /Stream terminated early/);
179 });
180
181 test("events after a long idle gap are not throttled", async () => {
182 let t = 0;
183 const delays: number[] = [];
184 const root = new progress.Root({
185 now: () => t,
186 delay: (ms) => (delays.push(ms), async.delay(0)),
187 });
188 progress.encodeEventStream(root);
189 t = 1000;
190 // regression: the throttle used to wait `idle + throttleMs` instead of
191 // `throttleMs - idle`, so this would schedule a ~1033ms delay
192 root.start("x");
193 assert.ok(
194 delays.every((ms) => ms <= 1000 / 30 + 1),
195 `delays: ${delays.join(", ")}`,
196 );
197 });
198
199 test("byte stream round trip with end", async () => {
200 const root = new progress.Root<string>();
201 const a = root.start("task", { total: 3 });
202 const handle = progress.decodeByteStream<string>(
203 progress.encodeByteStream(root),
204 progress.nullNode,
205 );
206 a.inc();
207 a.end();
208 root.end("done");
209 assert.equal(await handle, "done");
210 });
211});
212
213test("child truncation ends with a newline", () => {
214 const root = new progress.Root();
215 const a = root.start("many");
216 for (let i = 0; i < 55; i += 1) a.start(`item ${i}`);
217 root.start("after");
218 const lines = ansi.strip(progress.formatAnsi(0, root.active)).split("\n");
219 const trunc = lines.indexOf("└─ [5 more]");
220 assert.notEqual(trunc, -1, "expected a truncation indicator");
221 // regression: the indicator had no trailing newline, so the next top-level
222 // node rendered on the same line
223 assert.equal(lines[trunc + 1], "⠋ after");
76224});
77225
78226// ## value formatters
......@@ -294,6 +442,7 @@ type EncodedKey = typeof progress.internals.EncodedKey;
294442import assert from "node:assert/strict";
295443import { describe, test, vi } from "vitest";
296444import { UNWRAP } from "./assert.ts";
445import * as async from "./async.ts";
297446import * as progress from "./progress.ts";
298447import * as stream from "./stream.ts";
299448import * as ansi from "./string/ansi.ts";
lib/progress.ts+75-19
......@@ -622,12 +622,9 @@ function endNode<R, M extends Events.Map>(owner: Root<R, M>, state: Internal) {
622622 state.children.forEach((child) => endNode(owner, child));
623623 state.detached = true;
624624 globalKeyPool.recycle(state.key);
625 let title = state.text;
626625 if (state.parent) {
627626 const i = state.parent.children.indexOf(state);
628627 ASSERT(i !== -1);
629 let p: Internal | null = state;
630 while (p = p.parent) title = p.text + " / " + title;
631628 state.parent.children.splice(i, 1);
632629 }
633630 owner.emit("node-end", state);
......@@ -732,7 +729,7 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {
732729 }
733730 if (truncated) {
734731 out += depth.map((x) => x ? box.line : " ").join("") + box.langle;
735 out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`);
732 out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`) + "\n";
736733 }
737734 return out;
738735}
......@@ -793,8 +790,11 @@ export function attachToScreen(
793790 format: ({ now }) => formatAnsi(now, root.active),
794791 }) ?? null;
795792 if (!widget) return;
796 widget.fps = items.some((x) => x.showTotal !== false && x.total > 0)
793 // scan for spinners and visible estimations
794 widget.fps = items.some((x) => !x.hidden && !(x.showTotal !== false && x.total > 0))
797795 ? spinnerFps
796 : hasEstimate(items)
797 ? 1
798798 : null;
799799 widget.redraw();
800800 } else {
......@@ -817,7 +817,16 @@ export function attachToScreen(
817817 }
818818 }));
819819
820 return ts.defer(() => stack[Symbol.dispose]);
820 stack.defer(() => {
821 widget?.stop();
822 widget = null;
823 });
824
825 return ts.defer(() => stack.dispose());
826}
827
828function hasEstimate(nodes: readonly ReadOnlyNode[]): boolean {
829 return nodes.some((n) => n.estimatedTime != null || hasEstimate(n.children));
821830}
822831
823832const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n");
......@@ -915,7 +924,7 @@ export function encodeEventStream<
915924 let timer: async.Cancelable<void> | null = null;
916925 function emitSoon() {
917926 if (timer || ready.value) return;
918 const remaining = now() - lastEvent + throttleMs;
927 const remaining = lastEvent + throttleMs - now();
919928 if (remaining > 0) {
920929 (timer = delay(remaining)).then(() => {
921930 timer = null;
......@@ -927,6 +936,13 @@ export function encodeEventStream<
927936 lastEvent = now();
928937 }
929938 }
939 /** terminal events skip the throttle so the stream ends promptly */
940 function emitNow() {
941 timer?.cancel();
942 timer = null;
943 ready.value = true;
944 lastEvent = now();
945 }
930946 stack.defer(() => {
931947 if (timer) timer.cancel();
932948 s.changed.clear();
......@@ -959,6 +975,22 @@ export function encodeEventStream<
959975 s.deleted.add(node); // handle add and remove in same frame
960976 emitSoon();
961977 }));
978 // custom events pass through the stream verbatim
979 stack.use(root.onAny((channel, args) => {
980 if (rootEvents.has(channel)) return;
981 s.pendingEvents.push([channel as string, ...args as ts.Json[]]);
982 emitSoon();
983 }));
984 stack.use(root.on("end", (result) => {
985 s.pendingEvents.push(["end", result]);
986 s.ended = true;
987 emitNow();
988 }));
989 stack.use(root.on("error", (error) => {
990 s.pendingEvents.push(["error", errors.message(error)]);
991 s.ended = true;
992 emitNow();
993 }));
962994 },
963995 async pull(controller) {
964996 await ready.until((x) => x === true);
......@@ -969,10 +1001,13 @@ export function encodeEventStream<
9691001 }
9701002 controller.enqueue(s.getDelta(isFirst));
9711003 isFirst = false;
1004 if (s.ended) {
1005 controller.close();
1006 stack.dispose();
1007 }
9721008 },
9731009 cancel() {
9741010 stack.dispose();
975 (0, performance.now)();
9761011 },
9771012 }, { highWaterMark: 0 });
9781013}
......@@ -1003,11 +1038,10 @@ export function decodeEventStream<
10031038 const { value, done } = await reader.read();
10041039 signal.throwIfAborted();
10051040 if (done) break;
1006 decoder.processEvent(value);
1041 if (decoder.processEvent(value)) hasEmittedEnd = true;
10071042 }
10081043 ASSERT(hasEmittedEnd, "Stream terminated early.");
10091044 } catch (err) {
1010 console.error(err);
10111045 reader.cancel(err);
10121046 if (!signal.aborted) reject(err);
10131047 } finally {
......@@ -1037,6 +1071,10 @@ class Encoder<
10371071 rootChildrenUpdated = true;
10381072 changed = new Map<ReadOnlyNode, Set<keyof ReadOnlyNode>>();
10391073 deleted = new Set<ReadOnlyNode>();
1074 /** custom and terminal ("end"/"error") events awaiting the next delta */
1075 pendingEvents: StreamCustomEvent[] = [];
1076 /** set when "end" or "error" was recorded; the stream closes after flushing */
1077 ended = false;
10401078
10411079 constructor(
10421080 root: Root<Result, EventMap>,
......@@ -1119,8 +1157,11 @@ class Encoder<
11191157 );
11201158 }
11211159
1160 payload.push(...this.pendingEvents);
1161
11221162 this.deleted.clear();
11231163 this.changed.clear();
1164 this.pendingEvents = [];
11241165 this.rootChildrenUpdated = false;
11251166
11261167 return payload;
......@@ -1182,7 +1223,6 @@ class Decoder<
11821223 events: Events<EventMap>;
11831224 resolve: (result: Result) => void;
11841225 active = new Map<EncodedKey, Node>();
1185 children = new Map<Node, Node[]>();
11861226 pendingStart = new Map<EncodedKey, PendingStart>();
11871227 pendingParents = new Map<EncodedKey, EncodedKey>();
11881228
......@@ -1201,7 +1241,8 @@ class Decoder<
12011241 this.active.clear();
12021242 }
12031243
1204 processEvent(event: StreamEvent) {
1244 /** returns whether the stream signalled "end"; throws on "error" */
1245 processEvent(event: StreamEvent): boolean {
12051246 event = event.slice();
12061247
12071248 // delete unreferenced nodes first, since they may get re-allocated
......@@ -1236,6 +1277,8 @@ class Decoder<
12361277 if (chunk[0] === "end") {
12371278 resolving = true;
12381279 resolvingValue = chunk[1];
1280 } else if (chunk[0] === "error") {
1281 throw new Error(`Progress stream error: ${chunk[1]}`);
12391282 } else {
12401283 // custom event
12411284 // @ts-expect-error TODO: typescript soundness
......@@ -1300,13 +1343,21 @@ class Decoder<
13001343 }
13011344 this.pendingParents.clear();
13021345
1346 // validate that every referenced child became active; a failure here
1347 // means the stream is desynced
13031348 for (const { key, children } of updateChildren) {
1304 const node = UNWRAP(this.active.get(key));
1305 this.children.set(
1306 node,
1307 children.map((id) => UNWRAP(this.active.get(id))),
1308 );
1349 UNWRAP(this.active.get(key), () => `parent ${key} not active`);
1350 for (const id of children) {
1351 UNWRAP(this.active.get(id), () => `child ${id} not active`);
1352 }
13091353 }
1354
1355 if (resolving) {
1356 this.reset();
1357 this.resolve(resolvingValue as Result);
1358 return true;
1359 }
1360 return false;
13101361 }
13111362
13121363 startRecursive(key: EncodedKey, opts: PendingStart): Ref {
......@@ -1526,10 +1577,14 @@ export function decodeByteStream<
15261577 async start(controller) {
15271578 reader = new stream.BufferedReader(encoded.getReader());
15281579 try {
1529 while (reader) controller.enqueue(await readStreamEvent(reader));
1580 // an event boundary with no further bytes is a clean end of stream
1581 while (reader && await reader.ensureAvailableOrFalse(1)) {
1582 controller.enqueue(await readStreamEvent(reader));
1583 }
1584 if (!cancelled) controller.close();
15301585 } catch (e) {
15311586 if (!cancelled) {
1532 reader.cancel(e);
1587 reader?.cancel(e);
15331588 throw e;
15341589 }
15351590 } finally {
......@@ -1802,6 +1857,7 @@ export const internals: {
18021857import { ASSERT, UNWRAP } from "./assert.ts";
18031858import * as async from "./async.ts";
18041859import * as bytes from "./bytes.ts";
1860import * as errors from "./error.ts";
18051861import { Events } from "./Events.ts";
18061862import * as log from "./log.ts";
18071863import * as stack from "./log/stack.ts";
lib/string.test.ts+6-4
......@@ -3,10 +3,12 @@ test("countNewlines", () => {
33 assert.equal(string.countNewlines(""), 0);
44});
55test("escapeRegExp", () => {
6 assert.equal(
7 string.escapeRegExp("delete * from users where id = $id"),
8 "delete \\* from users where id = \\$id",
9 );
6 // the exact escape encoding differs between the native `RegExp.escape`
7 // (hex escapes) and the fallback (backslashes); assert the behavior
8 const input = "delete * from users where id = $id";
9 const re = new RegExp(`^${string.escapeRegExp(input)}$`);
10 assert.ok(re.test(input));
11 assert.ok(!re.test("delete x from users where id = yid"));
1012});
1113test("escapeShellArgument", () => {
1214 // escapeShellArgument leaves it alone when the input is trivial
lib/string/ansi.ts+5-7
......@@ -164,6 +164,8 @@ export const clearToEndOfLine: string = "\x1b[K";
164164export const clearToStartOfLine: string = "\x1b[1K";
165165/** clear the entire line that the cursor is on without moving it. */
166166export const clearFullLine: string = "\x1b[2K";
167/** clear from and including the current cell to the end of the screen. */
168export const clearToEndOfScreen: string = "\x1b[0J";
167169
168170/**
169171 * Begin Synchronized Output
......@@ -257,6 +259,7 @@ export function debugAnsi(text: string): string {
257259 "clearToEndOfLine",
258260 "clearToStartOfLine",
259261 "clearFullLine",
262 "clearToEndOfScreen",
260263 "syncStart",
261264 "syncEnd",
262265 "bold",
......@@ -292,14 +295,9 @@ export function widthInTerminal(str: string): number {
292295 width += 1;
293296 continue;
294297 }
298 // the remainder of the string is measured with the segmenter, which
299 // handles ascii correctly via segmentWidth (controls are zero-width).
295300 for (const { segment } of segmenter.segment(str.slice(i))) {
296 if (segment.length === 1) {
297 if (code < 32) continue;
298 if (code < 127) {
299 width += 1;
300 continue;
301 }
302 }
303301 width += segmentWidth(segment);
304302 }
305303 return width;
lib/testing.ts+34-17
......@@ -114,7 +114,7 @@ export class SyncPromise<T> implements Promise<T> {
114114export class FakeTimers {
115115 time = 0;
116116 entries: Array<{
117 duration: number;
117 deadline: number;
118118 resolve: () => void;
119119 src: stack.Frame[];
120120 }> = [];
......@@ -124,21 +124,23 @@ export class FakeTimers {
124124 };
125125 delay: typeof async.delay = (ms) => {
126126 const src = stack.capture(2);
127 let entry: (typeof this.entries)[number];
127128 return async.makeCancelable(
128129 new SyncPromise((resolve) => {
129 ASSERT(this.entries.length === 0);
130 this.entries.push({
131 duration: ms,
132 resolve,
133 src,
134 });
130 entry = { deadline: this.time + ms, resolve, src };
131 this.entries.push(entry);
135132 }),
136133 () => {
137 ASSERT(this.entries.length === 1);
138 this.entries.length = 0;
134 const i = this.entries.indexOf(entry);
135 if (i !== -1) this.entries.splice(i, 1);
139136 },
140137 );
141138 };
139
140 /** the pending timer that fires soonest */
141 next(): (typeof this.entries)[number] | undefined {
142 return this.entries.toSorted((a, b) => a.deadline - b.deadline)[0];
143 }
142144}
143145
144146export function removeUndefinedKeys(obj: unknown): unknown {
......@@ -176,12 +178,16 @@ export class MockScreen implements Disposable, log.WidgetHost {
176178 now: log.WidgetHost["now"];
177179
178180 hasTerminalLock: null | "locked" | "temporary-unlock" = null;
181 #sizeObservers = new Set<(size: { columns: number; rows: number }) => void>();
179182
180183 static sync(text: string[]): string {
181184 return ansi.syncStart + text.join("") + ansi.syncEnd;
182185 }
183186
184 constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) {
187 constructor({ temporaryUnlocking, outputSharesScreen }: {
188 temporaryUnlocking?: boolean;
189 outputSharesScreen?: boolean;
190 } = {}) {
185191 const callerFile = UNWRAP(stack.capture()[0]);
186192 const host = log.createTerminalWidgetHost({
187193 lockTerminal: () => {
......@@ -210,8 +216,10 @@ export class MockScreen implements Disposable, log.WidgetHost {
210216 content,
211217 });
212218 },
213 getSize: () => {
214 return this;
219 observeSize: (callback) => {
220 callback({ columns: this.columns, rows: this.rows });
221 this.#sizeObservers.add(callback);
222 return () => void this.#sizeObservers.delete(callback);
215223 },
216224 temporaryUnlock: temporaryUnlocking
217225 ? () => {
......@@ -231,6 +239,7 @@ export class MockScreen implements Disposable, log.WidgetHost {
231239 now: this.timers.now,
232240 delay: this.timers.delay,
233241 color: true,
242 outputSharesScreen,
234243 });
235244 this.writeOutput = host.writeOutput;
236245 this.writeError = host.writeError;
......@@ -240,18 +249,26 @@ export class MockScreen implements Disposable, log.WidgetHost {
240249 this.now = host.now;
241250 }
242251
252 /** simulate a terminal resize (SIGWINCH) */
253 resize(columns: number, rows: number = this.rows) {
254 this.columns = columns;
255 this.rows = rows;
256 this.#sizeObservers.forEach((cb) => cb({ columns, rows }));
257 }
258
243259 expectNone() {
244260 ASSERT(this.timers.entries.length === 0);
245261 }
246262
247263 expectWithoutConsume(ms: number) {
248264 const wait = UNWRAP(
249 this.timers.entries[0],
265 this.timers.next(),
250266 () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o",
251267 );
268 const duration = wait.deadline - this.timers.time;
252269 ASSERT(
253 ms === wait.duration,
254 `expected ${ms}ms to pass, got ${wait.duration}, from:\n${
270 ms === duration,
271 `expected ${ms}ms to pass, got ${duration}, from:\n${
255272 wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n")
256273 }`,
257274 );
......@@ -265,8 +282,8 @@ export class MockScreen implements Disposable, log.WidgetHost {
265282 }) {
266283 if (ms != null) {
267284 const wait = this.expectWithoutConsume(ms);
268 this.timers.entries.shift();
269 this.timers.time += wait.duration;
285 this.timers.entries.splice(this.timers.entries.indexOf(wait), 1);
286 this.timers.time = wait.deadline;
270287 wait.resolve();
271288 } else {
272289 ASSERT(this.timers.entries.length === 0);