authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-12 18:21:02-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-15 12:49:48-07:00
log333b8a7a5c68ab8ab1917a6192022b0f96c46823
tree1afbc15d9c3a4264106e327aa0b1951d905b3f11
parent6f72c75c17126db8f199d81e8e58650a45ed131e
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

feat(lib/log): writeError will actually write to stderr

closes #93

3 files changed, 176 insertions(+), 48 deletions(-)

lib/log.test.ts+77
......@@ -758,6 +758,83 @@ describe("log widgets", () => {
758758 host.cancel();
759759 });
760760
761 test("writeError routes to the interactive stream in order", () => {
762 const host = new testing.MockScreen();
763 host.writeOutput("out 1\n");
764 host.writeError("err 1\n");
765 host.writeOutput("out 2\n");
766 host.expectFrame(0, {
767 stdout: "out 1\nout 2\n",
768 stderr: "err 1\n",
769 merged: "out 1\nerr 1\nout 2\n",
770 });
771 host.cancel();
772 });
773
774 test("error text moves widgets like output", () => {
775 const host = new testing.MockScreen();
776 using _ = host.startWidget({ format: () => "w" });
777 host.expectFrame(0, { stderr: testing.MockScreen.sync(["w\n"]) });
778 host.writeError("oops\n");
779 host.expectFrame(0, {
780 stdout: "",
781 stderr: testing.MockScreen.sync([
782 ansi.cursorUp(1),
783 ansi.clearFullLine,
784 "oops\n",
785 "w\n",
786 ]),
787 });
788 host.cancel();
789 });
790
791 test("mixed stream partial lines share cursor math", () => {
792 const host = new testing.MockScreen();
793 using _ = host.startWidget({ format: () => "w" });
794 host.expectFrame(0, { stderr: testing.MockScreen.sync(["w\n"]) });
795 host.writeOutput("a");
796 host.writeError("b");
797 host.expectFrame(0, {
798 stdout: "a",
799 merged: testing.MockScreen.sync([
800 ansi.cursorUp(1),
801 ansi.clearFullLine,
802 "a",
803 "b",
804 "\n",
805 "w\n",
806 ]),
807 });
808 // the continuation point is column 2, covering both partial chunks
809 host.writeOutput("c\n");
810 host.expectFrame(0, {
811 merged: testing.MockScreen.sync([
812 ansi.cursorUp(2) + ansi.cursorRight(2),
813 "c\n",
814 "w\n",
815 ]),
816 });
817 host.cancel();
818 });
819
820 test("error text stays on screen when output is redirected", () => {
821 const host = new testing.MockScreen({ outputSharesScreen: false });
822 using _ = host.startWidget({ format: () => "w" });
823 host.expectFrame(0, { stderr: testing.MockScreen.sync(["w\n"]) });
824 host.writeOutput("to the file\n");
825 host.writeError("to the screen\n");
826 host.expectFrame(0, {
827 stdout: "to the file\n",
828 stderr: testing.MockScreen.sync([
829 ansi.cursorUp(1),
830 ansi.clearFullLine,
831 "to the screen\n",
832 "w\n",
833 ]),
834 });
835 host.cancel();
836 });
837
761838 test("widgets start below a partial line", () => {
762839 const host = new testing.MockScreen();
763840 let text = "";
lib/log.ts+98-48
......@@ -238,14 +238,14 @@ export function writeOutput(text: string) {
238238 globalWidgetHost().writeOutput(text);
239239}
240240
241// TODO:
242// /**
243// * no built-in prefix, formatting, or newlline. ensures the text does not interweave.
244// * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.
245// */
246// export function writeError(text: string) {
247// globalWidgetHost().writeError(text);
248// }
241/**
242 * like {@linkcode writeOutput}, but the text is written to the error stream
243 * (stderr in node.js). relative ordering between output and error text is
244 * preserved through the shared flush buffer.
245 */
246export function writeError(text: string) {
247 globalWidgetHost().writeError(text);
248}
249249
250250/** write a {@linkcode Message} object directly. */
251251export function writeMessage(m: Message) {
......@@ -390,7 +390,11 @@ export interface TerminalWidgetHostOptions {
390390 * interface to communicate everything about the terminal state correctly.
391391 */
392392export interface TerminalLock {
393 /** recieves ANSI escape sequences for interactive data (should flush immediately) */
393 /**
394 * recieves ANSI escape sequences for interactive data, as well as error log
395 * content from `writeError` (should flush immediately). the interactive
396 * stream and the error stream are the same: stderr.
397 */
394398 writeInteractive(text: string): void;
395399 /** recieves log content from `write` (pre-buffered; should flush immediately) */
396400 writeOutput(text: string): void;
......@@ -460,7 +464,12 @@ export function createTerminalWidgetHost(
460464 let locks = 0;
461465 let redrawTime = 0;
462466 let lastFlush = 0;
463 let buffer = "";
467 // log text awaiting a flush. error text shares the queue so that relative
468 // write order is preserved, but flushes to the interactive stream (stderr)
469 // and always lands on the widget screen, while plain output rows only
470 // count when `outputSharesScreen`. consecutive same-stream writes merge
471 // into one chunk.
472 let buffer: { text: string; err: boolean }[] = [];
464473 // visible width of the trailing partial log line (text since the last "\n"
465474 // written to output). the cursor column is derived as `partialWidth %
466475 // columns` at draw time, so the value survives resizes and wrapped lines.
......@@ -536,7 +545,7 @@ export function createTerminalWidgetHost(
536545 }
537546 // cancel() during this render pass (an exit handler unwinding through
538547 // a crashed format callback) skips its teardown; finish it here.
539 if (widgets.length === 0 && terminal && !timer && !buffer) {
548 if (widgets.length === 0 && terminal && !timer && !buffer.length) {
540549 lines = [];
541550 closeTerminal();
542551 }
......@@ -550,21 +559,24 @@ export function createTerminalWidgetHost(
550559 if (!lines.length && !widgets.length) {
551560 // a redraw can get scheduled with nothing to write (e.g. widgets torn
552561 // down before the timer fired); it is a no-op, not an error
553 if (!buffer) return;
562 if (!buffer.length) return;
554563 needsToRestoreCursor = false;
555564 needsToSaveCursor = false;
556 if (!terminal && writeOutputTemporaryLock) {
557 writeOutputTemporaryLock(buffer);
565 if (
566 !terminal && writeOutputTemporaryLock && !buffer.some((c) => c.err)
567 ) {
568 const text = buffer.map((c) => c.text).join("");
569 buffer = [];
570 writeOutputTemporaryLock(text);
571 if (outputOnScreen) trackPartialWidth(text);
558572 } else {
559 acquireTerminal().writeOutput(buffer);
573 trackPartialWidth(flushChunks(acquireTerminal()));
560574 }
561575 if (hasSyncStart) {
562576 acquireTerminal().writeInteractive(ansi.syncEnd);
563577 hasSyncStart = false;
564578 }
565579 closeTerminal();
566 trackPartialWidth(buffer);
567 buffer = "";
568580 return;
569581 }
570582
......@@ -624,10 +636,8 @@ export function createTerminalWidgetHost(
624636 needsToRestoreCursor = false;
625637 lines = [];
626638 }
627 if (buffer) {
628 term.writeOutput(buffer);
629 trackPartialWidth(buffer);
630 buffer = "";
639 if (buffer.length) {
640 trackPartialWidth(flushChunks(term));
631641 }
632642 if (hasSyncStart) {
633643 term.writeInteractive(ansi.syncEnd);
......@@ -639,15 +649,20 @@ export function createTerminalWidgetHost(
639649 return;
640650 }
641651
642 if (buffer && !outputOnScreen) {
652 // concatenation of the buffered text that lands on the widget screen,
653 // which is what the cursor math must account for
654 const screenText = buffer
655 .filter((c) => c.err || outputOnScreen)
656 .map((c) => c.text)
657 .join("");
658 if (buffer.length && screenText === "") {
643659 // off-screen output (e.g. stdout redirected to a file) does not
644660 // interact with the widget block; flush it plainly and fall through
645661 // to a pure widget redraw
646 term.writeOutput(buffer);
647 buffer = "";
662 flushChunks(term);
648663 }
649664
650 if (buffer) {
665 if (buffer.length) {
651666 // when writing a buffer alongside widgets, the screen may look like this
652667 // > [existing log]
653668 // > [optional partial line]
......@@ -659,7 +674,7 @@ export function createTerminalWidgetHost(
659674 // first, clear out the space where new lines are going to intersect.
660675 // `span.rows` measures the cursor descent in physical rows, so log
661676 // lines wider than the terminal are accounted for correctly.
662 const span = measureTerminalSpan(buffer, pCol, columns);
677 const span = measureTerminalSpan(screenText, pCol, columns);
663678 // if more rows are buffered than there are widgets, only some are
664679 // needed. when a partial line exists, the buffer starts on its row
665680 // (one above the widget block), hence the -1.
......@@ -689,8 +704,8 @@ export function createTerminalWidgetHost(
689704 : "")
690705 : ""),
691706 );
692 // then write output lines on standard out
693 term.writeOutput(buffer);
707 // then write the buffered log content to its streams
708 flushChunks(term);
694709 term.writeInteractive(
695710 // if the buffer leaves a partial line, the widgets have to go on the
696711 // next line. to avoid breaking stdout, the newline gets emitted on
......@@ -753,17 +768,37 @@ export function createTerminalWidgetHost(
753768 needsToRestoreCursor ||= needsToSaveCursor;
754769 needsToSaveCursor = false;
755770 lines = newWidgetLines;
756 buffer = "";
757771 }
758772
759773 /**
760 * update `partialWidth` after writing `text` to the output. rows written
761 * off-screen never displace the widget block, so they are not tracked.
774 * write every buffered chunk to its stream in order, returning the
775 * concatenation of the chunks that landed on the widget screen. error
776 * chunks flush through `writeInteractive` since the interactive stream is
777 * the error stream; plain output rows land on screen only when
778 * `outputSharesScreen`.
762779 */
763 function trackPartialWidth(text: string) {
764 if (!outputOnScreen) return;
765 const i = text.lastIndexOf("\n");
766 partialWidth = ansi.widthInTerminal(text.slice(i + 1))
780 function flushChunks(term: TerminalLock): string {
781 let screen = "";
782 for (const chunk of buffer) {
783 if (chunk.err) {
784 term.writeInteractive(chunk.text);
785 screen += chunk.text;
786 } else {
787 term.writeOutput(chunk.text);
788 if (outputOnScreen) screen += chunk.text;
789 }
790 }
791 buffer = [];
792 return screen;
793 }
794
795 /**
796 * update `partialWidth` after `screenText` (already filtered to the chunks
797 * that landed on the widget screen) was written.
798 */
799 function trackPartialWidth(screenText: string) {
800 const i = screenText.lastIndexOf("\n");
801 partialWidth = ansi.widthInTerminal(screenText.slice(i + 1))
767802 + (i === -1 ? partialWidth : 0);
768803 }
769804
......@@ -807,24 +842,35 @@ export function createTerminalWidgetHost(
807842 hasSyncStart = shortTermDrawLock;
808843 }
809844 if (buffer.length > 0) {
810 if (widgets.length === 0 && !terminal && writeOutputTemporaryLock) {
811 writeOutputTemporaryLock(buffer);
845 if (
846 widgets.length === 0 && !terminal && writeOutputTemporaryLock
847 && !buffer.some((c) => c.err)
848 ) {
849 const text = buffer.map((c) => c.text).join("");
850 buffer = [];
851 writeOutputTemporaryLock(text);
852 if (outputOnScreen) trackPartialWidth(text);
812853 } else {
813 acquireTerminal().writeOutput(buffer);
854 trackPartialWidth(flushChunks(acquireTerminal()));
814855 if (widgets.length === 0) closeTerminal();
815856 }
816 trackPartialWidth(buffer);
817 buffer = "";
818857 }
819858 }
820859
860 /** append a write to the flush buffer, scheduling the flush */
861 function bufferChunk(chunk: string, err: boolean) {
862 const last = buffer[buffer.length - 1];
863 if (last && last.err === err) last.text += chunk;
864 else buffer.push({ text: chunk, err });
865 redrawSoon(0);
866 }
867
821868 return {
822869 writeOutput(chunk) {
823 if (chunk) buffer += chunk, redrawSoon(0);
870 if (chunk) bufferChunk(chunk, false);
824871 },
825872 writeError(chunk) {
826 // TODO: write to stderr. when this was introduced it was not a regression from v3
827 if (chunk) buffer += chunk, redrawSoon(0);
873 if (chunk) bufferChunk(chunk, true);
828874 },
829875 getDrawLock(mode) {
830876 if (rendering) ASSERT(locks === 0);
......@@ -1375,11 +1421,15 @@ const globalLog = /* @__PURE__ */ (() =>
13751421 if (global.writeMessage) {
13761422 global.writeMessage(m);
13771423 } else if (node.process) {
1378 globalWidgetHost()[
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.
1382 ](formatAnsiMessage(m, node.process.stdout.isTTY));
1424 // info/debug levels land on stdout, warnings and errors on stderr;
1425 // colors are keyed off the destination stream
1426 const err = (m.level ?? "info") !== "info" && m.level !== "debug";
1427 globalWidgetHost()[err ? "writeError" : "writeOutput"](
1428 formatAnsiMessage(
1429 m,
1430 (err ? node.process.stderr : node.process.stdout).isTTY,
1431 ),
1432 );
13831433 } else {
13841434 let { level = "info", [originalLogArgs]: args = [m.text], scope } = m;
13851435 if (scope) {
lib/readme.changes.md+1
......@@ -17,6 +17,7 @@
1717
1818- `mime`'s database contains `.eot` for embedded opentype fonts.
1919- `async.deferred`
20- `log.writeError` writes to stderr with proper logic
2021
2122## v4
2223