Skip to content

Commit a271d25

Browse files
Merge pull request Gerome-Elassaad#13 from Gerome-Elassaad/12-terminal-feature-request
added termianl feature
2 parents fc0e8bf + 5a8de4c commit a271d25

File tree

13 files changed

+450
-19
lines changed

13 files changed

+450
-19
lines changed

.github/workflows/changelog.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
{% for group, commits in commits | group_by(attribute="group") %}
3636
### {{ group | upper_first }}
3737
{% for commit in commits %}
38-
- {% if commit.scope %}(**{{ commit.scope }}**) {% endif %}{{ commit.message | upper_first }} ([`{{ commit.id | truncate(length=7, end="") }}`](https://github.com/${{ github.repository }}/commit/{{ commit.id }}))\
38+
- {% if commit.scope %}(**{{ commit.scope }}**) {% endif %}{{ commit.message | upper_first }} ([`{{ commit.id | truncate(length=7, end="") }}`](https://github.com/{{ repository }}/commit/{{ commit.id }}))\
3939
{% endfor %}
4040
{% endfor %}\n
4141
"""

app/api/terminal/route.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { Sandbox } from '@e2b/code-interpreter'
2+
import { NextRequest, NextResponse } from 'next/server'
3+
4+
export const maxDuration = 60
5+
6+
export async function POST(req: NextRequest) {
7+
try {
8+
const {
9+
command,
10+
sbxId,
11+
workingDirectory = '/home/user',
12+
teamID,
13+
accessToken
14+
} = await req.json()
15+
16+
if (!command || !sbxId) {
17+
return NextResponse.json(
18+
{ error: 'Missing required parameters' },
19+
{ status: 400 }
20+
)
21+
}
22+
23+
// Connect to existing sandbox
24+
const sandbox = await Sandbox.connect(sbxId, {
25+
...(teamID && accessToken
26+
? {
27+
headers: {
28+
'X-Supabase-Team': teamID,
29+
'X-Supabase-Token': accessToken,
30+
},
31+
}
32+
: {}),
33+
})
34+
35+
const fullCommand = `cd "${workingDirectory}" && ${command}`
36+
37+
const result = await sandbox.commands.run(fullCommand, {
38+
timeoutMs: 30000, // 30 second timeout
39+
})
40+
41+
return NextResponse.json({
42+
stdout: result.stdout,
43+
stderr: result.stderr,
44+
exitCode: result.exitCode,
45+
workingDirectory,
46+
})
47+
48+
} catch (error: any) {
49+
console.error('Terminal command error:', error)
50+
51+
return NextResponse.json(
52+
{
53+
error: error.message || 'Failed to execute command',
54+
stderr: error.message || 'Command execution failed'
55+
},
56+
{ status: 500 }
57+
)
58+
}
59+
}

app/page.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,17 @@ export default function Home() {
4141
const [result, setResult] = useState<ExecutionResult>()
4242
const [messages, setMessages] = useState<Message[]>([])
4343
const [fragment, setFragment] = useState<DeepPartial<FragmentSchema>>()
44-
const [currentTab, setCurrentTab] = useState<'code' | 'fragment'>('code')
44+
const [currentTab, setCurrentTab] = useState<'code' | 'fragment' | 'terminal'>('code')
4545
const [isPreviewLoading, setIsPreviewLoading] = useState(false)
4646
const [isAuthDialogOpen, setAuthDialog] = useState(false)
4747
const [authView, setAuthView] = useState<ViewType>('sign_in')
4848
const [isRateLimited, setIsRateLimited] = useState(false)
4949
const [errorMessage, setErrorMessage] = useState('')
5050

51-
// Project management state
5251
const [currentProject, setCurrentProject] = useState<Project | null>(null)
5352
const [isLoadingProject, setIsLoadingProject] = useState(false)
5453

55-
// Sidebar open state
56-
const [isSidebarOpen, setSidebarOpen] = useState(false)
54+
const [sidebarOpen, setSidebarOpen] = useState(false)
5755

5856
const { session, userTeam } = useAuth(setAuthDialog, setAuthView)
5957

@@ -348,7 +346,6 @@ export default function Home() {
348346
userPlan={userTeam?.tier}
349347
/>
350348

351-
352349
{/* Main content with left margin to account for collapsed sidebar */}
353350
<div className={cn(
354351
"grid w-full md:grid-cols-2 transition-all duration-300",

components/fragment-terminal.tsx

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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+
}

components/logo.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ export default function Logo(
88
props: Omit<ComponentProps<typeof Image>, 'src' | 'alt'>
99
) {
1010
const { theme } = useTheme()
11-
const src = theme === 'dark' ? '/thirdparty/logo.png' : '/thirdparty/logo-dark.png'
11+
const src = theme === 'dark' ? '/logo.png' : '/logo-dark.png'
12+
const { width, style } = props
1213

1314
return (
1415
<Image
1516
src={src}
1617
alt="Logo"
17-
width={24}
18-
height={24}
1918
{...props}
19+
style={{ ...style, width, height: 'auto' }}
2020
/>
2121
)
2222
}

components/navbar.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,17 +115,14 @@ export function NavBar({
115115
<DropdownMenuSeparator />
116116
<DropdownMenuItem
117117
onClick={() => {
118-
window.open('https://codinit.dev/codinit-beta', '_blank')
118+
window.open('https://codinit.dev/blog/codinit-beta', '_blank')
119119
}}
120120
>
121-
<Image
122-
src={typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? '/thirdparty/logo-dark.png' : '/thirdparty/logo.png'}
121+
<Logo
123122
width={16}
124123
height={16}
125124
className="mr-2 text-muted-foreground"
126-
alt="CodinIT Logo"
127125
/>
128-
129126
About CodinIT
130127
</DropdownMenuItem>
131128
<DropdownMenuItem onClick={() => onSocialClick('github')}>

0 commit comments

Comments
 (0)