fix: show the dev tunnel URL in the TUI instead of painting over it

`pnpm dev --tunnel` printed its banner to stdout, and a TTY run then handed
the screen to DashboardTUI, which repainted over it. The public URL — the
entire output of the flag — was unreadable.

The wrapper forwards the URL to the dev child over the IPC channel it already
uses for the listening report, and the dashboard renders a Tunnel row in the
system panel beside URL and Token.

Capture is order-independent. cloudflared can publish before or after the TUI
is constructed, and an IPC message that arrives with no listener attached is
lost, so the URL is stored at run scope on arrival and applied by whichever
half completes second.

Watch-mode restarts reuse the tunnel (a fresh quick tunnel would hand out a
new hostname on every reload), but the new child knows nothing about it, so
the wrapper re-announces on each listening report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-18 21:20:34 -07:00
parent 2556083b68
commit 204772bcc3
6 changed files with 96 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: The dev tunnel URL is now visible in the dashboard TUI instead of being painted over.
category: fix
dev: `pnpm dev --tunnel` prints its banner to stdout, but a TTY run hands the screen to `DashboardTUI`, which repaints over it — so the public URL, the entire output of the flag, could not be read. The wrapper now forwards the URL to the dev child over the existing IPC channel (`DEV_TUNNEL_READY_MESSAGE`) and the dashboard renders it as a `Tunnel` row in the system panel beside URL and Token. Capture is order-independent: the URL is stored on arrival at run scope and applied whenever the TUI exists, because cloudflared can publish before or after the TUI is constructed and an IPC message with no listener attached is lost. Watch-mode restarts re-announce the existing tunnel to the new child, which would otherwise show no tunnel row after the first reload.

View File

@@ -396,6 +396,14 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b
<Text color="yellow">{info.authToken}</Text>
</Box>
)}
{/* FNXC:DevTunnel 2026-08-19-04:30: the wrapper's tunnel banner is painted over by this
TUI, so the public URL is shown here — full width, never truncated, like the token. */}
{info.devTunnelUrl && (
<Box flexDirection="row" gap={1} flexShrink={0}>
<Text dimColor>Tunnel</Text>
<Text color="cyanBright">{info.devTunnelUrl}</Text>
</Box>
)}
{/* Inline hint row — always shown so the [Enter] / [c] shortcuts
stay discoverable even when the panel doesn't currently own
keyboard focus (e.g. when the narrow-mode log strip below has

View File

@@ -18,6 +18,14 @@ export interface SystemInfo {
authEnabled: boolean;
authToken?: string;
tokenizedUrl?: string;
/*
FNXC:DevTunnel 2026-08-19-04:30:
Public URL of the dev tunnel, when `pnpm dev --tunnel` started one. The wrapper prints its banner
to stdout, but a TTY run hands the screen to this TUI, which paints straight over it — so the one
piece of information the flag exists to produce was unreadable. The wrapper forwards the URL over
the dev IPC channel and it renders here instead.
*/
devTunnelUrl?: string;
engineMode: "no-engine" | "active" | "paused";
fileWatcher: boolean;
startTimeMs: number;

View File

@@ -149,6 +149,7 @@ import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-star
import { phaseTime } from "../startup-phase.js";
import {
DEV_SERVER_LISTENING_MESSAGE,
DEV_TUNNEL_READY_MESSAGE,
DEV_SOURCE_RESTART_ARMED_MESSAGE,
registerDevSourceRestart,
} from "./dev-source-restart.js";
@@ -805,6 +806,15 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// stores it to localStorage for subsequent loads.
const dashboardAuthToken = await resolveDashboardAuthToken(opts);
/*
FNXC:DevTunnel 2026-08-19-04:30:
Set by the dev wrapper's IPC hand-off (see DEV_TUNNEL_READY_MESSAGE) and rendered by the TUI.
Declared at run scope because the two halves — receiving the URL and having a TUI to draw it on —
complete in either order.
*/
let devTunnelUrl: string | undefined;
let applyDevTunnelUrl: (() => void) | undefined;
// Single sink/logger pair for all dashboard command diagnostics.
// In TTY mode this routes to DashboardTUI; in non-TTY mode it falls back to console.*.
const logSink = new DashboardLogSink();
@@ -2973,6 +2983,21 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
*/
setLocalDashboardPort(actualPort);
/*
FNXC:DevTunnel 2026-08-19-04:30:
Capture the dev tunnel URL as soon as it can arrive, not when the TUI happens to exist. The
wrapper sends it once, whenever cloudflared publishes — which can land before or after the TUI
is constructed, and a message with no listener attached is simply lost. Store it here and let
whichever comes second do the rendering.
*/
process.on("message", (message: unknown) => {
const parsed = message as { type?: string; url?: string } | null;
if (parsed?.type !== DEV_TUNNEL_READY_MESSAGE) return;
if (typeof parsed.url !== "string" || parsed.url.length === 0) return;
devTunnelUrl = parsed.url;
applyDevTunnelUrl?.();
});
/*
FNXC:DevTunnel 2026-08-19-02:05: report the REAL port to the dev supervisor (no-op without an
IPC channel, i.e. every non-`pnpm dev` launch). See DEV_SERVER_LISTENING_MESSAGE.
@@ -3075,7 +3100,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
startTimeMs: dashboardStartedAt,
startupDurationMs,
};
tui.setSystemInfo(systemInfo);
/*
FNXC:DevTunnel 2026-08-19-04:30:
`pnpm dev --tunnel` prints its banner to stdout, which a TTY run hands to this TUI — the TUI
paints over it, so the public URL (the entire point of the flag) was unreadable. Render it in
the system panel instead, whether the URL arrived before this point or arrives later.
*/
applyDevTunnelUrl = () => tui.setSystemInfo({ ...systemInfo, devTunnelUrl });
tui.setSystemInfo({ ...systemInfo, devTunnelUrl });
tui.setReady(true);
tui.setSettings({
maxConcurrent: settings.maxConcurrent ?? 1,

View File

@@ -13,6 +13,14 @@ export const DEV_SOURCE_RESTART_ARMED_MESSAGE = "fusion:dev-source-restart-armed
*/
export const DEV_SERVER_LISTENING_MESSAGE = "fusion:dev-server-listening";
/**
* FNXC:DevTunnel 2026-08-19-04:30:
* Parent -> child: the public tunnel URL, once cloudflared has published one. The wrapper prints a
* banner, but a TTY dev run hands the screen to the dashboard TUI, which paints over it — so the
* URL the flag exists to produce could not be read. The dashboard shows it in its system panel.
*/
export const DEV_TUNNEL_READY_MESSAGE = "fusion:dev-tunnel-ready";
interface DevSourceChangeMessage {
type: typeof DEV_SOURCE_CHANGE_MESSAGE;
}

View File

@@ -168,6 +168,22 @@ async function openDevTunnel() {
// fallback for targets that never reported one (an explicit --tunnel=PORT).
const auth = resolveDevTunnelAuth({ port, dashboardPort, args: forwardedArgs, reportedToken: token });
devTunnel = await startDevTunnel({ port, auth });
/*
FNXC:DevTunnel 2026-08-19-04:30:
Hand the URL to the dev server so its TUI can display it. The banner above goes to stdout, which a
TTY run has already given to the dashboard TUI — it paints over the banner, leaving the public URL
(the whole point of --tunnel) unreadable. Mirrors DEV_TUNNEL_READY_MESSAGE in
packages/cli/src/commands/dev-source-restart.ts; the literal is duplicated because this wrapper is
plain JS that must not load the TS build.
*/
if (devTunnel?.url && appChild?.connected) {
try {
appChild.send({ type: "fusion:dev-tunnel-ready", url: devTunnel.url, port });
} catch {
// Display-only: a failed hand-off must never take the tunnel or the dev loop down.
}
}
}
function runApp(extraArgs) {
@@ -209,7 +225,22 @@ function runApp(extraArgs) {
watchRestart.attach(tsx);
tsx.on("message", (message) => {
const listening = readDevServerListening(message);
if (listening) reportDevServerListening(listening);
if (listening) {
reportDevServerListening(listening);
/*
FNXC:DevTunnel 2026-08-19-04:30:
Re-announce an existing tunnel to a RESTARTED dev server. Watch-mode restarts reuse the tunnel
(a fresh quick tunnel would hand out a new hostname every reload), but the new child starts
with no knowledge of it, so its TUI would show no tunnel row at all after the first restart.
*/
if (devTunnel?.url && tsx.connected) {
try {
tsx.send({ type: "fusion:dev-tunnel-ready", url: devTunnel.url, port: listening.port });
} catch {
// Display-only.
}
}
}
watchRestart.onMessage(message);
});
ensureSourceWatcher();