diff --git a/CHANGELOG.md b/CHANGELOG.md index ca352b8b991..79333b24619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai - Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant. - Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd. - CLI/Pairing: default `pairing list` and `pairing approve` to the sole available pairing channel when omitted, so TUI-only setups can recover from `pairing required` without guessing channel arguments. (#21527) Thanks @losts1. +- TUI/Pairing: show explicit pairing-required recovery guidance after gateway disconnects that return `pairing required`, including approval steps to unblock quickstart TUI hatching on fresh installs. (#21841) Thanks @nicolinux. - Auth/Onboarding: align OAuth profile-id config mapping with stored credential IDs for OpenAI Codex and Chutes flows, preventing `provider:default` mismatches when OAuth returns email-scoped credentials. (#12692) thanks @mudrii. - Docker: pin base images to SHA256 digests in Docker builds to prevent mutable tag drift. (#7734) Thanks @coygeek. - Docker/Security: run E2E and install-sh test images as non-root by adding appuser directives. Thanks @thewilloftheshadow. diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index aa60f12109b..056cbc881c6 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import { getSlashCommands, parseCommand } from "./commands.js"; -import { resolveFinalAssistantText, resolveTuiSessionKey } from "./tui.js"; +import { + resolveFinalAssistantText, + resolveGatewayDisconnectState, + resolveTuiSessionKey, +} from "./tui.js"; describe("resolveFinalAssistantText", () => { it("falls back to streamed text when final text is empty", () => { @@ -67,3 +71,19 @@ describe("resolveTuiSessionKey", () => { ).toBe("agent:ops:incident"); }); }); + +describe("resolveGatewayDisconnectState", () => { + it("returns pairing recovery guidance when disconnect reason requires pairing", () => { + const state = resolveGatewayDisconnectState("gateway closed (1008): pairing required"); + expect(state.connectionStatus).toContain("pairing required"); + expect(state.activityStatus).toBe("pairing required: run openclaw devices list"); + expect(state.pairingHint).toContain("openclaw devices list"); + }); + + it("falls back to idle for generic disconnect reasons", () => { + const state = resolveGatewayDisconnectState("network timeout"); + expect(state.connectionStatus).toBe("gateway disconnected: network timeout"); + expect(state.activityStatus).toBe("idle"); + expect(state.pairingHint).toBeUndefined(); + }); +}); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 8f22087fe96..43743ce2ae9 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -195,6 +195,26 @@ export function resolveTuiSessionKey(params: { return `agent:${params.currentAgentId}:${trimmed}`; } +export function resolveGatewayDisconnectState(reason?: string): { + connectionStatus: string; + activityStatus: string; + pairingHint?: string; +} { + const reasonLabel = reason?.trim() ? reason.trim() : "closed"; + if (/pairing required/i.test(reasonLabel)) { + return { + connectionStatus: `gateway disconnected: ${reasonLabel}`, + activityStatus: "pairing required: run openclaw devices list", + pairingHint: + "Pairing required. Run `openclaw devices list`, approve your request ID, then reconnect.", + }; + } + return { + connectionStatus: `gateway disconnected: ${reasonLabel}`, + activityStatus: "idle", + }; +} + export async function runTui(opts: TuiOptions) { const config = loadConfig(); const initialSessionInput = (opts.session ?? "").trim(); @@ -213,6 +233,7 @@ export async function runTui(opts: TuiOptions) { let wasDisconnected = false; let toolsExpanded = false; let showThinking = false; + let pairingHintShown = false; const localRunIds = new Set(); const deliverDefault = opts.deliver ?? false; @@ -772,6 +793,7 @@ export async function runTui(opts: TuiOptions) { client.onConnected = () => { isConnected = true; + pairingHintShown = false; const reconnected = wasDisconnected; wasDisconnected = false; setConnectionStatus("connected"); @@ -794,9 +816,13 @@ export async function runTui(opts: TuiOptions) { isConnected = false; wasDisconnected = true; historyLoaded = false; - const reasonLabel = reason?.trim() ? reason.trim() : "closed"; - setConnectionStatus(`gateway disconnected: ${reasonLabel}`, 5000); - setActivityStatus("idle"); + const disconnectState = resolveGatewayDisconnectState(reason); + setConnectionStatus(disconnectState.connectionStatus, 5000); + setActivityStatus(disconnectState.activityStatus); + if (disconnectState.pairingHint && !pairingHintShown) { + pairingHintShown = true; + chatLog.addSystem(disconnectState.pairingHint); + } updateFooter(); tui.requestRender(); };