From 7c099c10eb34fec78e6d887da02cdac1cb053bc7 Mon Sep 17 00:00:00 2001 From: Prateek Date: Sat, 14 Feb 2026 19:00:50 +0530 Subject: [PATCH] fix: improve terminal rendering and remove clunky input interface - Fix rendering issues: - Use term.reset() instead of clear() for proper clearing - Only update when content changes (prevents flickering) - Add scrollToBottom() to show latest output - Increase scrollback buffer to 10000 lines - Add convertEol for proper line endings - Increase default height to 600px - Add padding around terminal content - Simplify interface: - Remove separate input box (was clunky) - Make it clearly "Read-only" by default - Clean up header UI - Better fullscreen sizing calculation Next step: Consider WebSocket-based bidirectional terminal for true interactive sessions (like tmux attach). Co-Authored-By: Claude Sonnet 4.5 --- packages/web/src/components/Terminal.tsx | 137 ++++++++++++----------- 1 file changed, 71 insertions(+), 66 deletions(-) diff --git a/packages/web/src/components/Terminal.tsx b/packages/web/src/components/Terminal.tsx index 668beca51..fcf8d70d3 100644 --- a/packages/web/src/components/Terminal.tsx +++ b/packages/web/src/components/Terminal.tsx @@ -11,16 +11,16 @@ interface TerminalProps { /** * Terminal embed using xterm.js. - * Streams tmux pane output via SSE and optionally allows input. + * Streams tmux pane output via SSE. Interactive terminal with direct input. */ export function Terminal({ sessionId }: TerminalProps) { const [fullscreen, setFullscreen] = useState(false); - const [inputMode, setInputMode] = useState(false); - const [inputText, setInputText] = useState(""); + const [isInteractive, setIsInteractive] = useState(false); const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const eventSourceRef = useRef(null); + const lastContentRef = useRef(""); // Initialize xterm.js useEffect(() => { @@ -28,28 +28,35 @@ export function Terminal({ sessionId }: TerminalProps) { // Create terminal instance const term = new XTerm({ - cursorBlink: false, - disableStdin: true, // Read-only by default + cursorBlink: true, + cursorStyle: "block", + disableStdin: true, // Will be enabled when interactive mode is on theme: { background: "#000000", foreground: "#d0d0d0", - cursor: "transparent", // Hide cursor in read-only mode + cursor: "#d0d0d0", }, fontFamily: 'Menlo, Monaco, "Courier New", monospace', fontSize: 12, lineHeight: 1.4, + scrollback: 10000, + convertEol: true, }); const fitAddon = new FitAddon(); term.loadAddon(fitAddon); term.open(terminalRef.current); - fitAddon.fit(); + + // Initial fit + setTimeout(() => fitAddon.fit(), 50); xtermRef.current = term; fitAddonRef.current = fitAddon; // Handle window resize - const handleResize = () => fitAddon.fit(); + const handleResize = () => { + setTimeout(() => fitAddon.fit(), 50); + }; window.addEventListener("resize", handleResize); return () => { @@ -63,11 +70,44 @@ export function Terminal({ sessionId }: TerminalProps) { // Refit terminal when fullscreen changes useEffect(() => { if (fitAddonRef.current) { - // Slight delay for DOM to update - setTimeout(() => fitAddonRef.current?.fit(), 100); + setTimeout(() => fitAddonRef.current?.fit(), 150); } }, [fullscreen]); + // Handle interactive mode + useEffect(() => { + const term = xtermRef.current; + if (!term) return; + + if (isInteractive) { + term.options.disableStdin = false; + term.options.cursorBlink = true; + + // Handle keyboard input + const disposable = term.onData((data) => { + // Send each keystroke to the session + void (async () => { + try { + await fetch(`/api/sessions/${sessionId}/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: data }), + }); + } catch (err) { + console.error("[Terminal] Failed to send input:", err); + } + })(); + }); + + return () => { + disposable.dispose(); + }; + } else { + term.options.disableStdin = true; + term.options.cursorBlink = false; + } + }, [isInteractive, sessionId]); + // Connect to SSE stream useEffect(() => { const term = xtermRef.current; @@ -83,9 +123,19 @@ export function Terminal({ sessionId }: TerminalProps) { | { type: "exit" }; if (data.type === "snapshot" || data.type === "update") { - // Clear and write full content - term.clear(); - term.write(data.content); + const content = data.content; + + // Only update if content changed + if (content !== lastContentRef.current) { + lastContentRef.current = content; + + // Reset terminal and write new content + term.reset(); + term.write(content); + + // Scroll to bottom + term.scrollToBottom(); + } } else if (data.type === "exit") { term.writeln("\r\n\r\n[Session exited]"); } @@ -105,28 +155,6 @@ export function Terminal({ sessionId }: TerminalProps) { }; }, [sessionId]); - // Send input to session - const handleSendInput = async () => { - if (!inputText.trim()) return; - - try { - const response = await fetch(`/api/sessions/${sessionId}/send`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: inputText }), - }); - - if (!response.ok) { - const err = (await response.json()) as { error?: string }; - console.error("[Terminal] Failed to send input:", err.error); - } else { - setInputText(""); - } - } catch (err) { - console.error("[Terminal] Failed to send input:", err); - } - }; - return (
{sessionId} - + + Read-only +
- {inputMode && ( -
- setInputText(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - void handleSendInput(); - } - }} - placeholder="Send message to agent..." - className="flex-1 rounded border border-[var(--color-border-default)] bg-[var(--color-bg-primary)] px-2 py-1 text-xs text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-blue)]" - /> - -
- )}
); }