diff --git a/tui_gateway/render.py b/tui_gateway/render.py new file mode 100644 index 000000000..c15ddef7c --- /dev/null +++ b/tui_gateway/render.py @@ -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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 84c86a054..d788b12a3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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)) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index dacfc9518..f170ea8cf 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -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([]) diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 36d86acc7..b2e8c914e 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -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 {msg.text} + } + return } diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 68aa468c1..a441841c2 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -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) => Object.values(r).flat()