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