1+ 'use client'
2+
3+ import { Button } from './ui/button'
4+ import { Input } from './ui/input'
5+ import { ScrollArea } from './ui/scroll-area'
6+ import { ExecutionResult } from '@/lib/types'
7+ import { Terminal , X , Copy , RefreshCw } from 'lucide-react'
8+ import { useState , useRef , useEffect , KeyboardEvent } from 'react'
9+ import { CopyButton } from './ui/copy-button'
10+
11+ interface TerminalEntry {
12+ id : string
13+ command : string
14+ output : string
15+ error ?: string
16+ timestamp : Date
17+ isRunning ?: boolean
18+ }
19+
20+ interface FragmentTerminalProps {
21+ result : ExecutionResult
22+ teamID ?: string
23+ accessToken ?: string
24+ }
25+
26+ export function FragmentTerminal ( { result, teamID, accessToken } : FragmentTerminalProps ) {
27+ const [ entries , setEntries ] = useState < TerminalEntry [ ] > ( [ ] )
28+ const [ currentCommand , setCurrentCommand ] = useState ( '' )
29+ const [ isExecuting , setIsExecuting ] = useState ( false )
30+ const [ workingDirectory , setWorkingDirectory ] = useState ( '/home/user' )
31+ const scrollAreaRef = useRef < HTMLDivElement > ( null )
32+ const inputRef = useRef < HTMLInputElement > ( null )
33+
34+ useEffect ( ( ) => {
35+ // Auto-scroll to bottom when new entries are added
36+ if ( scrollAreaRef . current ) {
37+ scrollAreaRef . current . scrollTop = scrollAreaRef . current . scrollHeight
38+ }
39+ } , [ entries ] )
40+
41+ useEffect ( ( ) => {
42+ // Focus input when component mounts
43+ if ( inputRef . current ) {
44+ inputRef . current . focus ( )
45+ }
46+ } , [ ] )
47+
48+ const executeCommand = async ( command : string ) => {
49+ if ( ! command . trim ( ) || isExecuting ) return
50+
51+ const entryId = Date . now ( ) . toString ( )
52+ const newEntry : TerminalEntry = {
53+ id : entryId ,
54+ command : command . trim ( ) ,
55+ output : '' ,
56+ timestamp : new Date ( ) ,
57+ isRunning : true
58+ }
59+
60+ setEntries ( prev => [ ...prev , newEntry ] )
61+ setCurrentCommand ( '' )
62+ setIsExecuting ( true )
63+
64+ try {
65+ const response = await fetch ( '/api/terminal' , {
66+ method : 'POST' ,
67+ headers : {
68+ 'Content-Type' : 'application/json' ,
69+ } ,
70+ body : JSON . stringify ( {
71+ command : command . trim ( ) ,
72+ sbxId : result . sbxId ,
73+ workingDirectory,
74+ teamID,
75+ accessToken,
76+ } ) ,
77+ } )
78+
79+ const data = await response . json ( )
80+
81+ setEntries ( prev => prev . map ( entry =>
82+ entry . id === entryId
83+ ? {
84+ ...entry ,
85+ output : data . stdout || '' ,
86+ error : data . stderr || data . error ,
87+ isRunning : false
88+ }
89+ : entry
90+ ) )
91+
92+ // Update working directory if command was cd
93+ if ( command . trim ( ) . startsWith ( 'cd' ) && ! data . error && ! data . stderr ) {
94+ const pwdResponse = await fetch ( '/api/terminal' , {
95+ method : 'POST' ,
96+ headers : {
97+ 'Content-Type' : 'application/json' ,
98+ } ,
99+ body : JSON . stringify ( {
100+ command : 'pwd' ,
101+ sbxId : result . sbxId ,
102+ workingDirectory,
103+ teamID,
104+ accessToken,
105+ } ) ,
106+ } )
107+ const pwdData = await pwdResponse . json ( )
108+ if ( pwdData . stdout ) {
109+ setWorkingDirectory ( pwdData . stdout . trim ( ) )
110+ }
111+ }
112+
113+ } catch ( error ) {
114+ setEntries ( prev => prev . map ( entry =>
115+ entry . id === entryId
116+ ? {
117+ ...entry ,
118+ output : '' ,
119+ error : `Failed to execute command: ${ error } ` ,
120+ isRunning : false
121+ }
122+ : entry
123+ ) )
124+ }
125+
126+ setIsExecuting ( false )
127+ }
128+
129+ const handleKeyDown = ( e : KeyboardEvent < HTMLInputElement > ) => {
130+ if ( e . key === 'Enter' ) {
131+ e . preventDefault ( )
132+ executeCommand ( currentCommand )
133+ }
134+ }
135+
136+ const clearTerminal = ( ) => {
137+ setEntries ( [ ] )
138+ }
139+
140+ const copyAllOutput = ( ) => {
141+ const allOutput = entries . map ( entry => {
142+ const prompt = `${ workingDirectory } $ ${ entry . command } `
143+ const output = entry . error || entry . output
144+ return output ? `${ prompt } \n${ output } ` : prompt
145+ } ) . join ( '\n\n' )
146+
147+ navigator . clipboard . writeText ( allOutput )
148+ }
149+
150+ if ( ! result || result . template === 'code-interpreter-v1' ) {
151+ return (
152+ < div className = "flex items-center justify-center h-full text-muted-foreground" >
153+ < div className = "text-center space-y-2" >
154+ < Terminal className = "h-8 w-8 mx-auto opacity-50" />
155+ < p className = "text-sm" > Terminal not available for this fragment type</ p >
156+ </ div >
157+ </ div >
158+ )
159+ }
160+
161+ return (
162+ < div className = "flex flex-col h-full bg-background font-mono text-sm" >
163+ { /* Terminal Header */ }
164+ < div className = "flex items-center justify-between px-4 py-2 border-b bg-muted/50" >
165+ < div className = "flex items-center gap-2" >
166+ < Terminal className = "h-4 w-4" />
167+ < span className = "text-xs text-muted-foreground" >
168+ Terminal - { result . sbxId ?. slice ( 0 , 8 ) }
169+ </ span >
170+ </ div >
171+ < div className = "flex items-center gap-1" >
172+ < Button
173+ variant = "ghost"
174+ size = "sm"
175+ onClick = { copyAllOutput }
176+ disabled = { entries . length === 0 }
177+ className = "h-7 w-7 p-0"
178+ >
179+ < Copy className = "h-3 w-3" />
180+ </ Button >
181+ < Button
182+ variant = "ghost"
183+ size = "sm"
184+ onClick = { clearTerminal }
185+ disabled = { entries . length === 0 }
186+ className = "h-7 w-7 p-0"
187+ >
188+ < X className = "h-3 w-3" />
189+ </ Button >
190+ </ div >
191+ </ div >
192+
193+ { /* Terminal Content */ }
194+ < div className = "flex-1 flex flex-col min-h-0" >
195+ < ScrollArea ref = { scrollAreaRef } className = "flex-1 p-4" >
196+ < div className = "space-y-2" >
197+ { entries . length === 0 && (
198+ < div className = "text-muted-foreground text-xs" >
199+ Welcome to the terminal. Type commands to interact with your sandbox.
200+ </ div >
201+ ) }
202+
203+ { entries . map ( ( entry ) => (
204+ < div key = { entry . id } className = "space-y-1" >
205+ < div className = "flex items-center gap-2" >
206+ < span className = "text-green-500 dark:text-green-400" >
207+ { workingDirectory } $
208+ </ span >
209+ < span className = "text-foreground" > { entry . command } </ span >
210+ { entry . isRunning && (
211+ < RefreshCw className = "h-3 w-3 animate-spin text-muted-foreground" />
212+ ) }
213+ </ div >
214+
215+ { ( entry . output || entry . error ) && (
216+ < div className = "pl-4 border-l-2 border-muted" >
217+ { entry . error ? (
218+ < div className = "text-red-500 dark:text-red-400 whitespace-pre-wrap" >
219+ { entry . error }
220+ </ div >
221+ ) : (
222+ < div className = "text-muted-foreground whitespace-pre-wrap" >
223+ { entry . output }
224+ </ div >
225+ ) }
226+ </ div >
227+ ) }
228+ </ div >
229+ ) ) }
230+ </ div >
231+ </ ScrollArea >
232+
233+ { /* Command Input */ }
234+ < div className = "border-t bg-background p-4" >
235+ < div className = "flex items-center gap-2" >
236+ < span className = "text-green-500 dark:text-green-400 shrink-0" >
237+ { workingDirectory } $
238+ </ span >
239+ < Input
240+ ref = { inputRef }
241+ value = { currentCommand }
242+ onChange = { ( e ) => setCurrentCommand ( e . target . value ) }
243+ onKeyDown = { handleKeyDown }
244+ placeholder = "Enter command..."
245+ disabled = { isExecuting }
246+ className = "border-none bg-transparent font-mono text-sm focus-visible:ring-0 focus-visible:ring-offset-0"
247+ />
248+ { isExecuting && (
249+ < RefreshCw className = "h-4 w-4 animate-spin text-muted-foreground" />
250+ ) }
251+ </ div >
252+ </ div >
253+ </ div >
254+ </ div >
255+ )
256+ }
0 commit comments