diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 04439c8406..0af85fd2ed 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -200,19 +200,38 @@ def handle_request(req: dict) -> dict | None: return fn(req.get("id"), req.get("params", {})) +def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None: + """Block until the session's agent has been built. + + Returns a JSON-RPC error dict on failure/timeout, ``None`` when the + agent is live. Cheap no-op when ``agent_ready`` is absent (sessions + from `session.resume`, which builds inline) or already set. + """ + ready = session.get("agent_ready") + if ready is not None and not ready.wait(timeout=timeout): + return _err(rid, 5032, "agent initialization timed out") + err = session.get("agent_error") + return _err(rid, 5032, err) if err else None + + +def _sess_nowait(params, rid): + """Resolve session without gating on agent readiness — for handlers + that only touch placeholder fields (cols, attached_images) and + shouldn't eat the agent-build window on cold start.""" + s = _sessions.get(params.get("session_id") or "") + return (s, None) if s else (None, _err(rid, 4001, "session not found")) + + def _sess(params, rid): - """Resolve session from params + block until its agent is ready. + """Resolve session from params + block on ``_wait_agent``. `session.create` builds the agent on a background thread (~500–1500ms - cold) so the placeholder session may exist before `session["agent"]` - is populated. Any handler that dereferences `session["agent"]` should - go through `_sess` — the wait is a free no-op when the event is - already set or absent (e.g. `session.resume` builds the agent inline). + cold) so the placeholder session exists before ``session["agent"]`` + is populated. Routing every agent-touching RPC through `_sess` hides + that window — reads become free once the agent is live. """ - s = _sessions.get(params.get("session_id") or "") - if not s: - return None, _err(rid, 4001, "session not found") - return s, _wait_agent(s, rid) + s, err = _sess_nowait(params, rid) + return (None, err) if err else (s, _wait_agent(s, rid)) def _normalize_completion_path(path_part: str) -> str: @@ -1041,41 +1060,28 @@ def _history_to_messages(history: list[dict]) -> list[dict]: # ── Methods: session ───────────────────────────────────────────────── -def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None: - """Block until the session's agent has been built, returning a JSON-RPC - error dict if initialization failed or timed out — or ``None`` when the - agent is live and ready for use. Cheap no-op when there is no - `agent_ready` event (already-ready sessions from `session.resume`, etc.). - """ - ready = session.get("agent_ready") - if ready is not None and not ready.is_set(): - if not ready.wait(timeout=timeout): - return _err(rid, 5032, "agent initialization timed out") - if session.get("agent_error"): - return _err(rid, 5032, session["agent_error"]) - return None - - @method("session.create") def _(rid, params: dict) -> dict: - """Non-blocking session creation. Returns the sid + minimal info right - away; the heavy agent init runs on a background thread and broadcasts a - `session.info` event when tools/skills are ready. Handlers that touch - `session["agent"]` must call `_wait_agent(session, rid)` first.""" + """Non-blocking session creation. + + Returns the sid + minimal info right away; the heavy agent init runs + on a background thread and broadcasts `session.info` when tools and + skills are wired. Handlers that touch ``session["agent"]`` go through + `_sess`, which gates on the `agent_ready` event. + """ sid = uuid.uuid4().hex[:8] key = _new_session_key() cols = int(params.get("cols", 80)) os.environ["HERMES_INTERACTIVE"] = "1" - ready_event = threading.Event() + ready = threading.Event() # Placeholder session so subsequent RPCs find the sid and can wait on - # `agent_ready`. Fields mirror `_init_session`; anything derived from - # the agent is filled once the build thread completes. + # `agent_ready`; anything derived from the agent is filled in by `_build`. _sessions[sid] = { "agent": None, "agent_error": None, - "agent_ready": ready_event, + "agent_ready": ready, "attached_images": [], "cols": cols, "edit_snapshots": {}, @@ -1092,6 +1098,7 @@ def _(rid, params: dict) -> dict: } def _build() -> None: + session = _sessions[sid] try: tokens = _set_session_context(key) try: @@ -1100,10 +1107,10 @@ def _(rid, params: dict) -> dict: _clear_session_context(tokens) _get_db().create_session(key, source="tui", model=_resolve_model()) - _sessions[sid]["agent"] = agent + session["agent"] = agent try: - _sessions[sid]["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model())) + session["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model())) except Exception: pass # slash.exec will surface the real failure @@ -1122,10 +1129,10 @@ def _(rid, params: dict) -> dict: info["credential_warning"] = warn _emit("session.info", sid, info) except Exception as e: - _sessions[sid]["agent_error"] = str(e) + session["agent_error"] = str(e) _emit("error", sid, {"message": f"agent init failed: {e}"}) finally: - ready_event.set() + ready.set() threading.Thread(target=_build, daemon=True).start() @@ -1359,11 +1366,9 @@ def _(rid, params: dict) -> dict: @method("terminal.resize") def _(rid, params: dict) -> dict: - # Direct dict lookup — no agent needed; skip `_sess`'s wait-for-agent - # gate so TUI's initial resize doesn't block on cold session.create. - session = _sessions.get(params.get("session_id") or "") - if not session: - return _err(rid, 4001, "session not found") + session, err = _sess_nowait(params, rid) + if err: + return err session["cols"] = int(params.get("cols", 80)) return _ok(rid, {"cols": session["cols"]}) @@ -1539,12 +1544,11 @@ def _(rid, params: dict) -> dict: @method("input.detect_drop") def _(rid, params: dict) -> dict: - # Pattern-matching on text — no agent needed. Skip `_sess`'s wait so - # the first post-startup message doesn't add the agent-build window - # on top of `prompt.submit`'s own wait. - session = _sessions.get(params.get("session_id") or "") - if not session: - return _err(rid, 4001, "session not found") + # Pure text pattern-matching — bypass the agent-ready gate so the first + # post-startup send doesn't stack the wait on top of `prompt.submit`'s. + session, err = _sess_nowait(params, rid) + if err: + return err try: from cli import _detect_file_drop diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index adb6d4f554..105a90707a 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -161,21 +161,24 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return - case 'session.info': + case 'session.info': { + const info = ev.payload + patchUiState(state => ({ ...state, - info: ev.payload, - // Flip from 'starting agent…' → 'ready' when the agent is live. - // Leave running/interrupted/error statuses alone. + info, + // agent just came online → flip the 'starting agent…' placeholder. + // leave running/interrupted/error statuses alone. status: state.status === 'starting agent…' ? 'ready' : state.status, - usage: ev.payload.usage ? { ...state.usage, ...ev.payload.usage } : state.usage + usage: info.usage ? { ...state.usage, ...info.usage } : state.usage })) - // Agent init is async in session.create, so the intro message may - // have been seeded with partial info (just model/cwd). Upgrade it - // in-place when the real session.info lands. - setHistoryItems(prev => prev.map(m => (m.kind === 'intro' ? { ...m, info: ev.payload } : m))) + + // upgrade the seeded/partial intro row in-place with the real info + setHistoryItems(prev => prev.map(m => (m.kind === 'intro' ? { ...m, info } : m))) return + } + case 'thinking.delta': { const text = ev.payload?.text diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index d1ac42f5d0..1c69c78a0d 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -379,26 +379,19 @@ export function useMainApp(gw: GatewayClient) { sys }) - // Flush any pre-session queued input once the session lands. - // Message.complete already drains subsequent items; this only kicks off the first. + // Flush any pre-session queued input the moment the session lands. + // `message.complete` drains the rest; this just kicks off the first send. const prevSidRef = useRef(null) useEffect(() => { const prev = prevSidRef.current prevSidRef.current = ui.sid - if (prev !== null || !ui.sid || ui.busy) { - return - } - - if (composerRefs.queueEditRef.current !== null) { + if (prev !== null || !ui.sid || ui.busy || composerRefs.queueEditRef.current !== null) { return } const next = composerActions.dequeue() - - if (next) { - sendQueued(next) - } + if (next) sendQueued(next) }, [ui.sid, ui.busy, composerActions, composerRefs, sendQueued]) const { pagerPageSize } = useInputHandlers({ diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 9fed9fe984..4a496f5f76 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -102,30 +102,25 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { return patchUiState({ status: 'ready' }) } + const info = r.info ?? null + resetSession() setSessionStartedAt(Date.now()) - // Python's `session.create` returns instantly with partial info (no `version` - // field); the `session.info` event will flip status to 'ready' once the - // agent is fully built (~1s later). Until then prompt.submit will block - // server-side on `_wait_agent`. + + // session.create returns instantly with partial info (no `version`); + // the `session.info` event flips status to 'ready' once the agent is live. patchUiState({ - info: r.info ?? null, + info, sid: r.session_id, - status: r.info?.version ? 'ready' : 'starting agent…', - usage: usageFrom(r.info ?? null) + status: info?.version ? 'ready' : 'starting agent…', + usage: usageFrom(info) }) - if (r.info) { - setHistoryItems([introMsg(r.info)]) - } + if (info) setHistoryItems([introMsg(info)]) - if (r.info?.credential_warning) { - sys(`warning: ${r.info.credential_warning}`) - } + if (info?.credential_warning) sys(`warning: ${info.credential_warning}`) - if (msg) { - sys(msg) - } + if (msg) sys(msg) }, [closeSession, colsRef, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] ) diff --git a/ui-tui/src/bootBanner.ts b/ui-tui/src/bootBanner.ts index 2aac254a27..35fd3ce40a 100644 --- a/ui-tui/src/bootBanner.ts +++ b/ui-tui/src/bootBanner.ts @@ -1,10 +1,7 @@ -// Prints the Hermes banner as raw ANSI to stdout before React/Ink load. -// Gives the user instant visual feedback during the ~170ms dynamic-import -// window; `` wipes the normal-screen buffer when Ink -// mounts, so there is no double-banner. -// -// Palette is hardcoded to match DEFAULT_THEME — drifting the theme's -// banner colors here is fine, Ink's real render takes over in ~200ms. +// Raw-ANSI banner painted to stdout before React/Ink load, giving the user +// instant visual feedback during the ~170ms dynamic-import window. +// `` wipes the normal-screen buffer when Ink mounts, so +// there's no double-banner and palette drift vs. DEFAULT_THEME is harmless. const GOLD = '\x1b[38;2;255;215;0m' const AMBER = '\x1b[38;2;255;191;0m' @@ -21,16 +18,15 @@ const LOGO = [ '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ' ] -const GRADIENT = [GOLD, GOLD, AMBER, AMBER, BRONZE, BRONZE] +const GRADIENT = [GOLD, GOLD, AMBER, AMBER, BRONZE, BRONZE] as const const LOGO_WIDTH = 98 -export function bootBanner(cols: number = process.stdout.columns || 80): string { - const lines = - cols >= LOGO_WIDTH - ? LOGO.map((text, i) => `${GRADIENT[i]}${text}${RESET}`) - : [`\x1b[1m${GOLD}⚕ NOUS HERMES${RESET}`] +const TAGLINE = `${DIM}⚕ Nous Research · Messenger of the Digital Gods${RESET}` +const FALLBACK = `\x1b[1m${GOLD}⚕ NOUS HERMES${RESET}` - return ( - '\n' + lines.join('\n') + '\n' + `${DIM}⚕ Nous Research · Messenger of the Digital Gods${RESET}\n\n` - ) +export function bootBanner(cols: number = process.stdout.columns || 80): string { + const body = + cols >= LOGO_WIDTH ? LOGO.map((text, i) => `${GRADIENT[i]}${text}${RESET}`).join('\n') : FALLBACK + + return `\n${body}\n${TAGLINE}\n\n` } diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index d1f4aaa4b5..cf85786c74 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -18,7 +18,6 @@ export interface ThemeColors { statusWarn: string statusBad: string statusCritical: string - selectionBg: string diffAdded: string @@ -95,10 +94,8 @@ export const DEFAULT_THEME: Theme = { statusWarn: '#FFD700', statusBad: '#FF8C00', statusCritical: '#FF6B6B', - - // Uniform selection bg — matches the muted navy of the status bar so - // gold/amber fg stays readable and the highlight doesn't fragment per - // fg color the way SGR-inverse does. + // muted navy — sits under gold/amber fg without fighting it, swaps + // cleanly with SGR-inverse that fragmented per fg color selectionBg: '#3a3a55', diffAdded: 'rgb(220,255,220)', @@ -155,7 +152,6 @@ export function fromSkin( statusWarn: c('ui_warn') ?? d.color.statusWarn, statusBad: d.color.statusBad, statusCritical: d.color.statusCritical, - selectionBg: c('selection_bg') ?? d.color.selectionBg, diffAdded: d.color.diffAdded,