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", () => {...@@ -524,6 +524,394 @@ describe("log widgets", () => {
524 host.cancel();524 host.cancel();
525 });525 });
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
527 test("writing during a render function is OK", () => {915 test("writing during a render function is OK", () => {
528 const host = new testing.MockScreen();916 const host = new testing.MockScreen();
529917
...@@ -564,6 +952,8 @@ describe("log widgets", () => {...@@ -564,6 +952,8 @@ describe("log widgets", () => {
564});952});
565953
566import { describe, test } from "vitest";954import { describe, test } from "vitest";
955import { ASSERT, UNWRAP } from "./assert.ts";
567import * as log from "./log.ts";956import * as log from "./log.ts";
957import * as node from "./node.ts";
568import * as ansi from "./string/ansi.ts";958import * as ansi from "./string/ansi.ts";
569import * as testing from "./testing.ts";959import * as testing from "./testing.ts";
lib/log.ts+273-96
...@@ -374,6 +374,15 @@ export interface TerminalWidgetHostOptions {...@@ -374,6 +374,15 @@ export interface TerminalWidgetHostOptions {
374 delay: typeof async.delay;374 delay: typeof async.delay;
375 /** is there color support? */375 /** is there color support? */
376 color: boolean;376 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;
377}386}
378387
379/**388/**
...@@ -385,9 +394,17 @@ export interface TerminalLock {...@@ -385,9 +394,17 @@ export interface TerminalLock {
385 writeInteractive(text: string): void;394 writeInteractive(text: string): void;
386 /** recieves log content from `write` (pre-buffered; should flush immediately) */395 /** recieves log content from `write` (pre-buffered; should flush immediately) */
387 writeOutput(text: string): void;396 writeOutput(text: string): void;
388 /** called often. TODO: convert this into a subscription */397 /**
389 getSize(): { columns: number; rows: number };398 * subscribe to the terminal size. the callback must fire synchronously
390 // size(cb: (value: { width: number, height: number }) => void): () => void;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;
391 /** temporarily free the lock */408 /** temporarily free the lock */
392 temporaryUnlock?(): () => void;409 temporaryUnlock?(): () => void;
393 /** completely free the lock */410 /** completely free the lock */
...@@ -424,7 +441,6 @@ export interface WidgetHost {...@@ -424,7 +441,6 @@ export interface WidgetHost {
424/** @internal state */441/** @internal state */
425interface WidgetState {442interface WidgetState {
426 frameTime: number;443 frameTime: number;
427 next: number;
428}444}
429445
430/**446/**
...@@ -435,15 +451,20 @@ export function createTerminalWidgetHost(...@@ -435,15 +451,20 @@ export function createTerminalWidgetHost(
435 env: TerminalWidgetHostOptions,451 env: TerminalWidgetHostOptions,
436): WidgetHost {452): WidgetHost {
437 const { lockTerminal, now, delay, writeOutputTemporaryLock, color } = env;453 const { lockTerminal, now, delay, writeOutputTemporaryLock, color } = env;
454 const outputOnScreen = env.outputSharesScreen ?? true;
438455
439 let timer: async.Cancelable<void> | null = null;456 let timer: async.Cancelable<void> | null = null;
440457
441 let rendering = false;458 let rendering = false;
459 let pendingRedraw = false;
442 let locks = 0;460 let locks = 0;
443 let redrawTime = 0;461 let redrawTime = 0;
444 let lastFlush = 0;462 let lastFlush = 0;
445 let buffer = "";463 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;
447 let needsToSaveCursor = false;468 let needsToSaveCursor = false;
448 let needsToRestoreCursor = false;469 let needsToRestoreCursor = false;
449 const widgets: WidgetOptions[] = [];470 const widgets: WidgetOptions[] = [];
...@@ -451,8 +472,53 @@ export function createTerminalWidgetHost(...@@ -451,8 +472,53 @@ export function createTerminalWidgetHost(
451 let lines: string[] = [];472 let lines: string[] = [];
452 let hasSyncStart = false;473 let hasSyncStart = false;
453 let terminal: TerminalLock | null = null;474 let terminal: TerminalLock | null = null;
475 let size: { columns: number; rows: number } | null = null;
476 let unwatchSize: (() => void) | null = null;
454 let tempUnlock: (() => void) | null = null;477 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
456 function redrawCallback() {522 function redrawCallback() {
457 timer = null;523 timer = null;
458 ASSERT(!rendering);524 ASSERT(!rendering);
...@@ -464,6 +530,16 @@ export function createTerminalWidgetHost(...@@ -464,6 +530,16 @@ export function createTerminalWidgetHost(
464 redrawCallbackInner();530 redrawCallbackInner();
465 } finally {531 } finally {
466 rendering = false;532 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 }
467 }543 }
468 }544 }
469545
...@@ -477,33 +553,27 @@ export function createTerminalWidgetHost(...@@ -477,33 +553,27 @@ export function createTerminalWidgetHost(
477 if (!buffer) return;553 if (!buffer) return;
478 needsToRestoreCursor = false;554 needsToRestoreCursor = false;
479 needsToSaveCursor = false;555 needsToSaveCursor = false;
480 if (writeOutputTemporaryLock) {556 if (!terminal && writeOutputTemporaryLock) {
481 writeOutputTemporaryLock(buffer);557 writeOutputTemporaryLock(buffer);
482 if (hasSyncStart) {
483 terminal ??= lockTerminal();
484 terminal.writeInteractive(ansi.syncEnd);
485 }
486 } else {558 } else {
487 terminal ??= lockTerminal();559 acquireTerminal().writeOutput(buffer);
488 buffer && terminal.writeOutput(buffer);
489 if (hasSyncStart) {
490 terminal ??= lockTerminal();
491 terminal.writeInteractive(ansi.syncEnd);
492 }
493 terminal.close();
494 terminal = null;
495 }560 }
496 partialLineIndex = partialLineLength(buffer);561 if (hasSyncStart) {
562 acquireTerminal().writeInteractive(ansi.syncEnd);
563 hasSyncStart = false;
564 }
565 closeTerminal();
566 trackPartialWidth(buffer);
497 buffer = "";567 buffer = "";
498 return;568 return;
499 }569 }
500570
501 terminal ??= lockTerminal();571 const term = acquireTerminal();
502572 const { columns, rows } = UNWRAP(size);
503 const { columns, rows } = terminal.getSize();
504 let newWidgetLines: string[] = [];573 let newWidgetLines: string[] = [];
505 let next = Infinity;574 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) {
507 const widget = UNWRAP(widgets[w]);577 const widget = UNWRAP(widgets[w]);
508 let out: string | { text: string } | null;578 let out: string | { text: string } | null;
509 try {579 try {
...@@ -515,50 +585,68 @@ export function createTerminalWidgetHost(...@@ -515,50 +585,68 @@ export function createTerminalWidgetHost(
515 } catch (e) {585 } catch (e) {
516 out = e instanceof Error ? stack.format(e, color) : errors.message(e);586 out = e instanceof Error ? stack.format(e, color) : errors.message(e);
517 }587 }
518 if (!out) {588 if (out == null) {
519 widgets.splice(w, 1);589 widgets.splice(w, 1);
520 UNWRAP(internals.splice(w, 1)[0]);590 UNWRAP(internals.splice(w, 1)[0]);
521 w -= 1;591 w -= 1;
522 length -= 1;
523 continue;592 continue;
524 }593 }
594 next = Math.min(next, UNWRAP(internals[w]).frameTime);
525 const outText = typeof out === "string" ? out : out.text;595 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;
526 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);599 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
527 if (rowsLeft === 1) break;600 if (rowsLeft === 1) continue;
528 const lines = outText.split("\n").slice(0, rowsLeft);
529 newWidgetLines.push(601 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)),
531 );604 );
532
533 next = Math.min(next, UNWRAP(internals[w]).frameTime);
534 }605 }
535 newWidgetLines = newWidgetLines.slice(0, rows - 1);606 newWidgetLines = newWidgetLines.slice(0, rows - 1);
536 if (next < Infinity) redrawSoon(next);607 if (next < Infinity) redrawSoon(next);
537608
538 terminal ??= lockTerminal();609 const pCol = partialWidth % columns;
539610
540 if (!newWidgetLines[0]) {611 if (newWidgetLines.length === 0) {
541 ASSERT(!needsToSaveCursor);
542 if (lines.length > 0) {612 if (lines.length > 0) {
543 terminal.writeInteractive(613 term.writeInteractive(
544 (hasSyncStart ? "" : ansi.syncStart)614 (hasSyncStart ? "" : ansi.syncStart)
545 // clear the widget space615 // clear the widget space
546 + (ansi.cursorUp(1) + ansi.clearFullLine)616 + (ansi.cursorUp(1) + ansi.clearFullLine)
547 .repeat(lines.length)617 .repeat(lines.length)
548 + (partialLineIndex618 + (pCol
549 ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex)619 ? ansi.cursorUp(1) + ansi.cursorRight(pCol)
550 : "")620 : "")
551 + (needsToRestoreCursor ? ansi.cursorRestore : ""),621 + (needsToRestoreCursor ? ansi.cursorRestore : ""),
552 );622 );
553 hasSyncStart = true;623 hasSyncStart = true;
624 needsToRestoreCursor = false;
554 lines = [];625 lines = [];
555 }626 }
556 if (buffer) terminal.writeOutput(buffer);627 if (buffer) {
557 buffer = "";628 term.writeOutput(buffer);
558 if (hasSyncStart) terminal.writeInteractive(ansi.syncEnd);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();
559 return;639 return;
560 }640 }
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
562 if (buffer) {650 if (buffer) {
563 // when writing a buffer alongside widgets, the screen may look like this651 // when writing a buffer alongside widgets, the screen may look like this
564 // > [existing log]652 // > [existing log]
...@@ -568,21 +656,24 @@ export function createTerminalWidgetHost(...@@ -568,21 +656,24 @@ export function createTerminalWidgetHost(
568 // > [widget line 3]656 // > [widget line 3]
569 // > [cursor is start of this line]657 // > [cursor is start of this line]
570 //658 //
571 // first, clear out the space where new lines are going to intersect659 // first, clear out the space where new lines are going to intersect.
572 const createsPartialLine = partialLineLength(buffer);660 // `span.rows` measures the cursor descent in physical rows, so log
573 // if more lines are buffered than there are widgets, only some are needed661 // 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.
574 const clearLinesTop = Math.min(666 const clearLinesTop = Math.min(
575 lines.length,667 lines.length,
576 string.countNewlines(buffer)668 span.rows
577 + (createsPartialLine ? 1 : 0)669 + (span.endWidth % columns > 0 ? 1 : 0)
578 + (partialLineIndex ? -1 : 0), // "consumes partial line"670 + (pCol ? -1 : 0),
579 );671 );
580 const oldLines = lines.slice(clearLinesTop);672 const oldLines = lines.slice(clearLinesTop);
581 terminal.writeInteractive(673 term.writeInteractive(
582 (needsToSaveCursor ? ansi.cursorSave + "\n" : "")674 (needsToSaveCursor ? ansi.cursorSave + "\n" : "")
583 + (hasSyncStart ? "" : ansi.syncStart)675 + (hasSyncStart ? "" : ansi.syncStart)
584 + (partialLineIndex && !lines[0] ? "\n" : "")676 + ((clearLinesTop > 0 || (pCol && lines.length > 0))
585 + ((clearLinesTop > 0 || (partialLineIndex && lines[0]))
586 // clear the lines for buffer677 // clear the lines for buffer
587 ? (clearLinesTop > 0678 ? (clearLinesTop > 0
588 ? ansi.cursorUp(lines.length - clearLinesTop + 1)679 ? ansi.cursorUp(lines.length - clearLinesTop + 1)
...@@ -590,22 +681,23 @@ export function createTerminalWidgetHost(...@@ -590,22 +681,23 @@ export function createTerminalWidgetHost(
590 + (ansi.cursorUp(1) + ansi.clearFullLine)681 + (ansi.cursorUp(1) + ansi.clearFullLine)
591 .repeat(clearLinesTop - 1)682 .repeat(clearLinesTop - 1)
592 : "")683 : "")
593 + (partialLineIndex && lines[0]684 + (pCol && lines.length > 0
594 ? ansi.cursorUp(685 ? ansi.cursorUp(
595 clearLinesTop > 0 ? 1 : lines.length + 1,686 clearLinesTop > 0 ? 1 : lines.length + 1,
596 )687 )
597 + ansi.cursorRight(partialLineIndex)688 + ansi.cursorRight(pCol)
598 : "")689 : "")
599 : ""),690 : ""),
600 );691 );
601 partialLineIndex = createsPartialLine;
602 // then write output lines on standard out692 // then write output lines on standard out
603 terminal.writeOutput(buffer);693 term.writeOutput(buffer);
604 terminal.writeInteractive(694 term.writeInteractive(
605 // if a partial line is created, then the widgets695 // if the buffer leaves a partial line, the widgets have to go on the
606 // have to go on the next line, to avoid breaking stdout,696 // next line. to avoid breaking stdout, the newline gets emitted on
607 // the newline gets emitted on the interactive out.697 // the interactive out. (a line ending exactly on the terminal edge
608 (createsPartialLine ? "\n" : "")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" : "")
609 // the widget text701 // the widget text
610 + newWidgetLines.map((newLine, i) =>702 + newWidgetLines.map((newLine, i) =>
611 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)703 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
...@@ -620,14 +712,20 @@ export function createTerminalWidgetHost(...@@ -620,14 +712,20 @@ export function createTerminalWidgetHost(
620 + "\n"712 + "\n"
621 ).join("") + ansi.syncEnd,713 ).join("") + ansi.syncEnd,
622 );714 );
715 partialWidth = span.endWidth;
623 } else {716 } else {
624 const clearLinesBottom = Math.min(717 const clearLinesBottom = Math.min(
625 lines.length,718 lines.length,
626 Math.max(0, lines.length - (newWidgetLines?.length ?? 0)),719 Math.max(0, lines.length - newWidgetLines.length),
627 );720 );
628 terminal.writeInteractive(721 term.writeInteractive(
629 (needsToSaveCursor ? ansi.cursorSave + "\n" : "")722 (needsToSaveCursor ? ansi.cursorSave + "\n" : "")
630 + (hasSyncStart ? "" : ansi.syncStart)723 + (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" : "")
631 // clear the bottom lines729 // clear the bottom lines
632 + (clearLinesBottom730 + (clearLinesBottom
633 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine)731 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine)
...@@ -658,6 +756,17 @@ export function createTerminalWidgetHost(...@@ -658,6 +756,17 @@ export function createTerminalWidgetHost(
658 buffer = "";756 buffer = "";
659 }757 }
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
661 function redrawSoon(ms: number) {770 function redrawSoon(ms: number) {
662 if (locks > 0 || (ms === 0 && rendering)) return;771 if (locks > 0 || (ms === 0 && rendering)) return;
663 const newRedrawTime = now() + ms;772 const newRedrawTime = now() + ms;
...@@ -671,35 +780,40 @@ export function createTerminalWidgetHost(...@@ -671,35 +780,40 @@ export function createTerminalWidgetHost(
671 timer.then(redrawCallback);780 timer.then(redrawCallback);
672 }781 }
673782
783 function requestRedraw() {
784 if (rendering) pendingRedraw = true;
785 else redrawSoon(0);
786 }
787
674 function flushAndClear(shortTermDrawLock: boolean) {788 function flushAndClear(shortTermDrawLock: boolean) {
675 timer?.cancel();789 timer?.cancel();
676 timer = null;790 timer = null;
677 if (lines.length > 0) {791 if (lines.length > 0) {
678 UNWRAP(terminal).writeInteractive(792 const term = UNWRAP(terminal);
793 const pCol = partialWidth % UNWRAP(size).columns;
794 term.writeInteractive(
679 ansi.syncStart795 ansi.syncStart
680 // clear the widget space796 // clear the widget space
681 + (ansi.cursorUp(1) + ansi.clearFullLine)797 + (ansi.cursorUp(1) + ansi.clearFullLine)
682 .repeat(lines.length)798 .repeat(lines.length)
683 + (partialLineIndex799 + (pCol
684 ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex)800 ? ansi.cursorUp(1) + ansi.cursorRight(pCol)
685 : "")801 : "")
686 + (shortTermDrawLock ? "" : ansi.syncEnd)802 + (shortTermDrawLock ? "" : ansi.syncEnd)
687 + (needsToRestoreCursor ? ansi.cursorRestore : ""),803 + (needsToRestoreCursor ? ansi.cursorRestore : ""),
688 );804 );
805 needsToRestoreCursor = false;
689 lines = [];806 lines = [];
690 hasSyncStart = shortTermDrawLock;807 hasSyncStart = shortTermDrawLock;
691 }808 }
692 if (buffer.length > 0) {809 if (buffer.length > 0) {
693 if (widgets.length === 0 && writeOutputTemporaryLock) {810 if (widgets.length === 0 && !terminal && writeOutputTemporaryLock) {
694 writeOutputTemporaryLock(buffer);811 writeOutputTemporaryLock(buffer);
695 } else {812 } else {
696 terminal ??= lockTerminal();813 acquireTerminal().writeOutput(buffer);
697 terminal.writeOutput(buffer);814 if (widgets.length === 0) closeTerminal();
698 if (widgets.length === 0) {
699 terminal.close();
700 terminal = null;
701 }
702 }815 }
816 trackPartialWidth(buffer);
703 buffer = "";817 buffer = "";
704 }818 }
705 }819 }
...@@ -720,8 +834,7 @@ export function createTerminalWidgetHost(...@@ -720,8 +834,7 @@ export function createTerminalWidgetHost(
720 if (terminal.temporaryUnlock) {834 if (terminal.temporaryUnlock) {
721 tempUnlock = terminal.temporaryUnlock();835 tempUnlock = terminal.temporaryUnlock();
722 } else {836 } else {
723 terminal.close();837 closeTerminal();
724 terminal = null;
725 }838 }
726 }839 }
727 }840 }
...@@ -730,14 +843,19 @@ export function createTerminalWidgetHost(...@@ -730,14 +843,19 @@ export function createTerminalWidgetHost(
730 return {843 return {
731 release(mode) {844 release(mode) {
732 if (disposed) return;845 if (disposed) return;
846 disposed = true;
733 locks -= 1;847 locks -= 1;
734 needsToSaveCursor ||= mode === "cursor-middle-of-line";848 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;
735 if (locks === 0) {854 if (locks === 0) {
736 tempUnlock?.();855 tempUnlock?.();
737 tempUnlock = null;856 tempUnlock = null;
738 if (buffer.length > 0 || widgets.length > 0) redrawSoon(0);857 if (buffer.length > 0 || widgets.length > 0) redrawSoon(0);
739 }858 }
740 disposed = true;
741 },859 },
742 [Symbol.dispose]() {860 [Symbol.dispose]() {
743 this.release("cursor-middle-of-line");861 this.release("cursor-middle-of-line");
...@@ -748,12 +866,11 @@ export function createTerminalWidgetHost(...@@ -748,12 +866,11 @@ export function createTerminalWidgetHost(
748 ASSERT(!widgets.includes(options), "Cannot start the same widget twice.");866 ASSERT(!widgets.includes(options), "Cannot start the same widget twice.");
749 let fps = options.fps ?? null;867 let fps = options.fps ?? null;
750 const state: WidgetState = {868 const state: WidgetState = {
751 next: 0,
752 frameTime: 1000 / (fps ?? 0),869 frameTime: 1000 / (fps ?? 0),
753 };870 };
754 widgets.push(options);871 widgets.push(options);
755 internals.push(state);872 internals.push(state);
756 redrawSoon(0);873 requestRedraw();
757 return {874 return {
758 options,875 options,
759 get fps() {876 get fps() {
...@@ -762,17 +879,16 @@ export function createTerminalWidgetHost(...@@ -762,17 +879,16 @@ export function createTerminalWidgetHost(
762 set fps(value) {879 set fps(value) {
763 fps = value;880 fps = value;
764 state.frameTime = 1000 / (fps ?? 0);881 state.frameTime = 1000 / (fps ?? 0);
882 // a pending frame may sit beyond the new cadence; draw to reanchor
883 requestRedraw();
765 },884 },
766 redraw() {885 redraw: requestRedraw,
767 state.next = 0;
768 redrawSoon(0);
769 },
770 stop() {886 stop() {
771 const i = widgets.indexOf(options);887 const i = widgets.indexOf(options);
772 if (i === -1) return;888 if (i === -1) return;
773 widgets.splice(i, 1);889 widgets.splice(i, 1);
774 UNWRAP(internals.splice(i, 1)[0]);890 UNWRAP(internals.splice(i, 1)[0]);
775 redrawSoon(0);891 requestRedraw();
776 },892 },
777 [Symbol.dispose]() {893 [Symbol.dispose]() {
778 this.stop();894 this.stop();
...@@ -787,6 +903,9 @@ export function createTerminalWidgetHost(...@@ -787,6 +903,9 @@ export function createTerminalWidgetHost(
787 if (!rendering) flushAndClear(false);903 if (!rendering) flushAndClear(false);
788 widgets.splice(0, widgets.length);904 widgets.splice(0, widgets.length);
789 internals.splice(0, internals.length);905 internals.splice(0, internals.length);
906 timer?.cancel();
907 timer = null;
908 if (!rendering) closeTerminal();
790 },909 },
791 delay,910 delay,
792 now,911 now,
...@@ -794,13 +913,35 @@ export function createTerminalWidgetHost(...@@ -794,13 +913,35 @@ export function createTerminalWidgetHost(
794 };913 };
795}914}
796915
797function partialLineLength(text: string) {916/**
798 const idx = text.lastIndexOf("\n");917 * compute the cursor descent (`rows`) and trailing line width (`endWidth`)
799 if (idx === -1) return text.length;918 * from writing `text` to a terminal `columns` wide, with the cursor starting
800 return text.length - idx - 1;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 };
801}942}
802943
803const logColors = node.process?.stderr.isTTY ?? false;944const logColors = node.process?.stdout.isTTY ?? false;
804945
805let formatLine = /* @__PURE__ */ (() => {946let formatLine = /* @__PURE__ */ (() => {
806 const fwo = node.builtin("util")?.formatWithOptions;947 const fwo = node.builtin("util")?.formatWithOptions;
...@@ -944,7 +1085,7 @@ interface GlobalCommunication {...@@ -944,7 +1085,7 @@ interface GlobalCommunication {
944}1085}
9451086
946const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log");1087const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log");
947const version = 4;1088const version = 5;
948let global: GlobalCommunication = /* @__PURE__ */ (() => {1089let global: GlobalCommunication = /* @__PURE__ */ (() => {
949 const global = (globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= {1090 const global = (globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= {
950 readme:1091 readme:
...@@ -1032,13 +1173,16 @@ export function defaultNodeProcessWidgetHost(...@@ -1032,13 +1173,16 @@ export function defaultNodeProcessWidgetHost(
1032 const stderrEnd = stderr.end;1173 const stderrEnd = stderr.end;
1033 function patchWriteMethod<T, R, A extends [string | Uint8Array]>(1174 function patchWriteMethod<T, R, A extends [string | Uint8Array]>(
1034 fn: (this: T, ...args: A) => R,1175 fn: (this: T, ...args: A) => R,
1176 touchesScreen: boolean,
1035 ) {1177 ) {
1036 return function(this: T, ...args: A) {1178 return function(this: T, ...args: A) {
1037 using lock = disposed ? null : host.getDrawLock("short");1179 using lock = disposed ? null : host.getDrawLock("short");
1038 const ret = fn.apply(this, args);1180 const ret = fn.apply(this, args);
1039 if (lock) {1181 if (lock) {
1182 // writes to a redirected stream never move the screen cursor
1040 lock.release(1183 lock.release(
1041 (typeof args[0] === "string"1184 !touchesScreen
1185 || (typeof args[0] === "string"
1042 ? args[0].endsWith("\n")1186 ? args[0].endsWith("\n")
1043 : bufferEndsInNewline(args[0]))1187 : bufferEndsInNewline(args[0]))
1044 ? "cursor-start-of-line"1188 ? "cursor-start-of-line"
...@@ -1048,8 +1192,14 @@ export function defaultNodeProcessWidgetHost(...@@ -1048,8 +1192,14 @@ export function defaultNodeProcessWidgetHost(
1048 return ret;1192 return ret;
1049 };1193 };
1050 }1194 }
1051 const newStdoutWrite = stdout.write = patchWriteMethod(stdoutWrite);1195 const newStdoutWrite = stdout.write = patchWriteMethod(
1052 const newStderrWrite = stderr.write = patchWriteMethod(stderrWrite);1196 stdoutWrite,
1197 stdout.isTTY,
1198 );
1199 const newStderrWrite = stderr.write = patchWriteMethod(
1200 stderrWrite,
1201 true,
1202 );
1053 function patchEndMethod<T, R, A extends unknown[]>(1203 function patchEndMethod<T, R, A extends unknown[]>(
1054 fn: (this: T, ...args: A) => R,1204 fn: (this: T, ...args: A) => R,
1055 ) {1205 ) {
...@@ -1090,19 +1240,34 @@ export function defaultNodeProcessWidgetHost(...@@ -1090,19 +1240,34 @@ export function defaultNodeProcessWidgetHost(
1090 }1240 }
10911241
1092 return {1242 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),
1094 writeInteractive: (string) => stderrWrite.call(stderr, string),1247 writeInteractive: (string) => stderrWrite.call(stderr, string),
1095 getSize: () => process.stderr,1248 observeSize(callback) {
1096 temporarilyUnlock() {1249 const emit = () =>
1097 // no action needed1250 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 () => {};
1098 },1263 },
1099 close() {1264 close() {
1100 disposed = true;1265 disposed = true;
1101 // leave patches in place if something else tampered with it.1266 // leave patches in place if something else tampered with it.
1102 if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite;1267 if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite;
1103 if (stderr.write === newStderrWrite) stdout.write = stderrWrite;1268 if (stderr.write === newStderrWrite) stderr.write = stderrWrite;
1104 if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd;1269 if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd;
1105 if (stderr.end === newStderrEnd) stdout.end = stderrEnd;1270 if (stderr.end === newStderrEnd) stderr.end = stderrEnd;
1106 for (const [key, old, patched] of restoreConsole) {1271 for (const [key, old, patched] of restoreConsole) {
1107 if (console[key] === patched) console[key] = old;1272 if (console[key] === patched) console[key] = old;
1108 }1273 }
...@@ -1112,6 +1277,7 @@ export function defaultNodeProcessWidgetHost(...@@ -1112,6 +1277,7 @@ export function defaultNodeProcessWidgetHost(
1112 now: () => performance.now(),1277 now: () => performance.now(),
1113 delay: async.delay,1278 delay: async.delay,
1114 color: process.stderr.isTTY,1279 color: process.stderr.isTTY,
1280 outputSharesScreen: process.stdout.isTTY,
1115 });1281 });
1116 process.addListener("beforeExit", () => host.cancel());1282 process.addListener("beforeExit", () => host.cancel());
1117 process.addListener("exit", () => host.cancel());1283 process.addListener("exit", () => host.cancel());
...@@ -1156,7 +1322,16 @@ export function simpleNodeProcessWidgetHost(...@@ -1156,7 +1322,16 @@ export function simpleNodeProcessWidgetHost(
1156 lockTerminal: () => ({1322 lockTerminal: () => ({
1157 writeOutput: (string) => process.stdout.write(string),1323 writeOutput: (string) => process.stdout.write(string),
1158 writeInteractive: (string) => process.stderr.write(string),1324 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 },
1160 close() {1335 close() {
1161 // no action needed1336 // no action needed
1162 },1337 },
...@@ -1164,6 +1339,7 @@ export function simpleNodeProcessWidgetHost(...@@ -1164,6 +1339,7 @@ export function simpleNodeProcessWidgetHost(
1164 now: () => performance.now(),1339 now: () => performance.now(),
1165 delay: async.delay,1340 delay: async.delay,
1166 color: process.stderr.isTTY,1341 color: process.stderr.isTTY,
1342 outputSharesScreen: process.stdout.isTTY,
1167 })1343 })
1168 : {1344 : {
1169 writeOutput: (string) => process.stdout.write(string),1345 writeOutput: (string) => process.stdout.write(string),
...@@ -1201,6 +1377,8 @@ const globalLog = /* @__PURE__ */ (() =>...@@ -1201,6 +1377,8 @@ const globalLog = /* @__PURE__ */ (() =>
1201 } else if (node.process) {1377 } else if (node.process) {
1202 globalWidgetHost()[1378 globalWidgetHost()[
1203 (m.level ?? "info") === "info" ? "writeOutput" : "writeError"1379 (m.level ?? "info") === "info" ? "writeOutput" : "writeError"
1380 // colors keyed off stdout, the destination of host log output;
1381 // `logColors` (used for inspect formatting) matches.
1204 ](formatAnsiMessage(m, node.process.stdout.isTTY));1382 ](formatAnsiMessage(m, node.process.stdout.isTTY));
1205 } else {1383 } else {
1206 let { level = "info", [originalLogArgs]: args = [m.text], scope } = m;1384 let { level = "info", [originalLogArgs]: args = [m.text], scope } = m;
...@@ -1233,6 +1411,5 @@ import * as async from "./async.ts";...@@ -1233,6 +1411,5 @@ import * as async from "./async.ts";
1233import * as errors from "./error.ts";1411import * as errors from "./error.ts";
1234import * as stack from "./log/stack.ts";1412import * as stack from "./log/stack.ts";
1235import * as node from "./node.ts";1413import * as node from "./node.ts";
1236import * as string from "./string.ts";
1237import * as ansi from "./string/ansi.ts";1414import * as ansi from "./string/ansi.ts";
1238import * as ts from "./ts.ts";1415import * as ts from "./ts.ts";
lib/node.ts+5-2
...@@ -30,9 +30,12 @@ interface Process {...@@ -30,9 +30,12 @@ interface Process {
3030
31interface Tty {31interface Tty {
32 isTTY: boolean;32 isTTY: boolean;
33 columns: number;33 /** only present when the stream is a tty */
34 rows: number;34 columns: number | undefined;
35 /** only present when the stream is a tty */
36 rows: number | undefined;
35 addListener(event: string, callback: () => void): this;37 addListener(event: string, callback: () => void): this;
38 removeListener(event: string, callback: () => void): this;
36 end(text?: string | Uint8Array): this;39 end(text?: string | Uint8Array): this;
37 write(text: string | Uint8Array): boolean;40 write(text: string | Uint8Array): boolean;
38}41}
lib/progress.test.ts+155-6
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1// a trivial example of how to use progress1// a trivial example of how to use progress
2test.skip("trivial end-to-end example", () => {2test("trivial end-to-end example", () => {
3 const { fgBlue: FB, fgReset: FR, reset: R } = ansi;3 const { fgBlue: FB, fgReset: FR, reset: R } = ansi;
4 const screen = new testing.MockScreen(); // create a mock terminal4 const screen = new testing.MockScreen(); // create a mock terminal
5 const root = new progress.Root(screen); // sync with mock timers5 const root = new progress.Root(screen); // sync with mock timers
...@@ -13,18 +13,20 @@ test.skip("trivial end-to-end example", () => {...@@ -13,18 +13,20 @@ test.skip("trivial end-to-end example", () => {
13 assert.equal(screen.timers.entries.length, 1);13 assert.equal(screen.timers.entries.length, 1);
1414
15 screen.expectFrame(1, { merged: "" }); // debounce15 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
17 screen.expectFrame(0, {18 screen.expectFrame(0, {
18 merged: testing.MockScreen.sync([19 merged: testing.MockScreen.sync([
19 `\r${FB}⠋${FR} hello${R}\n`,20 `${FB}⠋${FR} hello${R}\n`,
20 `${FB}⠋${FR} cats${R}\n`,21 `${FB}⠋${FR} cats${R}\n`,
21 "└─ meow\n",22 "└─ meow\n",
22 ]),23 ]),
23 });24 });
2425
26 // spinners animate from the clock, so frames tick at the spinner rate
25 screen.expectFrame(80, {27 screen.expectFrame(80, {
26 merged: testing.MockScreen.sync([28 merged: testing.MockScreen.sync([
27 `\r` + ansi.cursorUp(3),29 ansi.cursorUp(3),
28 `${FB}⠙${FR} hello${R}\n`,30 `${FB}⠙${FR} hello${R}\n`,
29 `${FB}⠙${FR} cats${R}\n`,31 `${FB}⠙${FR} cats${R}\n`,
30 "└─ meow\n",32 "└─ meow\n",
...@@ -34,13 +36,66 @@ test.skip("trivial end-to-end example", () => {...@@ -34,13 +36,66 @@ test.skip("trivial end-to-end example", () => {
34 a.end();36 a.end();
35 b.end();37 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, {
38 merged: testing.MockScreen.sync([42 merged: testing.MockScreen.sync([
39 `\r` + ansi.cursorUp(1) + ansi.clearFullLine,
40 ansi.cursorUp(1) + ansi.clearFullLine,43 ansi.cursorUp(1) + ansi.clearFullLine,
41 ansi.cursorUp(1) + ansi.clearFullLine,44 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,
42 ]),92 ]),
43 });93 });
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();
44});99});
45100
46describe("encodeEventStream", (t) => {101describe("encodeEventStream", (t) => {
...@@ -73,6 +128,99 @@ describe("encodeEventStream", (t) => {...@@ -73,6 +128,99 @@ describe("encodeEventStream", (t) => {
73 [1],128 [1],
74 ]);129 ]);
75 });130 });
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");
76});224});
77225
78// ## value formatters226// ## value formatters
...@@ -294,6 +442,7 @@ type EncodedKey = typeof progress.internals.EncodedKey;...@@ -294,6 +442,7 @@ type EncodedKey = typeof progress.internals.EncodedKey;
294import assert from "node:assert/strict";442import assert from "node:assert/strict";
295import { describe, test, vi } from "vitest";443import { describe, test, vi } from "vitest";
296import { UNWRAP } from "./assert.ts";444import { UNWRAP } from "./assert.ts";
445import * as async from "./async.ts";
297import * as progress from "./progress.ts";446import * as progress from "./progress.ts";
298import * as stream from "./stream.ts";447import * as stream from "./stream.ts";
299import * as ansi from "./string/ansi.ts";448import * 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) {...@@ -622,12 +622,9 @@ function endNode<R, M extends Events.Map>(owner: Root<R, M>, state: Internal) {
622 state.children.forEach((child) => endNode(owner, child));622 state.children.forEach((child) => endNode(owner, child));
623 state.detached = true;623 state.detached = true;
624 globalKeyPool.recycle(state.key);624 globalKeyPool.recycle(state.key);
625 let title = state.text;
626 if (state.parent) {625 if (state.parent) {
627 const i = state.parent.children.indexOf(state);626 const i = state.parent.children.indexOf(state);
628 ASSERT(i !== -1);627 ASSERT(i !== -1);
629 let p: Internal | null = state;
630 while (p = p.parent) title = p.text + " / " + title;
631 state.parent.children.splice(i, 1);628 state.parent.children.splice(i, 1);
632 }629 }
633 owner.emit("node-end", state);630 owner.emit("node-end", state);
...@@ -732,7 +729,7 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {...@@ -732,7 +729,7 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {
732 }729 }
733 if (truncated) {730 if (truncated) {
734 out += depth.map((x) => x ? box.line : " ").join("") + box.langle;731 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";
736 }733 }
737 return out;734 return out;
738}735}
...@@ -793,8 +790,11 @@ export function attachToScreen(...@@ -793,8 +790,11 @@ export function attachToScreen(
793 format: ({ now }) => formatAnsi(now, root.active),790 format: ({ now }) => formatAnsi(now, root.active),
794 }) ?? null;791 }) ?? null;
795 if (!widget) return;792 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))
797 ? spinnerFps795 ? spinnerFps
796 : hasEstimate(items)
797 ? 1
798 : null;798 : null;
799 widget.redraw();799 widget.redraw();
800 } else {800 } else {
...@@ -817,7 +817,16 @@ export function attachToScreen(...@@ -817,7 +817,16 @@ export function attachToScreen(
817 }817 }
818 }));818 }));
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));
821}830}
822831
823const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n");832const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n");
...@@ -915,7 +924,7 @@ export function encodeEventStream<...@@ -915,7 +924,7 @@ export function encodeEventStream<
915 let timer: async.Cancelable<void> | null = null;924 let timer: async.Cancelable<void> | null = null;
916 function emitSoon() {925 function emitSoon() {
917 if (timer || ready.value) return;926 if (timer || ready.value) return;
918 const remaining = now() - lastEvent + throttleMs;927 const remaining = lastEvent + throttleMs - now();
919 if (remaining > 0) {928 if (remaining > 0) {
920 (timer = delay(remaining)).then(() => {929 (timer = delay(remaining)).then(() => {
921 timer = null;930 timer = null;
...@@ -927,6 +936,13 @@ export function encodeEventStream<...@@ -927,6 +936,13 @@ export function encodeEventStream<
927 lastEvent = now();936 lastEvent = now();
928 }937 }
929 }938 }
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 }
930 stack.defer(() => {946 stack.defer(() => {
931 if (timer) timer.cancel();947 if (timer) timer.cancel();
932 s.changed.clear();948 s.changed.clear();
...@@ -959,6 +975,22 @@ export function encodeEventStream<...@@ -959,6 +975,22 @@ export function encodeEventStream<
959 s.deleted.add(node); // handle add and remove in same frame975 s.deleted.add(node); // handle add and remove in same frame
960 emitSoon();976 emitSoon();
961 }));977 }));
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 }));
962 },994 },
963 async pull(controller) {995 async pull(controller) {
964 await ready.until((x) => x === true);996 await ready.until((x) => x === true);
...@@ -969,10 +1001,13 @@ export function encodeEventStream<...@@ -969,10 +1001,13 @@ export function encodeEventStream<
969 }1001 }
970 controller.enqueue(s.getDelta(isFirst));1002 controller.enqueue(s.getDelta(isFirst));
971 isFirst = false;1003 isFirst = false;
1004 if (s.ended) {
1005 controller.close();
1006 stack.dispose();
1007 }
972 },1008 },
973 cancel() {1009 cancel() {
974 stack.dispose();1010 stack.dispose();
975 (0, performance.now)();
976 },1011 },
977 }, { highWaterMark: 0 });1012 }, { highWaterMark: 0 });
978}1013}
...@@ -1003,11 +1038,10 @@ export function decodeEventStream<...@@ -1003,11 +1038,10 @@ export function decodeEventStream<
1003 const { value, done } = await reader.read();1038 const { value, done } = await reader.read();
1004 signal.throwIfAborted();1039 signal.throwIfAborted();
1005 if (done) break;1040 if (done) break;
1006 decoder.processEvent(value);1041 if (decoder.processEvent(value)) hasEmittedEnd = true;
1007 }1042 }
1008 ASSERT(hasEmittedEnd, "Stream terminated early.");1043 ASSERT(hasEmittedEnd, "Stream terminated early.");
1009 } catch (err) {1044 } catch (err) {
1010 console.error(err);
1011 reader.cancel(err);1045 reader.cancel(err);
1012 if (!signal.aborted) reject(err);1046 if (!signal.aborted) reject(err);
1013 } finally {1047 } finally {
...@@ -1037,6 +1071,10 @@ class Encoder<...@@ -1037,6 +1071,10 @@ class Encoder<
1037 rootChildrenUpdated = true;1071 rootChildrenUpdated = true;
1038 changed = new Map<ReadOnlyNode, Set<keyof ReadOnlyNode>>();1072 changed = new Map<ReadOnlyNode, Set<keyof ReadOnlyNode>>();
1039 deleted = new Set<ReadOnlyNode>();1073 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
1041 constructor(1079 constructor(
1042 root: Root<Result, EventMap>,1080 root: Root<Result, EventMap>,
...@@ -1119,8 +1157,11 @@ class Encoder<...@@ -1119,8 +1157,11 @@ class Encoder<
1119 );1157 );
1120 }1158 }
11211159
1160 payload.push(...this.pendingEvents);
1161
1122 this.deleted.clear();1162 this.deleted.clear();
1123 this.changed.clear();1163 this.changed.clear();
1164 this.pendingEvents = [];
1124 this.rootChildrenUpdated = false;1165 this.rootChildrenUpdated = false;
11251166
1126 return payload;1167 return payload;
...@@ -1182,7 +1223,6 @@ class Decoder<...@@ -1182,7 +1223,6 @@ class Decoder<
1182 events: Events<EventMap>;1223 events: Events<EventMap>;
1183 resolve: (result: Result) => void;1224 resolve: (result: Result) => void;
1184 active = new Map<EncodedKey, Node>();1225 active = new Map<EncodedKey, Node>();
1185 children = new Map<Node, Node[]>();
1186 pendingStart = new Map<EncodedKey, PendingStart>();1226 pendingStart = new Map<EncodedKey, PendingStart>();
1187 pendingParents = new Map<EncodedKey, EncodedKey>();1227 pendingParents = new Map<EncodedKey, EncodedKey>();
11881228
...@@ -1201,7 +1241,8 @@ class Decoder<...@@ -1201,7 +1241,8 @@ class Decoder<
1201 this.active.clear();1241 this.active.clear();
1202 }1242 }
12031243
1204 processEvent(event: StreamEvent) {1244 /** returns whether the stream signalled "end"; throws on "error" */
1245 processEvent(event: StreamEvent): boolean {
1205 event = event.slice();1246 event = event.slice();
12061247
1207 // delete unreferenced nodes first, since they may get re-allocated1248 // delete unreferenced nodes first, since they may get re-allocated
...@@ -1236,6 +1277,8 @@ class Decoder<...@@ -1236,6 +1277,8 @@ class Decoder<
1236 if (chunk[0] === "end") {1277 if (chunk[0] === "end") {
1237 resolving = true;1278 resolving = true;
1238 resolvingValue = chunk[1];1279 resolvingValue = chunk[1];
1280 } else if (chunk[0] === "error") {
1281 throw new Error(`Progress stream error: ${chunk[1]}`);
1239 } else {1282 } else {
1240 // custom event1283 // custom event
1241 // @ts-expect-error TODO: typescript soundness1284 // @ts-expect-error TODO: typescript soundness
...@@ -1300,13 +1343,21 @@ class Decoder<...@@ -1300,13 +1343,21 @@ class Decoder<
1300 }1343 }
1301 this.pendingParents.clear();1344 this.pendingParents.clear();
13021345
1346 // validate that every referenced child became active; a failure here
1347 // means the stream is desynced
1303 for (const { key, children } of updateChildren) {1348 for (const { key, children } of updateChildren) {
1304 const node = UNWRAP(this.active.get(key));1349 UNWRAP(this.active.get(key), () => `parent ${key} not active`);
1305 this.children.set(1350 for (const id of children) {
1306 node,1351 UNWRAP(this.active.get(id), () => `child ${id} not active`);
1307 children.map((id) => UNWRAP(this.active.get(id))),1352 }
1308 );
1309 }1353 }
1354
1355 if (resolving) {
1356 this.reset();
1357 this.resolve(resolvingValue as Result);
1358 return true;
1359 }
1360 return false;
1310 }1361 }
13111362
1312 startRecursive(key: EncodedKey, opts: PendingStart): Ref {1363 startRecursive(key: EncodedKey, opts: PendingStart): Ref {
...@@ -1526,10 +1577,14 @@ export function decodeByteStream<...@@ -1526,10 +1577,14 @@ export function decodeByteStream<
1526 async start(controller) {1577 async start(controller) {
1527 reader = new stream.BufferedReader(encoded.getReader());1578 reader = new stream.BufferedReader(encoded.getReader());
1528 try {1579 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();
1530 } catch (e) {1585 } catch (e) {
1531 if (!cancelled) {1586 if (!cancelled) {
1532 reader.cancel(e);1587 reader?.cancel(e);
1533 throw e;1588 throw e;
1534 }1589 }
1535 } finally {1590 } finally {
...@@ -1802,6 +1857,7 @@ export const internals: {...@@ -1802,6 +1857,7 @@ export const internals: {
1802import { ASSERT, UNWRAP } from "./assert.ts";1857import { ASSERT, UNWRAP } from "./assert.ts";
1803import * as async from "./async.ts";1858import * as async from "./async.ts";
1804import * as bytes from "./bytes.ts";1859import * as bytes from "./bytes.ts";
1860import * as errors from "./error.ts";
1805import { Events } from "./Events.ts";1861import { Events } from "./Events.ts";
1806import * as log from "./log.ts";1862import * as log from "./log.ts";
1807import * as stack from "./log/stack.ts";1863import * as stack from "./log/stack.ts";
lib/string.test.ts+6-4
...@@ -3,10 +3,12 @@ test("countNewlines", () => {...@@ -3,10 +3,12 @@ test("countNewlines", () => {
3 assert.equal(string.countNewlines(""), 0);3 assert.equal(string.countNewlines(""), 0);
4});4});
5test("escapeRegExp", () => {5test("escapeRegExp", () => {
6 assert.equal(6 // the exact escape encoding differs between the native `RegExp.escape`
7 string.escapeRegExp("delete * from users where id = $id"),7 // (hex escapes) and the fallback (backslashes); assert the behavior
8 "delete \\* from users where id = \\$id",8 const input = "delete * from users where id = $id";
9 );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"));
10});12});
11test("escapeShellArgument", () => {13test("escapeShellArgument", () => {
12 // escapeShellArgument leaves it alone when the input is trivial14 // 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";...@@ -164,6 +164,8 @@ export const clearToEndOfLine: string = "\x1b[K";
164export const clearToStartOfLine: string = "\x1b[1K";164export const clearToStartOfLine: string = "\x1b[1K";
165/** clear the entire line that the cursor is on without moving it. */165/** clear the entire line that the cursor is on without moving it. */
166export const clearFullLine: string = "\x1b[2K";166export 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
168/**170/**
169 * Begin Synchronized Output171 * Begin Synchronized Output
...@@ -257,6 +259,7 @@ export function debugAnsi(text: string): string {...@@ -257,6 +259,7 @@ export function debugAnsi(text: string): string {
257 "clearToEndOfLine",259 "clearToEndOfLine",
258 "clearToStartOfLine",260 "clearToStartOfLine",
259 "clearFullLine",261 "clearFullLine",
262 "clearToEndOfScreen",
260 "syncStart",263 "syncStart",
261 "syncEnd",264 "syncEnd",
262 "bold",265 "bold",
...@@ -292,14 +295,9 @@ export function widthInTerminal(str: string): number {...@@ -292,14 +295,9 @@ export function widthInTerminal(str: string): number {
292 width += 1;295 width += 1;
293 continue;296 continue;
294 }297 }
298 // the remainder of the string is measured with the segmenter, which
299 // handles ascii correctly via segmentWidth (controls are zero-width).
295 for (const { segment } of segmenter.segment(str.slice(i))) {300 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 }
303 width += segmentWidth(segment);301 width += segmentWidth(segment);
304 }302 }
305 return width;303 return width;
lib/testing.ts+34-17
...@@ -114,7 +114,7 @@ export class SyncPromise<T> implements Promise<T> {...@@ -114,7 +114,7 @@ export class SyncPromise<T> implements Promise<T> {
114export class FakeTimers {114export class FakeTimers {
115 time = 0;115 time = 0;
116 entries: Array<{116 entries: Array<{
117 duration: number;117 deadline: number;
118 resolve: () => void;118 resolve: () => void;
119 src: stack.Frame[];119 src: stack.Frame[];
120 }> = [];120 }> = [];
...@@ -124,21 +124,23 @@ export class FakeTimers {...@@ -124,21 +124,23 @@ export class FakeTimers {
124 };124 };
125 delay: typeof async.delay = (ms) => {125 delay: typeof async.delay = (ms) => {
126 const src = stack.capture(2);126 const src = stack.capture(2);
127 let entry: (typeof this.entries)[number];
127 return async.makeCancelable(128 return async.makeCancelable(
128 new SyncPromise((resolve) => {129 new SyncPromise((resolve) => {
129 ASSERT(this.entries.length === 0);130 entry = { deadline: this.time + ms, resolve, src };
130 this.entries.push({131 this.entries.push(entry);
131 duration: ms,
132 resolve,
133 src,
134 });
135 }),132 }),
136 () => {133 () => {
137 ASSERT(this.entries.length === 1);134 const i = this.entries.indexOf(entry);
138 this.entries.length = 0;135 if (i !== -1) this.entries.splice(i, 1);
139 },136 },
140 );137 );
141 };138 };
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 }
142}144}
143145
144export function removeUndefinedKeys(obj: unknown): unknown {146export function removeUndefinedKeys(obj: unknown): unknown {
...@@ -176,12 +178,16 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -176,12 +178,16 @@ export class MockScreen implements Disposable, log.WidgetHost {
176 now: log.WidgetHost["now"];178 now: log.WidgetHost["now"];
177179
178 hasTerminalLock: null | "locked" | "temporary-unlock" = null;180 hasTerminalLock: null | "locked" | "temporary-unlock" = null;
181 #sizeObservers = new Set<(size: { columns: number; rows: number }) => void>();
179182
180 static sync(text: string[]): string {183 static sync(text: string[]): string {
181 return ansi.syncStart + text.join("") + ansi.syncEnd;184 return ansi.syncStart + text.join("") + ansi.syncEnd;
182 }185 }
183186
184 constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) {187 constructor({ temporaryUnlocking, outputSharesScreen }: {
188 temporaryUnlocking?: boolean;
189 outputSharesScreen?: boolean;
190 } = {}) {
185 const callerFile = UNWRAP(stack.capture()[0]);191 const callerFile = UNWRAP(stack.capture()[0]);
186 const host = log.createTerminalWidgetHost({192 const host = log.createTerminalWidgetHost({
187 lockTerminal: () => {193 lockTerminal: () => {
...@@ -210,8 +216,10 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -210,8 +216,10 @@ export class MockScreen implements Disposable, log.WidgetHost {
210 content,216 content,
211 });217 });
212 },218 },
213 getSize: () => {219 observeSize: (callback) => {
214 return this;220 callback({ columns: this.columns, rows: this.rows });
221 this.#sizeObservers.add(callback);
222 return () => void this.#sizeObservers.delete(callback);
215 },223 },
216 temporaryUnlock: temporaryUnlocking224 temporaryUnlock: temporaryUnlocking
217 ? () => {225 ? () => {
...@@ -231,6 +239,7 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -231,6 +239,7 @@ export class MockScreen implements Disposable, log.WidgetHost {
231 now: this.timers.now,239 now: this.timers.now,
232 delay: this.timers.delay,240 delay: this.timers.delay,
233 color: true,241 color: true,
242 outputSharesScreen,
234 });243 });
235 this.writeOutput = host.writeOutput;244 this.writeOutput = host.writeOutput;
236 this.writeError = host.writeError;245 this.writeError = host.writeError;
...@@ -240,18 +249,26 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -240,18 +249,26 @@ export class MockScreen implements Disposable, log.WidgetHost {
240 this.now = host.now;249 this.now = host.now;
241 }250 }
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
243 expectNone() {259 expectNone() {
244 ASSERT(this.timers.entries.length === 0);260 ASSERT(this.timers.entries.length === 0);
245 }261 }
246262
247 expectWithoutConsume(ms: number) {263 expectWithoutConsume(ms: number) {
248 const wait = UNWRAP(264 const wait = UNWRAP(
249 this.timers.entries[0],265 this.timers.next(),
250 () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o",266 () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o",
251 );267 );
268 const duration = wait.deadline - this.timers.time;
252 ASSERT(269 ASSERT(
253 ms === wait.duration,270 ms === duration,
254 `expected ${ms}ms to pass, got ${wait.duration}, from:\n${271 `expected ${ms}ms to pass, got ${duration}, from:\n${
255 wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n")272 wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n")
256 }`,273 }`,
257 );274 );
...@@ -265,8 +282,8 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -265,8 +282,8 @@ export class MockScreen implements Disposable, log.WidgetHost {
265 }) {282 }) {
266 if (ms != null) {283 if (ms != null) {
267 const wait = this.expectWithoutConsume(ms);284 const wait = this.expectWithoutConsume(ms);
268 this.timers.entries.shift();285 this.timers.entries.splice(this.timers.entries.indexOf(wait), 1);
269 this.timers.time += wait.duration;286 this.timers.time = wait.deadline;
270 wait.resolve();287 wait.resolve();
271 } else {288 } else {
272 ASSERT(this.timers.entries.length === 0);289 ASSERT(this.timers.entries.length === 0);