Files
hermes-agent/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
T
alt-glitch 2bd9c9b881 opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when
HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config);
Ink stays the default and the shipping path is untouched.

- _resolve_tui_engine() (env > config > ink); refuses opentui on
  Windows/Termux (no Bun) -> falls back to ink with a notice.
- _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step).
- _bun_bin() with HERMES_BUN override.
- Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host
  must not bootstrap Node).
- Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun
  is JSC; the V8 flag errors/ignores).

Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI ->
real Python gateway streamed a real reply. No-flag default still ink.
2026-06-08 11:11:54 +00:00

123 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { TERMINAL_BG } from './selection'
import { TerminalTab } from './index'
/**
* One xterm Terminal mounted at the layout root and CSS-overlayed onto
* whichever `<TerminalSlot />` is active. Moving the host DOM detaches xterm's
* WebGL renderer (it observes its own attachment) and resets the screen, so
* the host stays put and we chase the slot's bounding rect with position:fixed.
*/
const $slot = atom<HTMLElement | null>(null)
const SLOT_CLASS = 'relative flex min-h-0 min-w-0 flex-1 flex-col'
export function TerminalSlot({ className = SLOT_CLASS }: { className?: string }) {
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const el = ref.current
if (!el) {
return
}
$slot.set(el)
return () => {
if ($slot.get() === el) {
$slot.set(null)
}
}
}, [])
return <div className={className} ref={ref} />
}
interface PersistentTerminalProps {
cwd: string
onAddSelectionToChat: (text: string, label?: string) => void
}
interface Rect {
top: number
left: number
width: number
height: number
}
const sameRect = (a: Rect | null, b: Rect) =>
!!a && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height
export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerminalProps) {
const slot = useStore($slot)
const [rect, setRect] = useState<Rect | null>(null)
const [ready, setReady] = useState(false)
useLayoutEffect(() => {
if (!slot) {
setRect(null)
return
}
let prev: Rect | null = null
let frame = 0
const tick = () => {
const r = slot.getBoundingClientRect()
// floor top/left + ceil right/bottom: overlay always covers the slot's
// full pixel footprint, so half-pixel rects can't leak page bg through.
const top = Math.floor(r.top)
const left = Math.floor(r.left)
const next: Rect = { top, left, width: Math.ceil(r.right) - left, height: Math.ceil(r.bottom) - top }
if (!sameRect(prev, next)) {
prev = next
setRect(next)
if (next.width > 0 && next.height > 0) {
setReady(true)
}
}
frame = requestAnimationFrame(tick)
}
tick()
return () => cancelAnimationFrame(frame)
}, [slot])
const visible = Boolean(rect && rect.width > 0 && rect.height > 0)
const style: CSSProperties = {
position: 'fixed',
top: rect?.top ?? 0,
left: rect?.left ?? 0,
width: rect?.width ?? 0,
height: rect?.height ?? 0,
display: 'flex',
flexDirection: 'column',
visibility: visible ? 'visible' : 'hidden',
pointerEvents: visible ? 'auto' : 'none',
zIndex: 4,
backgroundColor: TERMINAL_BG,
contain: 'layout size paint'
}
// Defer mount until real dims — booting xterm at 0×0 starts the shell at
// 80×24, then the first ResizeObserver SIGWINCH redraws the prompt on a
// new line. After first measurement we keep it mounted forever.
return (
<div aria-hidden={!visible} style={style}>
{ready && <TerminalTab cwd={cwd} onAddSelectionToChat={onAddSelectionToChat} />}
</div>
)
}