feat(plugin-webpack): render compiler output in the terminal with an ink multi-logger - #4389
feat(plugin-webpack): render compiler output in the terminal with an ink multi-logger#4389claude[bot] wants to merge 10 commits into
Conversation
…gger Replace the express + xterm.js web UI with an ink-based terminal renderer. Tabs, statuses and child-process output render right in the console when attached to a TTY, with a tag-prefixed plain fallback for CI and pipes. Everything logged before start() is buffered, and stop() flushes it as plain text if the UI never rendered. Enables "jsx": "react-jsx" in the shared tsconfig so the package can use JSX, and teaches vitest/oxlint/knip about .tsx sources. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…m it Spawn Electron with stdout/stderr piped instead of inherited. A postStart hook that starts reading a stream claims it; anything still unclaimed once the hooks have run is forwarded to Forge's own stdio, so behaviour is unchanged unless a plugin opts in. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
Swap @electron-forge/web-multi-logger for @electron-forge/multi-logger. Compiler output is buffered during preStart and rendered from postStart, once listr is done, with the Electron app's own output attached as a tab (reused across restarts). Tabs report building/success/warning/error status with counts and durations, and `r` restarts Electron since ink's raw mode hides the `rs` line from core. `loggerPort` is now deprecated and ignored. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
BREAKING CHANGE: loggerPort no longer exists; compiler output renders in the terminal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…e raw mode after restart
The tab bar and footer are `height={1}` Boxes with ink's default
`flexShrink: 1`. The body was sized in logical lines, so once lines wrapped
(long webpack output, or the `[Tab] ` prefixes of the merged view) the body
overflowed the fixed-height root and Yoga shrank the header to nothing,
leaving a blank first row and desyncing ink's line diff. The chrome now has
`flexShrink={0}` and the body keeps only the tail that fits in its screen
rows, measured with the same `wrap-ansi` settings ink uses.
The tab bar also relied on flex clipping, which cut the last chip mid-word
and hid `All` entirely at 120 columns. It is now measured with
`string-width` and degrades in steps: status details go first, then the
title, and finally the chips wrap onto more rows (the body shrinks to
match), so every chip, `All` included, is always visible.
Also:
- rename the Electron tab to `App` (and make it the default name for
`attachProcess`); add `initialTab` so the host can pick the first view
(a tab name or `'all'`);
- switch to a tab whose status turns to `error`, at most once per
`errorSwitchDebounceMs` (default 15s), never re-triggering for a tab
that stays in error;
- put stdin back into raw mode when an attached child exits: a child that
inherited the tty (Electron is a Node embedder) restores cooked mode on
exit while `stdin.isRaw` still reads `true`, so ink never re-asserted it
and keys were echoed instead of handled after a restart;
- do not colour the status glyph on the active (inverse) chip, where the
colour became a background cell.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
A terminal can only show one UI, so a host and the plugins running inside it
need to share a logger rather than each creating their own. `ensureSharedLogger`
returns the process-wide instance (creating it on first use), `getSharedLogger`
peeks at it and `resetSharedLogger` forgets it for tests. The instance lives
under `Symbol.for('@electron-forge/multi-logger')` on `globalThis`, so duplicate
copies of the package in one node_modules tree still share it. Options passed to
a later `ensureSharedLogger` call are merged additively through the new
`Logger#extendOptions`: extra keys, and a title / initial tab if none is set.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
`@electron-forge/core` now owns the tabbed terminal UI: every interactive `forge start` creates the process-wide shared logger before any hook runs, attaches the Electron app's stdout/stderr to its "App" tab (the default view) on every spawn and restart, and starts it once the app is up. Plugins only add tabs. While the UI is drawing, `r` restarts the app instead of typing `rs`, the "Restarting Electron app" notice goes into the App tab rather than over the alternate screen, and the UI is torn down when the app exits for good. Non-interactive (programmatic) starts are unchanged: no UI is created and the app's output is forwarded to the parent's stdio as before. BREAKING CHANGE: plugins must add tabs to the shared logger instead of creating their own; the app's stdio is attached to the App tab in interactive mode. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…s non-interactive `start()` now owns the shared logger for every run, not only interactive ones. Plugins add tabs unconditionally (webpack does so from its preStart hook), and previously a non-interactive start (`process.stdin.isTTY` false, as in CI, Docker or piped input, or a programmatic `api.start()`) never started the logger, so the plugins' compiler output and watch-mode build errors were buffered and silently dropped. Core now creates the logger with `interactive: false` in that case, which forces plain mode, starts it once the app is up so tabs are printed as `[Tab]`-prefixed lines, and stops it when the app exits. A project without plugins prints nothing: an empty plain logger is silent. The app's stdio is only piped when there is an App tab to show it in (interactive). Non-interactive starts spawn Electron with `stdio: 'inherit'` again, exactly as before this branch, so the app keeps the real terminal fds (`isTTY`, window size) and no output can be lost in a Node-level pipe. The `forwardUnclaimedStream` helper that piped the streams back to our stdio is gone with it. Two follow-ups from review: - `ensureSharedLogger()` replaces a singleton that has been stopped, so a second `api.start()` in the same process gets a live logger rather than one whose `start()` no-ops. - `Logger.mode` now reports the renderer actually in use: it switches to `'plain'` when the interactive UI fails to load. `start()` decides whether to read `rs` from stdin after `logger.start()` has resolved, so the app can still be restarted from the keyboard after such a fallback. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
…iped stdio A plugin's `startLogic` may hand back a child it spawned with inherited stdio. Attaching it to the App tab was a no-op (both streams null) while the terminal UI's alternate screen hid the app's real output. Core now detects that case, skips attaching and forces the shared logger into plain mode (new `Logger.forcePlain()`), so plugin tabs still print as `[Tab]` lines and the app keeps the terminal. Also stop claiming the Vite Plugin adds tabs to the terminal UI; only the Webpack Plugin is wired to the shared logger in this change. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding on the App-tab restart race, I also checked two related paths and ruled them out: the non-restart spawned.on('exit') handler calling logger.stop() unconditionally (packages/api/core/src/api/start.ts:358) is intentional — it matches the documented "app is gone for good" lifecycle rather than a bug — and the merged "All" view's line cap being shared across all tabs combined (packages/utils/multi-logger/src/Logger.ts:164) is a cosmetic scrollback limit, not a correctness issue, since each Tab still retains its own full maxLines history independently.
Extended reasoning...
This run surfaced one new confirmed finding (the attachProcess/restart race in Logger.ts) that was not part of my two prior reviews on this PR, so a brief note is warranted rather than staying fully silent. I additionally examined the pipe()/postStart ordering, the unconditional logger.stop() on non-restart exit, and the shared merged-view line cap; the latter two were ruled out as intentional/cosmetic behavior rather than bugs after reading the surrounding code in start.ts and Logger.ts. Since a confirmed finding already exists on this PR, this message stays limited to the brief ruled-out note per the review guidelines rather than a full approve/defer verdict, and does not restate or re-litigate the inline findings themselves.
Requested by Samuel Attard · Slack thread
Replaces
@electron-forge/web-multi-loggerwith@electron-forge/multi-logger, an ink-based terminal UI owned by@electron-forge/core, and wires the webpack plugin to it.Before:
forge startprints "Output Available: http://localhost:9000" and you open a browser tab to read webpack output.After: every interactive
forge startshows the UI, with the App tab (the Electron app's own output) as the default view; bundler plugins add compiler tabs (Main / Renderer / Preload for webpack) with live build status, scrollback, a merged view (a),rto restart the app andqto quit. Non-TTY orCIfalls back to[Tab]-prefixed plain lines.How
packages/utils/multi-logger: sameLogger/TabAPI, express + xterm replaced by ink 7 + react.ensureSharedLogger()and add tabs.postStartruns. Non-interactive (programmatic, or stdin not a TTY): Electron inherits Forge's stdio exactly as before, and the plugins' tabs are printed as[Tab]-prefixed plain lines (nothing at all when there are no plugins).loggerPortis removed from plugin-webpack (breaking, 8.0).Open questions
jsx: react-jsxis set in the sharedtsconfig.base.json(the generator has no per-package overrides).Screenshots and video are in the Slack thread.
🤖 Generated with Claude Code
https://claude.ai/code/session_017gBeqc5gc2rBabA5Xs4BmJ