mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
246
ui/src/components/TerminalTabs.tsx
Normal file
246
ui/src/components/TerminalTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/confirmdialog.tsx","./src/components/debuglogviewer.tsx","./src/components/devservercontrol.tsx","./src/components/expandprojectchat.tsx","./src/components/expandprojectmodal.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/terminal.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/useexpandchat.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/confirmdialog.tsx","./src/components/debuglogviewer.tsx","./src/components/devservercontrol.tsx","./src/components/expandprojectchat.tsx","./src/components/expandprojectmodal.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/terminal.tsx","./src/components/terminaltabs.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/useexpandchat.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}
|
||||
Reference in New Issue
Block a user