feat: add multiple terminal tabs with rename capability

Add support for multiple terminal instances per project with tabbed
navigation in the debug panel. Each terminal maintains its own PTY
session and WebSocket connection.

Backend changes:
- Add terminal metadata storage (id, name, created_at) per project
- Update terminal_manager.py with create, list, rename, delete functions
- Extend WebSocket endpoint to /api/terminal/ws/{project}/{terminal_id}
- Add REST endpoints for terminal CRUD operations
- Implement deferred PTY start with initial resize message

Frontend changes:
- Create TerminalTabs component with neobrutalism styling
- Support double-click rename and right-click context menu
- Fix terminal switching issues with transform-based hiding
- Use isActiveRef to prevent stale closure bugs in connect()
- Add double requestAnimationFrame for reliable activation timing
- Implement proper dimension validation in fitTerminal()

Other updates:
- Add GLM model configuration documentation to README
- Simplify client.py by removing CLI_COMMAND support
- Update chat session services with consistent patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-01-12 11:55:50 +02:00
parent c1985eb285
commit a7f8c3aa8d
16 changed files with 1032 additions and 194 deletions

View File

@@ -9,6 +9,9 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { ChevronUp, ChevronDown, Trash2, Terminal as TerminalIcon, GripHorizontal, Cpu, Server } from 'lucide-react'
import { Terminal } from './Terminal'
import { TerminalTabs } from './TerminalTabs'
import { listTerminals, createTerminal, renameTerminal, deleteTerminal } from '@/lib/api'
import type { TerminalInfo } from '@/lib/types'
const MIN_HEIGHT = 150
const MAX_HEIGHT = 600
@@ -61,6 +64,11 @@ export function DebugLogViewer({
return (saved as TabType) || 'agent'
})
// Terminal management state
const [terminals, setTerminals] = useState<TerminalInfo[]>([])
const [activeTerminalId, setActiveTerminalId] = useState<string | null>(null)
const [isLoadingTerminals, setIsLoadingTerminals] = useState(false)
// Use controlled tab if provided, otherwise use internal state
const activeTab = controlledActiveTab ?? internalActiveTab
const setActiveTab = (tab: TabType) => {
@@ -69,6 +77,91 @@ export function DebugLogViewer({
onTabChange?.(tab)
}
// Fetch terminals for the project
const fetchTerminals = useCallback(async () => {
if (!projectName) return
setIsLoadingTerminals(true)
try {
const terminalList = await listTerminals(projectName)
setTerminals(terminalList)
// Set active terminal to first one if not set or current one doesn't exist
if (terminalList.length > 0) {
if (!activeTerminalId || !terminalList.find((t) => t.id === activeTerminalId)) {
setActiveTerminalId(terminalList[0].id)
}
}
} catch (err) {
console.error('Failed to fetch terminals:', err)
} finally {
setIsLoadingTerminals(false)
}
}, [projectName, activeTerminalId])
// Handle creating a new terminal
const handleCreateTerminal = useCallback(async () => {
if (!projectName) return
try {
const newTerminal = await createTerminal(projectName)
setTerminals((prev) => [...prev, newTerminal])
setActiveTerminalId(newTerminal.id)
} catch (err) {
console.error('Failed to create terminal:', err)
}
}, [projectName])
// Handle renaming a terminal
const handleRenameTerminal = useCallback(
async (terminalId: string, newName: string) => {
if (!projectName) return
try {
const updated = await renameTerminal(projectName, terminalId, newName)
setTerminals((prev) =>
prev.map((t) => (t.id === terminalId ? updated : t))
)
} catch (err) {
console.error('Failed to rename terminal:', err)
}
},
[projectName]
)
// Handle closing a terminal
const handleCloseTerminal = useCallback(
async (terminalId: string) => {
if (!projectName || terminals.length <= 1) return
try {
await deleteTerminal(projectName, terminalId)
setTerminals((prev) => prev.filter((t) => t.id !== terminalId))
// If we closed the active terminal, switch to another one
if (activeTerminalId === terminalId) {
const remaining = terminals.filter((t) => t.id !== terminalId)
if (remaining.length > 0) {
setActiveTerminalId(remaining[0].id)
}
}
} catch (err) {
console.error('Failed to close terminal:', err)
}
},
[projectName, terminals, activeTerminalId]
)
// Fetch terminals when project changes
useEffect(() => {
if (projectName) {
fetchTerminals()
} else {
setTerminals([])
setActiveTerminalId(null)
}
}, [projectName]) // eslint-disable-line react-hooks/exhaustive-deps
// Auto-scroll to bottom when new agent logs arrive (if user hasn't scrolled up)
useEffect(() => {
if (autoScroll && scrollRef.current && isOpen && activeTab === 'agent') {
@@ -429,10 +522,61 @@ export function DebugLogViewer({
{/* Terminal Tab */}
{activeTab === 'terminal' && (
<Terminal
projectName={projectName}
isActive={activeTab === 'terminal'}
/>
<div className="h-full flex flex-col">
{/* Terminal tabs bar */}
{terminals.length > 0 && (
<TerminalTabs
terminals={terminals}
activeTerminalId={activeTerminalId}
onSelect={setActiveTerminalId}
onCreate={handleCreateTerminal}
onRename={handleRenameTerminal}
onClose={handleCloseTerminal}
/>
)}
{/* Terminal content - render all terminals and show/hide to preserve buffers */}
<div className="flex-1 min-h-0 relative">
{isLoadingTerminals ? (
<div className="h-full flex items-center justify-center text-gray-500 font-mono text-sm">
Loading terminals...
</div>
) : terminals.length === 0 ? (
<div className="h-full flex items-center justify-center text-gray-500 font-mono text-sm">
No terminal available
</div>
) : (
/* Render all terminals stacked on top of each other.
* Active terminal is visible and receives input.
* Inactive terminals are moved off-screen with transform to:
* 1. Trigger IntersectionObserver (xterm.js pauses rendering)
* 2. Preserve terminal buffer content
* 3. Allow proper dimension calculation when becoming visible
* Using transform instead of opacity/display:none for best xterm.js compatibility.
*/
terminals.map((terminal) => {
const isActiveTerminal = terminal.id === activeTerminalId
return (
<div
key={terminal.id}
className="absolute inset-0"
style={{
zIndex: isActiveTerminal ? 10 : 1,
transform: isActiveTerminal ? 'none' : 'translateX(-200%)',
pointerEvents: isActiveTerminal ? 'auto' : 'none',
}}
>
<Terminal
projectName={projectName}
terminalId={terminal.id}
isActive={activeTab === 'terminal' && isActiveTerminal}
/>
</div>
)
})
)}
</div>
</div>
)}
</div>
)}

View File

@@ -12,6 +12,7 @@ import '@xterm/xterm/css/xterm.css'
interface TerminalProps {
projectName: string
terminalId: string
isActive: boolean
}
@@ -69,7 +70,7 @@ const TERMINAL_THEME = {
const RECONNECT_DELAY_BASE = 1000
const RECONNECT_DELAY_MAX = 30000
export function Terminal({ projectName, isActive }: TerminalProps) {
export function Terminal({ projectName, terminalId, isActive }: TerminalProps) {
const containerRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<XTerm | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null)
@@ -83,8 +84,11 @@ export function Terminal({ projectName, isActive }: TerminalProps) {
const isManualCloseRef = useRef(false)
// Store connect function in ref to avoid useEffect dependency issues
const connectRef = useRef<(() => void) | null>(null)
// Track last project to avoid duplicate connect on initial activation
// Track last project/terminal to avoid duplicate connect on initial activation
const lastProjectRef = useRef<string | null>(null)
const lastTerminalIdRef = useRef<string | null>(null)
// Track isActive in a ref to avoid stale closure issues in connect()
const isActiveRef = useRef(isActive)
const [isConnected, setIsConnected] = useState(false)
const [hasExited, setHasExited] = useState(false)
@@ -95,6 +99,11 @@ export function Terminal({ projectName, isActive }: TerminalProps) {
hasExitedRef.current = hasExited
}, [hasExited])
// Keep isActiveRef in sync with isActive prop to avoid stale closures
useEffect(() => {
isActiveRef.current = isActive
}, [isActive])
/**
* Encode string to base64
*/
@@ -160,9 +169,27 @@ export function Terminal({ projectName, isActive }: TerminalProps) {
const fitTerminal = useCallback(() => {
if (fitAddonRef.current && terminalRef.current) {
try {
fitAddonRef.current.fit()
// Try to get proposed dimensions first
const dimensions = fitAddonRef.current.proposeDimensions()
const hasValidDimensions = dimensions &&
dimensions.cols &&
dimensions.rows &&
!isNaN(dimensions.cols) &&
!isNaN(dimensions.rows) &&
dimensions.cols >= 1 &&
dimensions.rows >= 1
if (hasValidDimensions) {
// Valid dimensions - fit the terminal
fitAddonRef.current.fit()
}
// Always send resize with current terminal dimensions
// This ensures the server has the correct size even if fit() was skipped
const { cols, rows } = terminalRef.current
sendResize(cols, rows)
if (cols > 0 && rows > 0) {
sendResize(cols, rows)
}
} catch {
// Container may not be visible yet, ignore
}
@@ -173,7 +200,9 @@ export function Terminal({ projectName, isActive }: TerminalProps) {
* Connect to the terminal WebSocket
*/
const connect = useCallback(() => {
if (!projectName || !isActive) return
// Use isActiveRef.current instead of isActive to avoid stale closure issues
// when connect is called from setTimeout callbacks
if (!projectName || !terminalId || !isActiveRef.current) return
// Prevent multiple simultaneous connection attempts
if (
@@ -192,10 +221,10 @@ export function Terminal({ projectName, isActive }: TerminalProps) {
reconnectTimeoutRef.current = null
}
// Build WebSocket URL
// Build WebSocket URL with terminal ID
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const wsUrl = `${protocol}//${host}/api/terminal/ws/${encodeURIComponent(projectName)}`
const wsUrl = `${protocol}//${host}/api/terminal/ws/${encodeURIComponent(projectName)}/${encodeURIComponent(terminalId)}`
try {
const ws = new WebSocket(wsUrl)
@@ -253,8 +282,8 @@ export function Terminal({ projectName, isActive }: TerminalProps) {
wsRef.current = null
// Only reconnect if still active, not intentionally exited, and not manually closed
// Use refs to avoid re-creating this callback when state changes
const shouldReconnect = isActive && !hasExitedRef.current && !isManualCloseRef.current
// Use isActiveRef.current to get the current value, avoiding stale closure
const shouldReconnect = isActiveRef.current && !hasExitedRef.current && !isManualCloseRef.current
// Reset manual close flag after checking (so subsequent disconnects can auto-reconnect)
isManualCloseRef.current = false
@@ -289,7 +318,7 @@ export function Terminal({ projectName, isActive }: TerminalProps) {
connect()
}, delay)
}
}, [projectName, isActive, sendResize, decodeBase64])
}, [projectName, terminalId, sendResize, decodeBase64])
// Keep connect ref up to date
useEffect(() => {
@@ -325,10 +354,9 @@ export function Terminal({ projectName, isActive }: TerminalProps) {
fitAddonRef.current = fitAddon
isInitializedRef.current = true
// Initial fit
setTimeout(() => {
fitTerminal()
}, 0)
// NOTE: Don't call fitTerminal() here - let the activation effect handle it
// after layout is fully calculated. This avoids dimension calculation issues
// when the container is first rendered.
// Handle keyboard input
terminal.onData((data) => {
@@ -353,7 +381,7 @@ export function Terminal({ projectName, isActive }: TerminalProps) {
terminal.onResize(({ cols, rows }) => {
sendResize(cols, rows)
})
}, [fitTerminal, encodeBase64, sendMessage, sendResize])
}, [encodeBase64, sendMessage, sendResize])
/**
* Handle window resize
@@ -376,43 +404,83 @@ export function Terminal({ projectName, isActive }: TerminalProps) {
*/
useEffect(() => {
if (!isActive) {
// Clean up when becoming inactive
// When becoming inactive, just clear reconnect timeout but keep WebSocket alive
// This preserves the terminal buffer and connection for when we switch back
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
// DO NOT close WebSocket here - keep it alive to preserve buffer
return
}
// Initialize terminal if not already done
if (!isInitializedRef.current) {
initializeTerminal()
} else {
// Re-fit when becoming active again
setTimeout(() => {
fitTerminal()
}, 0)
}
// Connect WebSocket using ref to avoid dependency on connect callback
connectRef.current?.()
}, [isActive, initializeTerminal, fitTerminal])
// Connect WebSocket if not already connected
// Use double rAF + timeout to ensure terminal is rendered with correct dimensions
// before connecting (the fit/refresh effect handles the actual fitting)
let rafId1: number
let rafId2: number
const connectIfNeeded = () => {
rafId1 = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
connectRef.current?.()
}
})
})
}
// Delay connection to ensure terminal dimensions are calculated first
const timeoutId = window.setTimeout(connectIfNeeded, 50)
return () => {
clearTimeout(timeoutId)
cancelAnimationFrame(rafId1)
cancelAnimationFrame(rafId2)
}
}, [isActive, initializeTerminal])
/**
* Fit terminal when isActive becomes true
* Fit and refresh terminal when isActive becomes true
*/
useEffect(() => {
if (isActive && terminalRef.current) {
// Small delay to ensure container is visible
const timeoutId = setTimeout(() => {
fitTerminal()
terminalRef.current?.focus()
}, 100)
return () => clearTimeout(timeoutId)
// Use double requestAnimationFrame to ensure:
// 1. First rAF: style changes are committed
// 2. Second rAF: layout is recalculated
// This is more reliable than setTimeout for visibility changes
let rafId1: number
let rafId2: number
const handleActivation = () => {
rafId1 = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
if (terminalRef.current && fitAddonRef.current) {
// Fit terminal to get correct dimensions
fitTerminal()
// Refresh the terminal to redraw content after becoming visible
// This fixes rendering issues when switching between terminals
terminalRef.current.refresh(0, terminalRef.current.rows - 1)
// Focus the terminal to receive keyboard input
terminalRef.current.focus()
}
})
})
}
// Small initial delay to ensure React has committed the style changes
const timeoutId = window.setTimeout(handleActivation, 16)
return () => {
clearTimeout(timeoutId)
cancelAnimationFrame(rafId1)
cancelAnimationFrame(rafId2)
}
}
}, [isActive, fitTerminal])
@@ -435,25 +503,27 @@ export function Terminal({ projectName, isActive }: TerminalProps) {
}, [])
/**
* Reconnect when project changes
* Reconnect when project or terminal changes
*/
useEffect(() => {
if (isActive && isInitializedRef.current) {
// Only reconnect if project actually changed, not on initial activation
// This prevents duplicate connect calls when both isActive and projectName effects run
if (lastProjectRef.current === null) {
// Initial activation - just track the project, don't reconnect (the isActive effect handles initial connect)
// Only reconnect if project or terminal actually changed, not on initial activation
// This prevents duplicate connect calls when both isActive and projectName/terminalId effects run
if (lastProjectRef.current === null && lastTerminalIdRef.current === null) {
// Initial activation - just track the project/terminal, don't reconnect (the isActive effect handles initial connect)
lastProjectRef.current = projectName
lastTerminalIdRef.current = terminalId
return
}
if (lastProjectRef.current === projectName) {
// Project didn't change, skip
if (lastProjectRef.current === projectName && lastTerminalIdRef.current === terminalId) {
// Nothing changed, skip
return
}
// Project changed - update tracking
// Project or terminal changed - update tracking
lastProjectRef.current = projectName
lastTerminalIdRef.current = terminalId
// Clear terminal and reset cursor position
if (terminalRef.current) {
@@ -476,10 +546,10 @@ export function Terminal({ projectName, isActive }: TerminalProps) {
setExitCode(null)
reconnectAttempts.current = 0
// Connect to new project using ref to avoid dependency on connect callback
// Connect to new project/terminal using ref to avoid dependency on connect callback
connectRef.current?.()
}
}, [projectName, isActive])
}, [projectName, terminalId, isActive])
return (
<div className="relative h-full w-full bg-[#1a1a1a]">
@@ -506,6 +576,10 @@ export function Terminal({ projectName, isActive }: TerminalProps) {
ref={containerRef}
className="h-full w-full p-2"
style={{ minHeight: '100px' }}
onClick={() => {
// Ensure terminal gets focus when container is clicked
terminalRef.current?.focus()
}}
/>
</div>
)

View File

@@ -0,0 +1,246 @@
/**
* Terminal Tabs Component
*
* Manages multiple terminal tabs with add, rename, and close functionality.
* Supports inline rename via double-click and context menu.
*/
import { useState, useRef, useEffect, useCallback } from 'react'
import { Plus, X } from 'lucide-react'
import type { TerminalInfo } from '@/lib/types'
interface TerminalTabsProps {
terminals: TerminalInfo[]
activeTerminalId: string | null
onSelect: (terminalId: string) => void
onCreate: () => void
onRename: (terminalId: string, newName: string) => void
onClose: (terminalId: string) => void
}
interface ContextMenuState {
visible: boolean
x: number
y: number
terminalId: string | null
}
export function TerminalTabs({
terminals,
activeTerminalId,
onSelect,
onCreate,
onRename,
onClose,
}: TerminalTabsProps) {
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
visible: false,
x: 0,
y: 0,
terminalId: null,
})
const inputRef = useRef<HTMLInputElement>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
// Focus input when editing starts
useEffect(() => {
if (editingId && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [editingId])
// Close context menu when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
contextMenuRef.current &&
!contextMenuRef.current.contains(e.target as Node)
) {
setContextMenu((prev) => ({ ...prev, visible: false }))
}
}
if (contextMenu.visible) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [contextMenu.visible])
// Start editing a terminal name
const startEditing = useCallback((terminal: TerminalInfo) => {
setEditingId(terminal.id)
setEditValue(terminal.name)
setContextMenu((prev) => ({ ...prev, visible: false }))
}, [])
// Handle edit submission
const submitEdit = useCallback(() => {
if (editingId && editValue.trim()) {
onRename(editingId, editValue.trim())
}
setEditingId(null)
setEditValue('')
}, [editingId, editValue, onRename])
// Cancel editing
const cancelEdit = useCallback(() => {
setEditingId(null)
setEditValue('')
}, [])
// Handle key events during editing
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
submitEdit()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelEdit()
}
},
[submitEdit, cancelEdit]
)
// Handle double-click to start editing
const handleDoubleClick = useCallback(
(terminal: TerminalInfo) => {
startEditing(terminal)
},
[startEditing]
)
// Handle context menu
const handleContextMenu = useCallback(
(e: React.MouseEvent, terminalId: string) => {
e.preventDefault()
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
terminalId,
})
},
[]
)
// Handle context menu actions
const handleContextMenuRename = useCallback(() => {
if (contextMenu.terminalId) {
const terminal = terminals.find((t) => t.id === contextMenu.terminalId)
if (terminal) {
startEditing(terminal)
}
}
}, [contextMenu.terminalId, terminals, startEditing])
const handleContextMenuClose = useCallback(() => {
if (contextMenu.terminalId) {
onClose(contextMenu.terminalId)
}
setContextMenu((prev) => ({ ...prev, visible: false }))
}, [contextMenu.terminalId, onClose])
// Handle tab close with confirmation if needed
const handleClose = useCallback(
(e: React.MouseEvent, terminalId: string) => {
e.stopPropagation()
onClose(terminalId)
},
[onClose]
)
return (
<div className="flex items-center gap-1 px-2 py-1 bg-[#2a2a2a] border-b-2 border-black overflow-x-auto">
{/* Terminal tabs */}
{terminals.map((terminal) => (
<div
key={terminal.id}
className={`
group flex items-center gap-1 px-3 py-1 border-2 border-black cursor-pointer
transition-colors duration-100 select-none min-w-0
${
activeTerminalId === terminal.id
? 'bg-neo-progress text-black'
: 'bg-[#3a3a3a] text-white hover:bg-[#4a4a4a]'
}
`}
onClick={() => onSelect(terminal.id)}
onDoubleClick={() => handleDoubleClick(terminal)}
onContextMenu={(e) => handleContextMenu(e, terminal.id)}
>
{editingId === terminal.id ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={submitEdit}
onKeyDown={handleKeyDown}
className="bg-white text-black px-1 py-0 text-sm font-mono border-2 border-black w-24 outline-none"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="text-sm font-mono truncate max-w-[120px]">
{terminal.name}
</span>
)}
{/* Close button */}
{terminals.length > 1 && (
<button
onClick={(e) => handleClose(e, terminal.id)}
className={`
p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity
${
activeTerminalId === terminal.id
? 'hover:bg-black/20'
: 'hover:bg-white/20'
}
`}
title="Close terminal"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
{/* Add new terminal button */}
<button
onClick={onCreate}
className="flex items-center justify-center w-8 h-8 border-2 border-black bg-[#3a3a3a] text-white hover:bg-[#4a4a4a] transition-colors"
title="New terminal"
>
<Plus className="w-4 h-4" />
</button>
{/* Context menu */}
{contextMenu.visible && (
<div
ref={contextMenuRef}
className="fixed z-50 bg-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] py-1 min-w-[120px]"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
onClick={handleContextMenuRename}
className="w-full px-3 py-1 text-left text-sm font-mono hover:bg-neo-progress hover:text-black transition-colors"
>
Rename
</button>
{terminals.length > 1 && (
<button
onClick={handleContextMenuClose}
className="w-full px-3 py-1 text-left text-sm font-mono hover:bg-neo-danger hover:text-white transition-colors"
>
Close
</button>
)}
</div>
)}
</div>
)
}

View File

@@ -23,6 +23,7 @@ import type {
ModelsResponse,
DevServerStatusResponse,
DevServerConfig,
TerminalInfo,
} from './types'
const API_BASE = '/api'
@@ -333,3 +334,41 @@ export async function stopDevServer(
export async function getDevServerConfig(projectName: string): Promise<DevServerConfig> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/devserver/config`)
}
// ============================================================================
// Terminal API
// ============================================================================
export async function listTerminals(projectName: string): Promise<TerminalInfo[]> {
return fetchJSON(`/terminal/${encodeURIComponent(projectName)}`)
}
export async function createTerminal(
projectName: string,
name?: string
): Promise<TerminalInfo> {
return fetchJSON(`/terminal/${encodeURIComponent(projectName)}`, {
method: 'POST',
body: JSON.stringify({ name: name ?? null }),
})
}
export async function renameTerminal(
projectName: string,
terminalId: string,
name: string
): Promise<TerminalInfo> {
return fetchJSON(`/terminal/${encodeURIComponent(projectName)}/${terminalId}`, {
method: 'PATCH',
body: JSON.stringify({ name }),
})
}
export async function deleteTerminal(
projectName: string,
terminalId: string
): Promise<void> {
await fetchJSON(`/terminal/${encodeURIComponent(projectName)}/${terminalId}`, {
method: 'DELETE',
})
}

View File

@@ -125,6 +125,13 @@ export interface DevServerConfig {
effective_command: string | null
}
// Terminal types
export interface TerminalInfo {
id: string
name: string
created_at: string
}
// WebSocket message types
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status'