/** * PC terminal UI (zcode-proxy's default mode) — an in-process control panel * mirroring the Android app's three-card layout: Settings & Login / Proxy * Server / Logs. * * The TUI owns the terminal (alternate screen + raw mode) and, like the * Android entry, intercepts console.log/warn/error into a ring buffer so the * per-request log rows land in the Logs card. Zero new dependencies: the * renderer, input parser and width math are hand-rolled ANSI/VT (Bun- and * Node-compatible, bundles into both release artifacts). * * index.ts dispatches here via dynamic import (this module imports helpers * back from index.js), mirroring the claimCommand pattern. */ import { loadConfig } from "../config/loader.js"; import { updateConfigYaml, ensureConfigFile } from "../config/edit.js"; import { AuthManager } from "../auth/manager.js"; import { startServer, type ProxyServer } from "../server/server.js"; import { buildServerOptions } from "../server/server-options.js"; import { loadCredential, saveCredential, clearCredential } from "../auth/store.js"; import { ZaiOAuthClient, BigmodelOAuthClient, LOGIN_TIMEOUT_MS, parsePastedCallbackUrl, type OAuthFlowClient, type OAuthFlowStart, type OAuthFlowTokens } from "../auth/oauth.js"; import { KeyResolver } from "../auth/resolver.js"; import { openBrowser } from "../runtime/open-browser.js"; import { pasteLoginInstructions, readPastedLine, boldIfTTY } from "../runtime/paste-login.js"; import { isGuestOriginError, describeGuestError } from "../runtime/guest-error.js"; import { ensureDeviceMidInConfig, VERSION, type ServeArgs } from "../index.js"; import { appendFileSync } from "node:fs"; import type { ProxyConfig } from "../config/types.js"; import type { ProviderId } from "../provider/types.js"; import { LogPane, type LogLevel } from "./log-pane.js"; import { KeyParser, type KeyAction } from "./keys.js"; import { buildFrame, findRegion, type ClickAction, type ClickRegion, type Frame } from "./frame.js"; type PlanTier = "coding-plan" | "start-plan"; type ServerStatus = "stopped" | "starting" | "running" | "error"; const RENDER_MS = 33; const PAGE_LINES = 10; const TOAST_MS = 2600; export async function runTui(args: ServeArgs): Promise { if (!process.stdout.isTTY || !process.stdin.isTTY) { process.stderr.write( "zcode-proxy: TUI mode needs an interactive terminal (TTY). " + "For headless/CLI use run: zcode-proxy --cli serve\n", ); process.exit(1); } const path = args.configPath ?? process.env.ZCODE_PROXY_CONFIG ?? "config.yaml"; let config: ProxyConfig; try { if (ensureConfigFile(path)) { ensureDeviceMidInConfig(path); process.stderr.write(`Created ${path} from bundled template.\n`); } config = loadConfig(path); } catch (err) { process.stderr.write(`zcode-proxy: config error: ${(err as Error).message}\n`); process.exit(1); } const auth = new AuthManager(); const pane = new LogPane(2000); const serverRef: { current: ProxyServer | null } = { current: null }; const state = { provider: config.provider as ProviderId, plan: config.plan as PlanTier, loggedIn: false, apiKeyPreview: "", serverStatus: "stopped" as ServerStatus, serverUrl: "", serverError: "", loginInFlight: false, loginHint: "", toast: null as { text: string; kind: "ok" | "err" | "info" } | null, }; // --- terminal setup ------------------------------------------------------ const stdout = process.stdout; const stdin = process.stdin; // Captured before any interception: the TUI's own frame/setup writes and // the post-cleanup error path must always reach the real terminal, even // after process.stdout/stderr.write are rerouted into the log pane below. const realStdoutWrite = stdout.write.bind(stdout) as (...a: unknown[]) => boolean; const realStderrWrite = process.stderr.write.bind(process.stderr) as (...a: unknown[]) => boolean; // Raw-mode availability check BEFORE any terminal state is touched: the // failure message must reach the real terminal, not the (not yet visible) // log pane, and nothing needs restoring if we never entered the alt screen. if (typeof (stdin as { setRawMode?: (m: boolean) => void }).setRawMode !== "function") { realStderrWrite("zcode-proxy: tui requires a terminal with raw-mode input.\n"); process.exit(1); } // Alt screen + hide cursor + SGR mouse tracking (buttons clickable, wheel scrolls). const enterAltScreen = (): void => { realStdoutWrite("\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h\x1b[2J"); }; enterAltScreen(); const restore = (): void => { try { realStdoutWrite("\x1b[0m\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l"); } catch { /* terminal gone */ } }; // --- console interception → log pane (Android-entry pattern) ------------ // ALL console methods are intercepted, not just log/warn/error: guest // scripts (the captcha SDK) probe the full console surface, and anything // left unpatched would bypass the pane and write raw into the alt-screen // frame. Originals are kept for the pre-TUI error path and restore. const origConsole: Record void> = {}; const origError = console.error; const logFile = process.env.ZCODE_TUI_LOGFILE; const emit = (text: string, level: "info" | "warn" | "error"): void => { pane.push(text, level); if (logFile) { try { appendFileSync(logFile, text + "\n", "utf-8"); } catch { /* best-effort tee */ } } scheduleRender(); }; const consoleOwner = console as unknown as Record; for (const key of Object.keys(console)) { const fn = consoleOwner[key]; if (typeof fn !== "function") continue; origConsole[key] = fn as (...a: unknown[]) => void; consoleOwner[key] = (...a: unknown[]) => { const level = key === "error" ? "error" : key === "warn" ? "warn" : "info"; const prefix = key === "error" ? "[error] " : key === "warn" ? "[warn] " : ""; emit(prefix + a.map(String).join(" "), level); }; } const restoreConsole = (): void => { for (const [key, fn] of Object.entries(origConsole)) consoleOwner[key] = fn; }; const restoreStdio = (): void => { process.stdout.write = realStdoutWrite as typeof process.stdout.write; process.stderr.write = realStderrWrite as typeof process.stderr.write; }; // Reroute raw process.stdout/stderr writers into the log pane. The codebase // has ~30 direct `process.stderr.write` call sites (captcha diagnostics, // runtime notices) that bypass the console interception above — on the alt // screen their text + newlines land mid-frame, garbling the rendered UI and // the click regions with it. The TUI's own writes go through the captured // originals, never through these overrides. const routeToPane = (level: LogLevel) => (chunk: unknown, encOrCb?: unknown, maybeCb?: unknown): boolean => { const text = typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk instanceof Uint8Array ? new TextDecoder().decode(chunk) : String(chunk); try { emit(text, level); } catch { /* pane failure must not break the writer */ } const cb = typeof encOrCb === "function" ? encOrCb : typeof maybeCb === "function" ? maybeCb : undefined; if (cb) (cb as () => void)(); return true; }; process.stdout.write = routeToPane("info") as typeof process.stdout.write; process.stderr.write = routeToPane("warn") as typeof process.stderr.write; // --- rendering ----------------------------------------------------------- let renderTimer: ReturnType | null = null; let lastRenderAt = 0; let activeRegions: ClickRegion[] = []; // Paste login (L key) hands the terminal back for a moment: renders and // key handling are frozen while the real screen shows the paste prompt. let rendersSuspended = false; let inputSuspended = false; function renderNow(): void { if (rendersSuspended) return; // paste prompt owns the terminal if (renderTimer) clearTimeout(renderTimer); // watchdog may fire mid-cycle renderTimer = null; lastRenderAt = Date.now(); let frame: Frame; try { const width = stdout.columns || 80; const height = stdout.rows || 24; const view = pane.view(Math.max(4, height - 14)); frame = buildFrame({ version: VERSION, configPath: path, provider: state.provider, plan: state.plan, loggedIn: state.loggedIn, apiKeyPreview: state.apiKeyPreview, loginInFlight: state.loginInFlight, loginHint: state.loginHint, serverStatus: state.serverStatus, serverUrl: state.serverUrl, serverError: state.serverError, modelCount: config.models.length, responsesEnabled: config.responses.enabled, claimAuto: config.claim.enabled && config.claim.auto, logTotal: view.total, logView: view.lines, logFollowing: pane.following, logFromBottom: view.fromBottom, toast: state.toast, width, height, }); } catch (err) { // A broken frame must never escape: through the uncaughtException // handler it would tear down the whole TUI. Record it WITHOUT // rescheduling (pane.push, not emit) — emit's scheduleRender would turn // a deterministic buildFrame failure into a ~30 Hz render-error loop. // The watchdog retries at its own 2-4s cadence instead. try { pane.push(`frame render failed: ${(err as Error).message}`, "error"); } catch { /* ignore */ } return; } activeRegions = frame.regions; try { realStdoutWrite("\x1b[H" + frame.text); } catch { /* terminal gone */ } } function scheduleRender(): void { if (renderTimer) return; renderTimer = setTimeout(renderNow, RENDER_MS); if (typeof renderTimer.unref === "function") renderTimer.unref(); } let toastTimer: ReturnType | null = null; function setToast(text: string, kind: "ok" | "err" | "info"): void { state.toast = { text, kind }; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { state.toast = null; toastTimer = null; scheduleRender(); }, TOAST_MS); if (typeof toastTimer.unref === "function") toastTimer.unref(); scheduleRender(); } // --- auth ---------------------------------------------------------------- async function refreshAuth(): Promise { const cred = await loadCredential().catch(() => null); state.loggedIn = cred != null; state.apiKeyPreview = cred ? `${cred.apiKey.slice(0, 8)}…` : ""; scheduleRender(); } // --- proxy lifecycle (mirrors the Android startProxy/stopProxy hooks) ---- async function startProxy(): Promise { if (state.serverStatus === "running" || state.serverStatus === "starting") return; state.serverStatus = "starting"; state.serverError = ""; scheduleRender(); const cred = await loadCredential().catch(() => null); if (!cred) { state.serverStatus = "stopped"; setToast("not logged in — press l to login", "err"); return; } auth.setOAuthCredential(cred); try { const s = await startServer(buildServerOptions(config, auth, args.debug)); serverRef.current = s; state.serverStatus = "running"; state.serverUrl = `http://${s.hostname}:${s.port}`; setToast(`proxy started on ${state.serverUrl}`, "ok"); startBackgroundJobsOnce(); } catch (err) { state.serverStatus = "error"; state.serverError = (err as Error).message; setToast(`start failed: ${(err as Error).message}`, "err"); } } function stopProxy(): void { const s = serverRef.current; if (!s) return; try { s.stop(false); // NOT stop(true) — must not kill the process (anti-pattern #19) } catch (err) { setToast(`stop failed: ${(err as Error).message}`, "err"); return; } serverRef.current = null; state.serverStatus = "stopped"; state.serverUrl = ""; setToast("proxy stopped", "info"); } function toggleProxy(): void { if (state.serverStatus === "running" || state.serverStatus === "starting") stopProxy(); else void startProxy(); } // Captcha warmup + claim scheduler start once per process, even across // proxy stop/start cycles — the pools and scheduler are process-global. let backgroundJobsStarted = false; function startBackgroundJobsOnce(): void { if (backgroundJobsStarted) return; backgroundJobsStarted = true; if (config.plan === "start-plan") { import("../proxy/captcha.js") .then((m) => m.startCaptchaPool(config.identity.appVersion)) .catch((err) => console.error(`[captcha] pool warmup failed: ${(err as Error).message}`)); } if (config.claim.enabled && config.claim.auto) { import("../claim/runtime.js") .then((m) => { m.startAutoClaim(config, auth); console.log(`[claim] auto ON (poll ${Math.round(config.claim.pollIntervalMs / 1000)}s)`); }) .catch((err) => console.error(`[claim] scheduler failed to start: ${(err as Error).message}`)); } } // --- provider / plan switching (mirrors the Android setConfig command) --- function switchProvider(): void { const next: ProviderId = state.provider === "zai" ? "bigmodel" : "zai"; applyConfigChange(next, state.plan, `provider → ${next}`); } function switchPlan(): void { const next: PlanTier = state.plan === "coding-plan" ? "start-plan" : "coding-plan"; applyConfigChange(state.provider, next, `plan → ${next}`); } function applyConfigChange(provider: ProviderId, plan: PlanTier, message: string): void { if (state.serverStatus === "running" || state.serverStatus === "starting") { setToast("stop the proxy before switching (press s)", "err"); return; } config.provider = provider; config.plan = plan; try { updateConfigYaml(path, { provider, plan }); state.provider = provider; state.plan = plan; setToast(message, "ok"); console.log(`config updated: provider=${provider} plan=${plan}`); } catch (err) { setToast(`config update failed: ${(err as Error).message}`, "err"); } scheduleRender(); } // --- login / logout (mirrors the control-listener startOAuth/logout) ----- let activeOauth: { client: OAuthFlowClient; provider: ProviderId } | null = null; /** Set while a bigmodel login waits on the browser callback; `L` invokes it to switch to paste mode. */ let pasteSwitchBigmodel: (() => void) | null = null; async function startLogin(opts: { paste?: boolean } = {}): Promise { if (state.loginInFlight) { setToast("login already in progress", "info"); return; } const provider = state.provider; if (opts.paste && provider !== "bigmodel") { setToast("paste login is bigmodel-only — zai login already works headless (press l)", "info"); return; } if (provider === "bigmodel") { const client = new BigmodelOAuthClient(); let started: Awaited>; try { started = await client.start(); } catch (err) { void client.close().catch(() => {}); setToast(`login failed: ${(err as Error).message}`, "err"); return; } activeOauth = { client, provider }; state.loginInFlight = true; console.log(`OAuth: opening ${started.authorizeUrl}`); console.log("If the browser did not open, copy the URL above into a browser."); openBrowser(started.authorizeUrl); if (opts.paste) { state.loginHint = "paste the callback URL in the terminal…"; scheduleRender(); settleLogin(runPasteLoginInTui(client, started), client, provider); } else { console.log( "▸ HEADLESS (Docker/VPS)? The callback page will NOT load here — " + "PRESS L to paste the redirected URL instead.", ); state.loginHint = "waiting for callback · HEADLESS? PRESS L to paste URL"; scheduleRender(); settleLogin(waitForCallbackOrPaste(client, started), client, provider); } return; } const client: OAuthFlowClient = new ZaiOAuthClient(); let started: Awaited>; try { started = await client.start(); } catch (err) { void client.close().catch(() => {}); setToast(`login failed: ${(err as Error).message}`, "err"); return; } activeOauth = { client, provider }; state.loginInFlight = true; state.loginHint = "waiting for browser authorization…"; console.log(`OAuth: opening ${started.authorizeUrl}`); console.log("If the browser did not open, copy the URL above into a browser."); openBrowser(started.authorizeUrl); scheduleRender(); settleLogin(client.complete(started), client, provider); } /** Sentinel the `L` key resolves to switch a pending login over to paste mode. */ const PASTE_MODE = Symbol("paste-mode"); /** * Wait for the browser callback — but let `L` switch a pending login to * paste mode (a headless machine never receives the callback). Exactly one * branch wins; the loser's eventual timeout rejection is swallowed by the * race, and the client is closed exactly once in settleLogin. */ async function waitForCallbackOrPaste( client: BigmodelOAuthClient, started: OAuthFlowStart, ): Promise { let requestPaste: () => void = () => {}; const pasteRequested = new Promise((resolve) => { requestPaste = () => resolve(PASTE_MODE); }); pasteSwitchBigmodel = () => { state.loginHint = "paste the callback URL in the terminal…"; scheduleRender(); requestPaste(); }; try { const winner = await Promise.race([client.waitForCallback(), pasteRequested]); if (winner === PASTE_MODE) return await runPasteLoginInTui(client, started); return await client.exchangeCode(winner, started.callbackUrl, started.state); } finally { pasteSwitchBigmodel = null; } } /** `L` key: switch a pending bigmodel login to paste mode (or start one). */ function requestPasteLogin(): void { if (state.provider !== "bigmodel") { setToast("paste login is bigmodel-only — zai login already works headless", "info"); return; } if (state.loginInFlight) { if (pasteSwitchBigmodel) pasteSwitchBigmodel(); else setToast("already pasting — finish in the terminal", "info"); return; } void startLogin({ paste: true }); } /** Shared completion tail: resolve key, save, and ALWAYS close the client. */ function settleLogin(pending: Promise, client: OAuthFlowClient, provider: ProviderId): void { pending.then(async (tokens) => { const resolver = new KeyResolver(); const cred = await resolver.resolveCodingPlanCredential(tokens.accessToken, provider, tokens.userId); if (tokens.jwt) cred.jwt = tokens.jwt; await saveCredential(cred); if (serverRef.current) auth.setOAuthCredential(cred); console.log(`OAuth completed for ${provider}`); setToast("logged in", "ok"); }).catch((err: unknown) => { const msg = (err as Error)?.message ?? String(err); console.error(`OAuth flow ended without success: ${msg}`); setToast(`login failed: ${msg}`, "err"); }).finally(() => { // MUST run on rejection too — otherwise the callback port leaks // (fixed Android bug 5746857, same discipline applies here). void client.close().catch(() => {}); if (activeOauth?.client === client) activeOauth = null; state.loginInFlight = false; state.loginHint = ""; void refreshAuth(); }); } /** * Paste login for the TUI: briefly hands the terminal back (leaves the alt * screen, drops raw mode) so the user can paste the redirected callback * URL, then re-enters the TUI and exchanges the code. The callback server * stays bound — it only defines the redirect port; the browser cannot * reach it from another machine anyway. */ async function runPasteLoginInTui( client: BigmodelOAuthClient, started: OAuthFlowStart, ): Promise { let pasted: string; try { rendersSuspended = true; inputSuspended = true; try { (stdin as { setRawMode(m: boolean): void }).setRawMode(false); } catch { /* not raw-able */ } restore(); // leave the alt screen: the real terminal takes over realStdoutWrite("\n" + pasteLoginInstructions(started.authorizeUrl, started.callbackUrl, LOGIN_TIMEOUT_MS) + "\n\n"); realStdoutWrite(boldIfTTY("Paste the FULL redirected URL here, then press Enter:") + "\n> "); pasted = await readPastedLine(LOGIN_TIMEOUT_MS); } finally { enterAltScreen(); try { (stdin as { setRawMode(m: boolean): void }).setRawMode(true); } catch { /* ignore */ } inputSuspended = false; rendersSuspended = false; renderNow(); } const code = parsePastedCallbackUrl(pasted, started.state); realStdoutWrite("Exchanging authorization code…\n"); return client.exchangeCode(code, started.callbackUrl, started.state); } async function logout(): Promise { // Stop-first guard: the running proxy keeps serving with its in-memory // credential until restarted, so logging out mid-run would only look applied. if (state.serverStatus === "running" || state.serverStatus === "starting") { setToast("stop the proxy before logging out (press s)", "err"); return; } if (activeOauth) { void activeOauth.client.close().catch(() => {}); activeOauth = null; state.loginInFlight = false; state.loginHint = ""; } try { clearCredential(); } catch { /* best-effort logout */ } await refreshAuth(); if (serverRef.current) setToast("logged out — restart the proxy to apply", "info"); else setToast("logged out", "ok"); } // --- input loop ------------------------------------------------------------ const parser = new KeyParser(); stdin.setEncoding("utf8"); (stdin as { setRawMode(m: boolean): void }).setRawMode(true); stdin.resume(); stdin.on("data", (chunk: string) => { if (inputSuspended) return; // paste-login readline owns the terminal for (const action of parser.feed(chunk)) handleKey(action); }); function scrollUp(n: number): void { pane.scrollUp(n); scheduleRender(); } function scrollDown(n: number): void { pane.scrollDown(n); scheduleRender(); } function handleKey(action: KeyAction): void { switch (action.type) { case "ctrl-c": quit(); return; case "click": { const hit = findRegion(activeRegions, action.y, action.x); if (hit) dispatchClick(hit); return; } case "wheel-up": scrollUp(3); return; case "wheel-down": scrollDown(3); return; case "char": { // Uppercase L must not collapse into "l" below: paste fallback (headless). if (action.key === "L") { requestPasteLogin(); return; } switch (action.key.toLowerCase()) { case "q": quit(); return; case "s": toggleProxy(); return; case "l": void startLogin(); return; case "o": void logout(); return; case "p": switchProvider(); return; case "t": switchPlan(); return; case "c": pane.clear(); scheduleRender(); return; case "g": pane.followBottom(); scheduleRender(); return; case "k": scrollUp(1); return; case "j": scrollDown(1); return; default: return; } } case "up": scrollUp(1); return; case "down": scrollDown(1); return; case "pageup": scrollUp(PAGE_LINES); return; case "pagedown": scrollDown(PAGE_LINES); return; case "home": scrollUp(pane.count); return; case "end": pane.followBottom(); scheduleRender(); return; default: return; } } /** Mouse clicks hit the same actions as their keyboard shortcuts. */ function dispatchClick(action: ClickAction): void { switch (action.kind) { case "key": handleKey({ type: "char", key: action.key }); return; case "provider": applyConfigChange(action.value, state.plan, `provider → ${action.value}`); return; case "plan": applyConfigChange(state.provider, action.value, `plan → ${action.value}`); return; case "follow": pane.followBottom(); scheduleRender(); return; } } // --- teardown --------------------------------------------------------------- function quit(): void { cleanup(); process.exit(0); } function cleanup(): void { restore(); restoreConsole(); restoreStdio(); try { serverRef.current?.stop(false); } catch { /* already closed */ } } // Render watchdog: if the 33ms render chain ever dies (stuck timer id, // swallowed exception in a runtime with different uncaught semantics), the // screen would freeze while the proxy keeps serving. The watchdog re-renders // whenever nothing has painted for 3s and un-pauses a stalled stdin, so the // worst-case freeze is bounded. Fires fine unref'd (timers only keep the // process alive when everything else is gone; the server handle already does). const watchdog = setInterval(() => { try { if (stdin.isPaused()) stdin.resume(); } catch { /* stdin gone */ } if (Date.now() - lastRenderAt > 3000) renderNow(); }, 2000); if (typeof watchdog.unref === "function") watchdog.unref(); process.on("SIGINT", quit); process.on("SIGTERM", quit); process.on("exit", restore); stdout.on("resize", scheduleRender); // Guest captcha-SDK errors must NOT kill the TUI. Under Bun the Aliyun/ // FeiLin bundles run in the HOST realm, so an error escaping one of their // stray callbacks arrives here exactly like an internal fault. `serve` mode // already logs-and-continues (captcha-happy.ts); the TUI used to exit(1) for // everything, turning a retryable solve failure into a dead proxy (field // report: "tui crashed: ReferenceError: moveBy is not defined" from // feilin008.js). The pool retries the solve; the proxy keeps serving. process.on("uncaughtException", (err: Error) => { if (isGuestOriginError(err)) { emit(`[warn] captcha SDK error (ignored): ${describeGuestError(err)}`, "warn"); return; } cleanup(); origError(`zcode-proxy: tui crashed: ${err.stack ?? String(err)}`); process.exit(1); }); // --- boot --------------------------------------------------------------------- console.log(`zcode-proxy TUI — config: ${path}`); console.log(`provider: ${state.provider} · plan: ${state.plan}`); await refreshAuth(); renderNow(); if (!state.loggedIn) { setToast("not logged in — press l to login", "err"); } else { await startProxy(); } }