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

@@ -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>
)
}