mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
tui: inherit Python-side rendering via gateway bridge
This commit is contained in:
parent
0f556a17f5
commit
f116c59071
5 changed files with 128 additions and 14 deletions
49
tui_gateway/render.py
Normal file
49
tui_gateway/render.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Rendering bridge — routes TUI content through Python-side renderers.
|
||||
|
||||
When agent.rich_output exists, its functions are used. When it doesn't,
|
||||
everything returns None and the TUI falls back to its own markdown.tsx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def render_message(text: str, cols: int = 80) -> str | None:
|
||||
try:
|
||||
from agent.rich_output import format_response
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return format_response(text, cols=cols)
|
||||
except TypeError:
|
||||
return format_response(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def render_diff(text: str, cols: int = 80) -> str | None:
|
||||
try:
|
||||
from agent.rich_output import render_diff as _rd
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return _rd(text, cols=cols)
|
||||
except TypeError:
|
||||
return _rd(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def make_stream_renderer(cols: int = 80):
|
||||
try:
|
||||
from agent.rich_output import StreamingRenderer
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return StreamingRenderer(cols=cols)
|
||||
except TypeError:
|
||||
return StreamingRenderer()
|
||||
except Exception:
|
||||
return None
|
||||
|
|
@ -12,6 +12,8 @@ from hermes_cli.env_loader import load_hermes_dotenv
|
|||
_hermes_home = get_hermes_home()
|
||||
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env")
|
||||
|
||||
from tui_gateway.render import make_stream_renderer, render_diff, render_message
|
||||
|
||||
_sessions: dict[str, dict] = {}
|
||||
_methods: dict[str, callable] = {}
|
||||
_pending: dict[str, threading.Event] = {}
|
||||
|
|
@ -194,13 +196,14 @@ def _make_agent(sid: str, key: str, session_id: str | None = None):
|
|||
)
|
||||
|
||||
|
||||
def _init_session(sid: str, key: str, agent, history: list):
|
||||
def _init_session(sid: str, key: str, agent, history: list, cols: int = 80):
|
||||
_sessions[sid] = {
|
||||
"agent": agent,
|
||||
"session_key": key,
|
||||
"history": history,
|
||||
"attached_images": [],
|
||||
"image_counter": 0,
|
||||
"cols": cols,
|
||||
}
|
||||
try:
|
||||
from tools.approval import register_gateway_notify, load_permanent_allowlist
|
||||
|
|
@ -259,7 +262,7 @@ def _(rid, params: dict) -> dict:
|
|||
try:
|
||||
agent = _make_agent(sid, key)
|
||||
_get_db().create_session(key, source="tui", model=_resolve_model())
|
||||
_init_session(sid, key, agent, [])
|
||||
_init_session(sid, key, agent, [], cols=int(params.get("cols", 80)))
|
||||
except Exception as e:
|
||||
return _err(rid, 5000, f"agent init failed: {e}")
|
||||
return _ok(rid, {"session_id": sid})
|
||||
|
|
@ -300,7 +303,7 @@ def _(rid, params: dict) -> dict:
|
|||
for m in db.get_messages(target)
|
||||
if m.get("role") in ("user", "assistant", "tool", "system")]
|
||||
agent = _make_agent(sid, target, session_id=target)
|
||||
_init_session(sid, target, agent, history)
|
||||
_init_session(sid, target, agent, history, cols=int(params.get("cols", 80)))
|
||||
except Exception as e:
|
||||
return _err(rid, 5000, f"resume failed: {e}")
|
||||
return _ok(rid, {"session_id": sid, "resumed": target, "message_count": len(history)})
|
||||
|
|
@ -392,6 +395,15 @@ def _(rid, params: dict) -> dict:
|
|||
return _ok(rid, {"status": "interrupted"})
|
||||
|
||||
|
||||
@method("terminal.resize")
|
||||
def _(rid, params: dict) -> dict:
|
||||
session, err = _sess(params, rid)
|
||||
if err:
|
||||
return err
|
||||
session["cols"] = int(params.get("cols", 80))
|
||||
return _ok(rid, {"cols": session["cols"]})
|
||||
|
||||
|
||||
# ── Methods: prompt ──────────────────────────────────────────────────
|
||||
|
||||
@method("prompt.submit")
|
||||
|
|
@ -405,19 +417,36 @@ def _(rid, params: dict) -> dict:
|
|||
|
||||
def run():
|
||||
try:
|
||||
cols = session.get("cols", 80)
|
||||
streamer = make_stream_renderer(cols)
|
||||
images = session.pop("attached_images", [])
|
||||
prompt = _enrich_with_attached_images(text, images) if images else text
|
||||
|
||||
def _stream(delta):
|
||||
payload = {"text": delta}
|
||||
if streamer and (r := streamer.feed(delta)) is not None:
|
||||
payload["rendered"] = r
|
||||
_emit("message.delta", sid, payload)
|
||||
|
||||
result = agent.run_conversation(
|
||||
prompt, conversation_history=list(history),
|
||||
stream_callback=lambda delta: _emit("message.delta", sid, {"text": delta}),
|
||||
stream_callback=_stream,
|
||||
)
|
||||
|
||||
if isinstance(result, dict):
|
||||
if isinstance(result.get("messages"), list):
|
||||
session["history"] = result["messages"]
|
||||
raw = result.get("final_response", "")
|
||||
status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete"
|
||||
_emit("message.complete", sid, {"text": result.get("final_response", ""), "usage": _get_usage(agent), "status": status})
|
||||
else:
|
||||
_emit("message.complete", sid, {"text": str(result), "usage": _get_usage(agent), "status": "complete"})
|
||||
raw = str(result)
|
||||
status = "complete"
|
||||
|
||||
payload = {"text": raw, "usage": _get_usage(agent), "status": status}
|
||||
rendered = render_message(raw, cols)
|
||||
if rendered:
|
||||
payload["rendered"] = rendered
|
||||
_emit("message.complete", sid, payload)
|
||||
except Exception as e:
|
||||
_emit("error", sid, {"message": str(e)})
|
||||
|
||||
|
|
@ -868,7 +897,12 @@ def _(rid, params: dict) -> dict:
|
|||
return _err(rid, 4014, "hash required")
|
||||
try:
|
||||
r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, target))
|
||||
return _ok(rid, {"stat": r.get("stat", ""), "diff": r.get("diff", "")[:4000]})
|
||||
raw = r.get("diff", "")[:4000]
|
||||
payload = {"stat": r.get("stat", ""), "diff": raw}
|
||||
rendered = render_diff(raw, session.get("cols", 80))
|
||||
if rendered:
|
||||
payload["rendered"] = rendered
|
||||
return _ok(rid, payload)
|
||||
except Exception as e:
|
||||
return _err(rid, 5022, str(e))
|
||||
|
||||
|
|
|
|||
|
|
@ -117,6 +117,19 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
}
|
||||
}, [messages.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sid || !stdout) {
|
||||
return
|
||||
}
|
||||
|
||||
const onResize = () => rpc('terminal.resize', { session_id: sid, cols: stdout.columns ?? 80 })
|
||||
stdout.on('resize', onResize)
|
||||
|
||||
return () => {
|
||||
stdout.off('resize', onResize)
|
||||
}
|
||||
}, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const msgBudget = Math.max(3, rows - 2 - (empty ? 0 : 2) - (thinking ? 2 : 0) - 2)
|
||||
|
||||
const viewport = useMemo(() => {
|
||||
|
|
@ -144,6 +157,10 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
start = end - 1
|
||||
}
|
||||
|
||||
if (start > 0 && messages[start - 1]?.role === 'user') {
|
||||
start--
|
||||
}
|
||||
|
||||
return { above: start, end, start }
|
||||
}, [cols, messages, msgBudget, scrollOffset])
|
||||
|
||||
|
|
@ -155,7 +172,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
})
|
||||
|
||||
const newSession = (msg?: string) =>
|
||||
rpc('session.create').then((r: any) => {
|
||||
rpc('session.create', { cols }).then((r: any) => {
|
||||
if (!r) {
|
||||
return
|
||||
}
|
||||
|
|
@ -534,7 +551,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
break
|
||||
}
|
||||
|
||||
buf.current += p.text
|
||||
buf.current += p.rendered ?? p.text
|
||||
setThinking(false)
|
||||
setTools([])
|
||||
setReasoning('')
|
||||
|
|
@ -543,7 +560,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
break
|
||||
case 'message.complete': {
|
||||
idle()
|
||||
setMessages(prev => upsert(prev, 'assistant', (p?.text ?? buf.current).trimStart()))
|
||||
setMessages(prev => upsert(prev, 'assistant', (p?.rendered ?? p?.text ?? buf.current).trimStart()))
|
||||
buf.current = ''
|
||||
setStatus('ready')
|
||||
|
||||
|
|
@ -1050,7 +1067,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
return
|
||||
}
|
||||
|
||||
rpc('rollback.diff', { session_id: sid, hash }).then((d: any) => sys(d.stat || d.diff || 'no changes'))
|
||||
rpc('rollback.diff', { session_id: sid, hash }).then((d: any) =>
|
||||
sys(d.rendered || d.stat || d.diff || 'no changes')
|
||||
)
|
||||
})
|
||||
|
||||
return true
|
||||
|
|
@ -1239,6 +1258,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
if (r.blocked) {
|
||||
return sys(r.hint ?? 'blocked')
|
||||
}
|
||||
|
||||
sys(r.output ?? '(no output)')
|
||||
|
||||
if (r.code !== 0) {
|
||||
|
|
@ -1548,7 +1568,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
onSelect={id => {
|
||||
setPicker(false)
|
||||
setStatus('resuming…')
|
||||
gw.request('session.resume', { session_id: id })
|
||||
gw.request('session.resume', { session_id: id, cols })
|
||||
.then((r: any) => {
|
||||
setSid(r.session_id)
|
||||
setMessages([])
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Box, Text } from 'ink'
|
||||
|
||||
import { LONG_MSG, ROLE } from '../constants.js'
|
||||
import { userDisplay } from '../lib/text.js'
|
||||
import { hasAnsi, userDisplay } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { Msg } from '../types.js'
|
||||
|
||||
|
|
@ -12,6 +12,10 @@ export function MessageLine({ compact, msg, t }: { compact?: boolean; msg: Msg;
|
|||
|
||||
const content = (() => {
|
||||
if (msg.role === 'assistant') {
|
||||
if (hasAnsi(msg.text)) {
|
||||
return <Text>{msg.text}</Text>
|
||||
}
|
||||
|
||||
return <Md compact={compact} t={t} text={msg.text} />
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { INTERPOLATION_RE, LONG_MSG } from '../constants.js'
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g
|
||||
|
||||
export const stripAnsi = (s: string) => s.replace(ANSI_RE, '')
|
||||
|
||||
export const hasAnsi = (s: string) => s.includes('\x1b[')
|
||||
|
||||
export const compactPreview = (s: string, max: number) => {
|
||||
const one = s.replace(/\s+/g, ' ').trim()
|
||||
|
||||
|
|
@ -7,7 +14,7 @@ export const compactPreview = (s: string, max: number) => {
|
|||
}
|
||||
|
||||
export const estimateRows = (text: string, w: number) =>
|
||||
text.split('\n').reduce((s, l) => s + Math.max(1, Math.ceil(Math.max(1, l.length) / w)), 0)
|
||||
text.split('\n').reduce((sum, line) => sum + Math.ceil((stripAnsi(line).length || 1) / w), 0)
|
||||
|
||||
export const flat = (r: Record<string, string[]>) => Object.values(r).flat()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue