|
1 | 1 | "use client"; |
2 | 2 |
|
3 | | -import { useEffect, useRef } from "react"; |
| 3 | +import { useEffect, useRef, useState, useCallback } from "react"; |
| 4 | +import Ansi from "ansi-to-react"; |
4 | 5 | import type { useTerminal } from "@/hooks/useTerminal"; |
5 | 6 |
|
6 | 7 | interface TerminalInstanceProps { |
7 | 8 | terminal: ReturnType<typeof useTerminal>; |
8 | 9 | } |
9 | 10 |
|
10 | 11 | /** |
11 | | - * TerminalInstance — renders terminal output as a simple text view. |
| 12 | + * TerminalInstance — renders terminal output with ANSI color support. |
12 | 13 | * |
13 | | - * Phase 4 v1: Uses a simple <pre> element for output display. |
14 | | - * xterm.js integration can be added later for full terminal emulation |
15 | | - * (requires npm install xterm @xterm/addon-fit). |
| 14 | + * Uses ansi-to-react for ANSI escape code rendering. |
| 15 | + * xterm.js integration can be added later for full terminal emulation. |
16 | 16 | */ |
17 | 17 | export function TerminalInstance({ terminal }: TerminalInstanceProps) { |
18 | | - const outputRef = useRef<HTMLPreElement>(null); |
| 18 | + const { isElectron, connected, exited, create, write, setOnData } = terminal; |
| 19 | + const scrollRef = useRef<HTMLDivElement>(null); |
19 | 20 | const inputRef = useRef<HTMLInputElement>(null); |
20 | | - const outputTextRef = useRef(""); |
| 21 | + const [output, setOutput] = useState(""); |
| 22 | + const bufferRef = useRef(""); |
| 23 | + const rafRef = useRef<number | null>(null); |
21 | 24 |
|
| 25 | + // Flush buffered output via rAF to avoid excessive re-renders |
| 26 | + const flush = useCallback(() => { |
| 27 | + rafRef.current = null; |
| 28 | + setOutput(bufferRef.current); |
| 29 | + }, []); |
| 30 | + |
| 31 | + // Subscribe to PTY output |
22 | 32 | useEffect(() => { |
23 | | - terminal.setOnData((data: string) => { |
24 | | - outputTextRef.current += data; |
25 | | - if (outputRef.current) { |
26 | | - outputRef.current.textContent = outputTextRef.current; |
27 | | - outputRef.current.scrollTop = outputRef.current.scrollHeight; |
| 33 | + setOnData((data: string) => { |
| 34 | + bufferRef.current += data; |
| 35 | + if (rafRef.current === null) { |
| 36 | + rafRef.current = requestAnimationFrame(flush); |
28 | 37 | } |
29 | 38 | }); |
30 | | - }, [terminal]); |
| 39 | + return () => { |
| 40 | + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); |
| 41 | + }; |
| 42 | + }, [setOnData, flush]); |
| 43 | + |
| 44 | + // Auto-scroll on new output |
| 45 | + useEffect(() => { |
| 46 | + if (scrollRef.current) { |
| 47 | + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; |
| 48 | + } |
| 49 | + }, [output]); |
31 | 50 |
|
32 | 51 | // Create terminal on mount |
33 | 52 | useEffect(() => { |
34 | | - if (terminal.isElectron && !terminal.connected && !terminal.exited) { |
35 | | - terminal.create(120, 30); |
| 53 | + if (isElectron && !connected && !exited) { |
| 54 | + create(120, 30); |
36 | 55 | } |
37 | | - }, [terminal]); |
| 56 | + }, [isElectron, connected, exited, create]); |
| 57 | + |
| 58 | + // Focus input when terminal connects |
| 59 | + useEffect(() => { |
| 60 | + inputRef.current?.focus(); |
| 61 | + }, [connected]); |
38 | 62 |
|
39 | 63 | const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { |
40 | 64 | if (e.key === 'Enter') { |
41 | 65 | const value = inputRef.current?.value || ''; |
42 | | - terminal.write(value + '\n'); |
| 66 | + write(value + '\n'); |
43 | 67 | if (inputRef.current) inputRef.current.value = ''; |
44 | 68 | } |
45 | 69 | }; |
46 | 70 |
|
| 71 | + const handleContainerClick = () => { |
| 72 | + inputRef.current?.focus(); |
| 73 | + }; |
| 74 | + |
47 | 75 | return ( |
48 | | - <div className="flex flex-col h-full bg-[#1a1a1a] text-[#d4d4d4] font-mono text-xs"> |
49 | | - <pre |
50 | | - ref={outputRef} |
| 76 | + <div |
| 77 | + className="flex flex-col h-full bg-[#1a1a1a] text-[#d4d4d4] font-mono text-xs" |
| 78 | + onClick={handleContainerClick} |
| 79 | + > |
| 80 | + <div |
| 81 | + ref={scrollRef} |
51 | 82 | className="flex-1 overflow-auto p-2 whitespace-pre-wrap break-all" |
52 | | - /> |
| 83 | + > |
| 84 | + <Ansi>{output}</Ansi> |
| 85 | + </div> |
53 | 86 | <div className="flex items-center border-t border-[#333] px-2"> |
54 | 87 | <span className="text-green-400 mr-1">$</span> |
55 | 88 | <input |
56 | 89 | ref={inputRef} |
57 | 90 | type="text" |
58 | | - className="flex-1 bg-transparent border-none outline-none text-xs py-1.5 text-[#d4d4d4]" |
| 91 | + className="flex-1 bg-transparent border-none outline-none text-xs py-1.5 text-[#d4d4d4] caret-[#d4d4d4]" |
59 | 92 | onKeyDown={handleKeyDown} |
60 | 93 | autoFocus |
61 | 94 | spellCheck={false} |
|
0 commit comments