feat(remote): real QR codes, live tailscale URL, TUI status & shortcut

* Replace placeholder /remote/qr SVG (URL drawn as text) with real QR
  rendered via the qrcode package; add format=terminal returning ASCII
  QR for the TUI.
* Resolve the public tailscale funnel URL from captured CLI output
  instead of constructing http://<hostname>:<port> from a configured
  hostname label — that label was never used by `tailscale funnel` and
  produced a non-public URL in the auth/QR link.
* Drop hostname requirement from engine + UI; only target port matters.
* Tighten tailscale parseReadiness to require a URL on the matched line
  so the tunnel manager doesn't lock in `running` before the URL line.
* TUI: poll remote status, show ● tunnel indicator + URL in MainHeader,
  bind Ctrl+Q to a global QR overlay (terminal ASCII), and switch the
  in-Settings K shortcut to render the same ASCII QR.
* Auto-poll remote status in the dashboard while in `starting`/`stopping`
  so the UI flips to running without reopening the modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-29 13:20:03 -07:00
parent 72ae0b46bf
commit 8bbd956fa5
9 changed files with 178 additions and 31 deletions

View File

@@ -479,7 +479,6 @@ describe("Settings view", () => {
await waitForFrameContains(lastFrame, "Short-lived expires:", 6000);
stdin.write("K");
await waitForFrameContains(lastFrame, "QR text payload:", 6000);
await waitForFrameContains(lastFrame, "ASCII-QR-PAYLOAD", 6000);
unmount();
});
@@ -541,7 +540,7 @@ describe("Settings view", () => {
unmount();
});
it("renders SVG QR fallback instruction", async () => {
it("renders ASCII QR payload when terminal format is returned", async () => {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setInteractiveData(makeInteractiveData({
@@ -549,8 +548,8 @@ describe("Settings view", () => {
getQrPayload: async () => ({
url: "https://remote.example.com?token=svg",
expiresAt: new Date().toISOString(),
format: "image/svg",
data: "<svg/>",
format: "terminal",
data: "▀▀▀ASCII-QR▀▀▀",
}),
},
}));
@@ -561,7 +560,7 @@ describe("Settings view", () => {
stdin.write("\u001B[C");
await new Promise((r) => setTimeout(r, 20));
stdin.write("K");
await waitForFrameContains(lastFrame, "QR SVG returned by server.");
await waitForFrameContains(lastFrame, "▀▀▀ASCII-QR▀▀▀");
unmount();
});
});

View File

@@ -959,6 +959,20 @@ function MainHeader({ state }: { state: DashboardState }) {
);
})}
<Box flexGrow={1} />
{state.remoteStatus?.state === "running" && (
<Box flexShrink={0} marginRight={1}>
<Text wrap="truncate-end" color="green" bold> tunnel</Text>
{state.remoteStatus.url && cols >= 100 && (
<Text wrap="truncate-end" color="green"> {state.remoteStatus.url}</Text>
)}
{cols >= 80 && <Text wrap="truncate-end" dimColor> [^Q] QR</Text>}
</Box>
)}
{state.remoteStatus?.state === "starting" && (
<Box flexShrink={0} marginRight={1}>
<Text wrap="truncate-end" color="yellow"> tunnel starting</Text>
</Box>
)}
{showHelpHint && <Box flexShrink={0}><Text wrap="truncate-end" dimColor>[?] help [q] quit</Text></Box>}
</Box>
);
@@ -2308,16 +2322,11 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
async function handleFetchRemoteQr(tokenType: "persistent" | "short-lived", ttlMs?: number) {
if (!data?.remote) return;
const result = await data.remote.getQrPayload(tokenType, ttlMs);
const result = await data.remote.getQrPayload(tokenType, ttlMs, "terminal");
setRemoteUrl(result.url);
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : tokenType);
if (result.format === "text") {
setRemoteQrDisplay(result.data ?? result.url);
setRemoteQrFallback(null);
return;
}
setRemoteQrDisplay(null);
setRemoteQrFallback("QR SVG returned by server. Open the authenticated URL on your phone/browser to continue.");
setRemoteQrDisplay(result.data ?? result.url);
setRemoteQrFallback(null);
}
useInput((input, key) => {
@@ -2628,7 +2637,11 @@ function SettingsInteractiveView({ state, controller }: { state: DashboardState;
<Text dimColor wrap="truncate-end">Short-lived expires: {new Date(shortLivedExpiresAt).toLocaleString()}</Text>
)}
{remoteQrDisplay && (
<Text wrap="truncate-end">QR text payload: {remoteQrDisplay}</Text>
<Box flexDirection="column">
{remoteQrDisplay.split("\n").map((line, idx) => (
<Text key={idx}>{line}</Text>
))}
</Box>
)}
{remoteQrFallback && (
<Text color="yellow" wrap="truncate-end">{remoteQrFallback}</Text>
@@ -3881,19 +3894,63 @@ export function DashboardApp({ controller }: DashboardAppProps) {
useCallback(() => controller.getSnapshot(), [controller]),
);
// Global QR overlay state — populated when the user hits Ctrl+Q on a
// running tunnel. `loading` covers the network request; `error` surfaces
// the message inline so the overlay never sits blank.
const [qrOverlay, setQrOverlay] = useState<
| { state: "loading" }
| { state: "ready"; url: string; ascii: string; tokenType: string; expiresAt: string | null }
| { state: "error"; message: string }
| null
>(null);
// Global key handling
useInput((input, key) => {
// Quit — route through SIGINT so the dashboard's shutdown handler runs
// (stops dev-server child process groups, engines, mesh, etc.). Calling
// process.exit(0) directly here orphans node/vitest children spawned by
// user-project dev servers.
if (input === "q" || input === "Q" || (key.ctrl && input === "c")) {
if (((input === "q" || input === "Q") && !key.ctrl) || (key.ctrl && input === "c")) {
void controller.stop();
exit();
process.kill(process.pid, "SIGINT");
return;
}
// QR overlay open/close — Ctrl+Q toggles, Esc closes when open.
if (qrOverlay && key.escape) {
setQrOverlay(null);
return;
}
if (key.ctrl && input === "q") {
if (qrOverlay) {
setQrOverlay(null);
return;
}
const remote = state.interactiveData?.remote;
if (!remote) return;
if (state.remoteStatus?.state !== "running") {
setQrOverlay({ state: "error", message: "No remote tunnel is running. Start one in Settings (g)." });
return;
}
setQrOverlay({ state: "loading" });
void remote
.getQrPayload("persistent", undefined, "terminal")
.then((payload) => {
setQrOverlay({
state: "ready",
url: payload.url,
ascii: payload.data ?? "",
tokenType: "persistent",
expiresAt: payload.expiresAt,
});
})
.catch((err: unknown) => {
setQrOverlay({ state: "error", message: err instanceof Error ? err.message : String(err) });
});
return;
}
if (state.mode === "interactive" && state.interactiveInputLocked) {
return;
}
@@ -4151,6 +4208,26 @@ export function DashboardApp({ controller }: DashboardAppProps) {
<HelpOverlay />
</Box>
)}
{qrOverlay && (
<Box position="absolute" marginTop={2} marginLeft={2} flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
<Text bold color="cyanBright">Remote Access Scan to connect</Text>
{qrOverlay.state === "loading" && <Text dimColor>Generating QR</Text>}
{qrOverlay.state === "error" && <Text color="red">{qrOverlay.message}</Text>}
{qrOverlay.state === "ready" && (
<>
{qrOverlay.ascii.split("\n").map((line, idx) => (
<Text key={idx}>{line}</Text>
))}
<Text wrap="truncate-end">{qrOverlay.url}</Text>
<Text dimColor>
{qrOverlay.tokenType}
{qrOverlay.expiresAt ? ` · expires ${new Date(qrOverlay.expiresAt).toLocaleString()}` : ""}
</Text>
</>
)}
<Text dimColor>[Esc] close</Text>
</Box>
)}
</Box>
);
}

View File

@@ -43,6 +43,7 @@ import type {
DashboardState,
InteractiveData,
InteractiveView,
RemoteStatus,
UpdateStatus,
} from "./state.js";
import { SECTION_ORDER } from "./state.js";
@@ -129,6 +130,11 @@ export class DashboardTUI {
private lastCpuUsage: NodeJS.CpuUsage | null = null;
private lastCpuSampleAt = 0;
// Polled remote tunnel status; null until first successful fetch (or when
// no remote API is wired up).
private remoteStatus: RemoteStatus | null = null;
private remoteStatusTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
this.logBuffer = new LogRingBuffer();
}
@@ -165,6 +171,7 @@ export class DashboardTUI {
vitestKillThreshold: this.vitestKillThreshold,
updateStatus: this.updateStatus,
clipboardFlash: this.clipboardFlash,
remoteStatus: this.remoteStatus,
};
return this.cachedSnapshot;
}
@@ -365,6 +372,25 @@ export class DashboardTUI {
setInteractiveData(data: InteractiveData): void {
this.interactiveData = data;
this.notify();
this.startRemoteStatusPolling();
}
private startRemoteStatusPolling(): void {
if (this.remoteStatusTimer) return;
const tick = async () => {
const remote = this.interactiveData?.remote;
if (!remote) return;
try {
const status = await remote.getStatus();
const changed = JSON.stringify(this.remoteStatus) !== JSON.stringify(status);
this.remoteStatus = status;
if (changed) this.notify();
} catch {
// network/auth errors are non-fatal — leave the prior value alone
}
};
void tick();
this.remoteStatusTimer = setInterval(() => { void tick(); }, 3000);
}
setInteractiveView(view: InteractiveView): void {
@@ -719,6 +745,11 @@ export class DashboardTUI {
this.systemStatsTimer = null;
}
if (this.remoteStatusTimer) {
clearInterval(this.remoteStatusTimer);
this.remoteStatusTimer = null;
}
if (this.resizeListener && process.stdout && typeof process.stdout.off === "function") {
process.stdout.off("resize", this.resizeListener);
this.resizeListener = null;

View File

@@ -70,7 +70,7 @@ export interface RemoteTokenResult {
export interface RemoteQrPayload {
url: string;
expiresAt: string | null;
format: "text" | "image/svg";
format: "text" | "image/svg" | "terminal";
data?: string;
}
@@ -290,7 +290,7 @@ export interface InteractiveData {
regeneratePersistentToken: () => Promise<RemoteTokenResult>;
generateShortLivedToken: (ttlMs: number) => Promise<RemoteTokenResult>;
getRemoteUrl: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number) => Promise<RemoteQrPayload>;
getQrPayload: (tokenType: "persistent" | "short-lived", ttlMs?: number, format?: "text" | "terminal" | "image/svg") => Promise<RemoteQrPayload>;
};
git: {
getStatus: (projectPath: string) => Promise<GitStatus>;
@@ -354,6 +354,10 @@ export interface DashboardState {
// monotonic timestamp so the view can render "Copied!" briefly before the
// controller clears it via setTimeout.
clipboardFlash: { ok: boolean; at: number } | null;
// Latest remote tunnel status, polled by the controller while
// `interactiveData.remote` is available. Used to surface tunnel state
// (state/url) globally in the TUI header.
remoteStatus: RemoteStatus | null;
}
export const SECTION_ORDER: SectionId[] = ["system", "logs", "utilities", "stats", "settings"];
@@ -382,5 +386,6 @@ export function createInitialState(): DashboardState {
vitestKillThreshold: 0.9,
updateStatus: null,
clipboardFlash: null,
remoteStatus: null,
};
}

View File

@@ -2205,9 +2205,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}
return await response.json();
},
getQrPayload: async (tokenType: "persistent" | "short-lived", ttlMs?: number) => {
getQrPayload: async (tokenType: "persistent" | "short-lived", ttlMs?: number, format?: "text" | "terminal" | "image/svg") => {
const params = new URLSearchParams({ tokenType });
if (typeof ttlMs === "number") params.set("ttlMs", String(ttlMs));
if (format) params.set("format", format);
const response = await fetch(`${baseUrl}/api/remote/qr?${params.toString()}`, { headers: buildAuthHeaders() });
if (!response.ok) {
const payload = await response.json().catch(() => null);