From 204772bcc3b848cbbad08ceb4e2249229b3ecbeb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 18 Aug 2026 21:20:34 -0700 Subject: [PATCH] fix: show the dev tunnel URL in the TUI instead of painting over it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .changeset/dev-tunnel-visible-in-tui.md | 7 ++++ .../cli/src/commands/dashboard-tui/app.tsx | 8 +++++ .../cli/src/commands/dashboard-tui/state.ts | 8 +++++ packages/cli/src/commands/dashboard.ts | 34 ++++++++++++++++++- .../cli/src/commands/dev-source-restart.ts | 8 +++++ scripts/dev-with-memory.mjs | 33 +++++++++++++++++- 6 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 .changeset/dev-tunnel-visible-in-tui.md diff --git a/.changeset/dev-tunnel-visible-in-tui.md b/.changeset/dev-tunnel-visible-in-tui.md new file mode 100644 index 0000000000..1c1389f199 --- /dev/null +++ b/.changeset/dev-tunnel-visible-in-tui.md @@ -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. diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index 2513e1c3c8..dcdbfbab1d 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -396,6 +396,14 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b {info.authToken} )} + {/* 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 && ( + + Tunnel + {info.devTunnelUrl} + + )} {/* 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 diff --git a/packages/cli/src/commands/dashboard-tui/state.ts b/packages/cli/src/commands/dashboard-tui/state.ts index e26883be27..f602e5f2c1 100644 --- a/packages/cli/src/commands/dashboard-tui/state.ts +++ b/packages/cli/src/commands/dashboard-tui/state.ts @@ -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; diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 7560bdadbf..97532df60c 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -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, diff --git a/packages/cli/src/commands/dev-source-restart.ts b/packages/cli/src/commands/dev-source-restart.ts index 820cd47c81..7c54fa0995 100644 --- a/packages/cli/src/commands/dev-source-restart.ts +++ b/packages/cli/src/commands/dev-source-restart.ts @@ -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; } diff --git a/scripts/dev-with-memory.mjs b/scripts/dev-with-memory.mjs index 6e522a6761..6025c0de9e 100644 --- a/scripts/dev-with-memory.mjs +++ b/scripts/dev-with-memory.mjs @@ -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();