diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c3e3685b..1a8f55859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed). +- TUI: block onboarding output while TUI is active and restore terminal state on exit. - Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode. - fix(agents): validate AbortSignal instances before calling AbortSignal.any() (#7277) (thanks @Elarwei001) - fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz) diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index 2c534f0cf..d0e147dc2 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -1,6 +1,7 @@ import type { RuntimeEnv } from "../runtime.js"; import type { OnboardOptions } from "./onboard-types.js"; import { defaultRuntime } from "../runtime.js"; +import { restoreTerminalState } from "../terminal/restore.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; import { runOnboardingWizard } from "../wizard/onboarding.js"; import { WizardCancelledError } from "../wizard/prompts.js"; @@ -18,5 +19,7 @@ export async function runInteractiveOnboarding( return; } throw err; + } finally { + restoreTerminalState("onboarding finish"); } } diff --git a/src/runtime.ts b/src/runtime.ts index 819e360ba..c8eab74ec 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,4 +1,5 @@ import { clearActiveProgressLine } from "./terminal/progress-line.js"; +import { restoreTerminalState } from "./terminal/restore.js"; export type RuntimeEnv = { log: typeof console.log; @@ -16,6 +17,7 @@ export const defaultRuntime: RuntimeEnv = { console.error(...args); }, exit: (code) => { + restoreTerminalState("runtime exit"); process.exit(code); throw new Error("unreachable"); // satisfies tests when mocked }, diff --git a/src/terminal/restore.ts b/src/terminal/restore.ts new file mode 100644 index 000000000..b25baa3fd --- /dev/null +++ b/src/terminal/restore.ts @@ -0,0 +1,49 @@ +import { clearActiveProgressLine } from "./progress-line.js"; + +const RESET_SEQUENCE = "\x1b[0m\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l"; + +function reportRestoreFailure(scope: string, err: unknown, reason?: string): void { + const suffix = reason ? ` (${reason})` : ""; + const message = `[terminal] restore ${scope} failed${suffix}: ${String(err)}`; + try { + process.stderr.write(`${message}\n`); + } catch (writeErr) { + try { + console.error(`[terminal] restore reporting failed${suffix}: ${String(writeErr)}`); + } catch (consoleErr) { + throw consoleErr; + } + } +} + +export function restoreTerminalState(reason?: string): void { + try { + clearActiveProgressLine(); + } catch (err) { + reportRestoreFailure("progress line", err, reason); + } + + const stdin = process.stdin; + if (stdin.isTTY && typeof stdin.setRawMode === "function") { + try { + stdin.setRawMode(false); + } catch (err) { + reportRestoreFailure("raw mode", err, reason); + } + if (typeof stdin.isPaused === "function" && stdin.isPaused()) { + try { + stdin.resume(); + } catch (err) { + reportRestoreFailure("stdin resume", err, reason); + } + } + } + + if (process.stdout.isTTY) { + try { + process.stdout.write(RESET_SEQUENCE); + } catch (err) { + reportRestoreFailure("stdout reset", err, reason); + } + } +} diff --git a/src/tui/tui.ts b/src/tui/tui.ts index a2250746e..fd693b4db 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -662,4 +662,10 @@ export async function runTui(opts: TuiOptions) { updateFooter(); tui.start(); client.start(); + await new Promise((resolve) => { + const finish = () => resolve(); + process.once("exit", finish); + process.once("SIGINT", finish); + process.once("SIGTERM", finish); + }); } diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index 70b7f8430..9ef3024b4 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -21,7 +21,6 @@ import { detectBrowserOpenSupport, formatControlUiSshHint, openUrl, - openUrlInBackground, probeGatewayReachable, waitForGatewayReachable, resolveControlUiLinks, @@ -29,6 +28,7 @@ import { import { resolveGatewayService } from "../daemon/service.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; +import { restoreTerminalState } from "../terminal/restore.js"; import { runTui } from "../tui/tui.js"; import { resolveUserPath } from "../utils.js"; @@ -43,7 +43,9 @@ type FinalizeOnboardingOptions = { runtime: RuntimeEnv; }; -export async function finalizeOnboardingWizard(options: FinalizeOnboardingOptions) { +export async function finalizeOnboardingWizard( + options: FinalizeOnboardingOptions, +): Promise<{ launchedTui: boolean }> { const { flow, opts, baseConfig, nextConfig, settings, prompter, runtime } = options; const withWizardProgress = async ( @@ -286,6 +288,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption let controlUiOpenHint: string | undefined; let seededInBackground = false; let hatchChoice: "tui" | "web" | "later" | null = null; + let launchedTui = false; if (!opts.skipUi && gatewayProbe.ok) { if (hasBootstrap) { @@ -321,6 +324,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption }); if (hatchChoice === "tui") { + restoreTerminalState("pre-onboarding tui"); await runTui({ url: links.wsUrl, token: settings.authMode === "token" ? settings.gatewayToken : undefined, @@ -329,17 +333,7 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption deliver: false, message: hasBootstrap ? "Wake up, my friend!" : undefined, }); - if (settings.authMode === "token" && settings.gatewayToken) { - seededInBackground = await openUrlInBackground(authedUrl); - } - if (seededInBackground) { - await prompter.note( - `Web UI seeded in the background. Open later with: ${formatCliCommand( - "openclaw dashboard --no-open", - )}`, - "Web UI", - ); - } + launchedTui = true; } else if (hatchChoice === "web") { const browserSupport = await detectBrowserOpenSupport(); if (browserSupport.ok) { @@ -471,4 +465,6 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption ? "Onboarding complete. Web UI seeded in the background; open it anytime with the tokenized link above." : "Onboarding complete. Use the tokenized dashboard link above to control OpenClaw.", ); + + return { launchedTui }; } diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index de1c6c36f..544e455ec 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -455,7 +455,7 @@ export async function runOnboardingWizard( nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); await writeConfigFile(nextConfig); - await finalizeOnboardingWizard({ + const { launchedTui } = await finalizeOnboardingWizard({ flow, opts, baseConfig, @@ -465,6 +465,9 @@ export async function runOnboardingWizard( prompter, runtime, }); + if (launchedTui) { + return; + } const installShell = await prompter.confirm({ message: "Install shell completion script?",