Skip to content

feat(plugin-webpack): render compiler output in the terminal with an ink multi-logger - #4389

Open
claude[bot] wants to merge 10 commits into
nextfrom
feat/console-multi-logger
Open

feat(plugin-webpack): render compiler output in the terminal with an ink multi-logger#4389
claude[bot] wants to merge 10 commits into
nextfrom
feat/console-multi-logger

Conversation

@claude

@claude claude Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Requested by Samuel Attard · Slack thread

Replaces @electron-forge/web-multi-logger with @electron-forge/multi-logger, an ink-based terminal UI owned by @electron-forge/core, and wires the webpack plugin to it.

Before: forge start prints "Output Available: http://localhost:9000" and you open a browser tab to read webpack output.

After: every interactive forge start shows 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), r to restart the app and q to quit. Non-TTY or CI falls back to [Tab]-prefixed plain lines.

How

  • packages/utils/multi-logger: same Logger/Tab API, express + xterm replaced by ink 7 + react.
  • core owns a process-wide shared logger for every start; plugins call ensureSharedLogger() and add tabs.
  • interactive: core spawns Electron with piped stdout/stderr and attaches them to the App tab before postStart runs. 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). loggerPort is removed from plugin-webpack (breaking, 8.0).

Open questions

  • jsx: react-jsx is set in the shared tsconfig.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

…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
@github-actions github-actions Bot added the next label Sep 12, 2026
@socket-security

socket-security Bot commented Sep 12, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​types/​react@​19.2.181001007994100
Addednpm/​ink-testing-library@​4.0.010010010082100
Addednpm/​react@​19.2.81001008497100
Addednpm/​ink@​7.1.19810010090100

View full report

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
@MarshallOfSound
MarshallOfSound marked this pull request as ready for review September 12, 2026 01:38
@MarshallOfSound
MarshallOfSound requested a review from a team as a code owner September 12, 2026 01:38

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread packages/plugin/webpack/src/WebpackPlugin.ts
Comment thread packages/api/core/src/api/start.ts
Comment thread packages/api/core/src/api/start.ts Outdated
Comment thread packages/utils/multi-logger/src/shared.ts
…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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread docs/cli.md
Comment thread packages/api/core/src/api/start.ts
…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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/utils/multi-logger/src/Logger.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant