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()
|
_hermes_home = get_hermes_home()
|
||||||
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env")
|
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] = {}
|
_sessions: dict[str, dict] = {}
|
||||||
_methods: dict[str, callable] = {}
|
_methods: dict[str, callable] = {}
|
||||||
_pending: dict[str, threading.Event] = {}
|
_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] = {
|
_sessions[sid] = {
|
||||||
"agent": agent,
|
"agent": agent,
|
||||||
"session_key": key,
|
"session_key": key,
|
||||||
"history": history,
|
"history": history,
|
||||||
"attached_images": [],
|
"attached_images": [],
|
||||||
"image_counter": 0,
|
"image_counter": 0,
|
||||||
|
"cols": cols,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
from tools.approval import register_gateway_notify, load_permanent_allowlist
|
from tools.approval import register_gateway_notify, load_permanent_allowlist
|
||||||
|
|
@ -259,7 +262,7 @@ def _(rid, params: dict) -> dict:
|
||||||
try:
|
try:
|
||||||
agent = _make_agent(sid, key)
|
agent = _make_agent(sid, key)
|
||||||
_get_db().create_session(key, source="tui", model=_resolve_model())
|
_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:
|
except Exception as e:
|
||||||
return _err(rid, 5000, f"agent init failed: {e}")
|
return _err(rid, 5000, f"agent init failed: {e}")
|
||||||
return _ok(rid, {"session_id": sid})
|
return _ok(rid, {"session_id": sid})
|
||||||
|
|
@ -300,7 +303,7 @@ def _(rid, params: dict) -> dict:
|
||||||
for m in db.get_messages(target)
|
for m in db.get_messages(target)
|
||||||
if m.get("role") in ("user", "assistant", "tool", "system")]
|
if m.get("role") in ("user", "assistant", "tool", "system")]
|
||||||
agent = _make_agent(sid, target, session_id=target)
|
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:
|
except Exception as e:
|
||||||
return _err(rid, 5000, f"resume failed: {e}")
|
return _err(rid, 5000, f"resume failed: {e}")
|
||||||
return _ok(rid, {"session_id": sid, "resumed": target, "message_count": len(history)})
|
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"})
|
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 ──────────────────────────────────────────────────
|
# ── Methods: prompt ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@method("prompt.submit")
|
@method("prompt.submit")
|
||||||
|
|
@ -405,19 +417,36 @@ def _(rid, params: dict) -> dict:
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
try:
|
try:
|
||||||
|
cols = session.get("cols", 80)
|
||||||
|
streamer = make_stream_renderer(cols)
|
||||||
images = session.pop("attached_images", [])
|
images = session.pop("attached_images", [])
|
||||||
prompt = _enrich_with_attached_images(text, images) if images else text
|
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(
|
result = agent.run_conversation(
|
||||||
prompt, conversation_history=list(history),
|
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, dict):
|
||||||
if isinstance(result.get("messages"), list):
|
if isinstance(result.get("messages"), list):
|
||||||
session["history"] = result["messages"]
|
session["history"] = result["messages"]
|
||||||
|
raw = result.get("final_response", "")
|
||||||
status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete"
|
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:
|
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:
|
except Exception as e:
|
||||||
_emit("error", sid, {"message": str(e)})
|
_emit("error", sid, {"message": str(e)})
|
||||||
|
|
||||||
|
|
@ -868,7 +897,12 @@ def _(rid, params: dict) -> dict:
|
||||||
return _err(rid, 4014, "hash required")
|
return _err(rid, 4014, "hash required")
|
||||||
try:
|
try:
|
||||||
r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, target))
|
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:
|
except Exception as e:
|
||||||
return _err(rid, 5022, str(e))
|
return _err(rid, 5022, str(e))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,19 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
}
|
}
|
||||||
}, [messages.length])
|
}, [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 msgBudget = Math.max(3, rows - 2 - (empty ? 0 : 2) - (thinking ? 2 : 0) - 2)
|
||||||
|
|
||||||
const viewport = useMemo(() => {
|
const viewport = useMemo(() => {
|
||||||
|
|
@ -144,6 +157,10 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
start = end - 1
|
start = end - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (start > 0 && messages[start - 1]?.role === 'user') {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
|
||||||
return { above: start, end, start }
|
return { above: start, end, start }
|
||||||
}, [cols, messages, msgBudget, scrollOffset])
|
}, [cols, messages, msgBudget, scrollOffset])
|
||||||
|
|
||||||
|
|
@ -155,7 +172,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const newSession = (msg?: string) =>
|
const newSession = (msg?: string) =>
|
||||||
rpc('session.create').then((r: any) => {
|
rpc('session.create', { cols }).then((r: any) => {
|
||||||
if (!r) {
|
if (!r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -534,7 +551,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
buf.current += p.text
|
buf.current += p.rendered ?? p.text
|
||||||
setThinking(false)
|
setThinking(false)
|
||||||
setTools([])
|
setTools([])
|
||||||
setReasoning('')
|
setReasoning('')
|
||||||
|
|
@ -543,7 +560,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
break
|
break
|
||||||
case 'message.complete': {
|
case 'message.complete': {
|
||||||
idle()
|
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 = ''
|
buf.current = ''
|
||||||
setStatus('ready')
|
setStatus('ready')
|
||||||
|
|
||||||
|
|
@ -1050,7 +1067,9 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
return
|
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
|
return true
|
||||||
|
|
@ -1239,6 +1258,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
if (r.blocked) {
|
if (r.blocked) {
|
||||||
return sys(r.hint ?? 'blocked')
|
return sys(r.hint ?? 'blocked')
|
||||||
}
|
}
|
||||||
|
|
||||||
sys(r.output ?? '(no output)')
|
sys(r.output ?? '(no output)')
|
||||||
|
|
||||||
if (r.code !== 0) {
|
if (r.code !== 0) {
|
||||||
|
|
@ -1548,7 +1568,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||||
onSelect={id => {
|
onSelect={id => {
|
||||||
setPicker(false)
|
setPicker(false)
|
||||||
setStatus('resuming…')
|
setStatus('resuming…')
|
||||||
gw.request('session.resume', { session_id: id })
|
gw.request('session.resume', { session_id: id, cols })
|
||||||
.then((r: any) => {
|
.then((r: any) => {
|
||||||
setSid(r.session_id)
|
setSid(r.session_id)
|
||||||
setMessages([])
|
setMessages([])
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Box, Text } from 'ink'
|
import { Box, Text } from 'ink'
|
||||||
|
|
||||||
import { LONG_MSG, ROLE } from '../constants.js'
|
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 { Theme } from '../theme.js'
|
||||||
import type { Msg } from '../types.js'
|
import type { Msg } from '../types.js'
|
||||||
|
|
||||||
|
|
@ -12,6 +12,10 @@ export function MessageLine({ compact, msg, t }: { compact?: boolean; msg: Msg;
|
||||||
|
|
||||||
const content = (() => {
|
const content = (() => {
|
||||||
if (msg.role === 'assistant') {
|
if (msg.role === 'assistant') {
|
||||||
|
if (hasAnsi(msg.text)) {
|
||||||
|
return <Text>{msg.text}</Text>
|
||||||
|
}
|
||||||
|
|
||||||
return <Md compact={compact} t={t} text={msg.text} />
|
return <Md compact={compact} t={t} text={msg.text} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import { INTERPOLATION_RE, LONG_MSG } from '../constants.js'
|
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) => {
|
export const compactPreview = (s: string, max: number) => {
|
||||||
const one = s.replace(/\s+/g, ' ').trim()
|
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) =>
|
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()
|
export const flat = (r: Record<string, string[]>) => Object.values(r).flat()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue