tui: inherit Python-side rendering via gateway bridge

This commit is contained in:
Brooklyn Nicholson 2026-04-05 18:50:41 -05:00
parent 0f556a17f5
commit f116c59071
5 changed files with 128 additions and 14 deletions

49
tui_gateway/render.py Normal file
View 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

View file

@ -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))

View file

@ -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([])

View file

@ -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} />
} }

View file

@ -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()