mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
refactor(tui): clean up touched files — DRY, KISS, functional
Python (tui_gateway/server.py):
- hoist `_wait_agent` next to `_sess` so `_sess` no longer forward-refs
- simplify `_wait_agent`: `ready.wait()` already returns True when set,
no separate `.is_set()` check, collapse two returns into one expr
- factor `_sess_nowait` for handlers that don't need the agent (currently
`terminal.resize` + `input.detect_drop`) — DRY up the duplicated
`_sessions.get` + "session not found" dance
- inline `session = _sessions[sid]` in the session.create build thread so
agent/worker writes don't re-look-up the dict each time
- rename inline `ready_event` → `ready` (it's never ambiguous)
TS:
- `useSessionLifecycle.newSession`: hoist `r.info ?? null` into `info`
so it's one lookup, drop ceremonial `{ … }` blocks around single-line
bodies
- `createGatewayEventHandler.session.info`: wrap the case in a block,
hoist `ev.payload` into `info`, tighten comments
- `useMainApp` flush effect: collapse two guard returns into one
- `bootBanner.ts`: lift `TAGLINE` + `FALLBACK` to module constants, make
`GRADIENT` readonly, one-liner return via template literal
- `theme.ts`: group `selectionBg` inside the status* block (it's a UI
surface bg, same family), trim the comment
This commit is contained in:
parent
275256cdb4
commit
727f0eaf74
6 changed files with 92 additions and 105 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 | string>(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({
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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; `<AlternateScreen>` 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.
|
||||
// `<AlternateScreen>` 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`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue