feat: add configurable CLI command and UI improvements

Add support for alternative CLI commands via CLI_COMMAND environment
variable, allowing users to use CLIs other than 'claude' (e.g., 'glm').
This change affects all server services and the main CLI launcher.

Key changes:

- Configurable CLI command via CLI_COMMAND env var (defaults to 'claude')
- Configurable Playwright headless mode via PLAYWRIGHT_HEADLESS env var
- Pin claude-agent-sdk version to <0.2.0 for stability
- Use tail -500 for progress notes to avoid context overflow
- Add project delete functionality with confirmation dialog
- Replace single-line input with resizable textarea in spec chat
- Add coder agent configuration for code implementation tasks
- Ignore issues/ directory in git

Files modified:
- client.py: CLI command and Playwright headless configuration
- server/main.py, server/services/*: CLI command configuration
- start.py: CLI command configuration and error messages
- .env.example: Document new environment variables
- .gitignore: Ignore issues/ directory
- requirements.txt: Pin SDK version
- .claude/templates/*: Use tail -500 for progress notes
- ui/src/components/ProjectSelector.tsx: Add delete button
- ui/src/components/SpecCreationChat.tsx: Auto-resizing textarea
- ui/src/components/ConfirmDialog.tsx: New reusable dialog
- .claude/agents/coder.md: New coder agent configuration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-01-10 13:19:49 +02:00
parent a0f7e72361
commit 117ca89f08
16 changed files with 496 additions and 49 deletions

View File

@@ -0,0 +1,103 @@
/**
* ConfirmDialog Component
*
* A reusable confirmation dialog following the neobrutalism design system.
* Used to confirm destructive actions like deleting projects.
*/
import { AlertTriangle, X } from 'lucide-react'
interface ConfirmDialogProps {
isOpen: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'warning'
isLoading?: boolean
onConfirm: () => void
onCancel: () => void
}
export function ConfirmDialog({
isOpen,
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'danger',
isLoading = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
if (!isOpen) return null
const variantColors = {
danger: {
icon: 'var(--color-neo-danger)',
button: 'neo-btn-danger',
},
warning: {
icon: 'var(--color-neo-pending)',
button: 'neo-btn-warning',
},
}
const colors = variantColors[variant]
return (
<div className="neo-modal-backdrop" onClick={onCancel}>
<div
className="neo-modal w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b-3 border-[var(--color-neo-border)]">
<div className="flex items-center gap-3">
<div
className="p-2 border-2 border-[var(--color-neo-border)] shadow-[2px_2px_0px_rgba(0,0,0,1)]"
style={{ backgroundColor: colors.icon }}
>
<AlertTriangle size={20} className="text-white" />
</div>
<h2 className="font-display font-bold text-lg text-[#1a1a1a]">
{title}
</h2>
</div>
<button
onClick={onCancel}
className="neo-btn neo-btn-ghost p-2"
disabled={isLoading}
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-6">
<p className="text-[var(--color-neo-text-secondary)] mb-6">
{message}
</p>
{/* Actions */}
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="neo-btn"
disabled={isLoading}
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`neo-btn ${colors.button}`}
disabled={isLoading}
>
{isLoading ? 'Deleting...' : confirmLabel}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,7 +1,9 @@
import { useState } from 'react'
import { ChevronDown, Plus, FolderOpen, Loader2 } from 'lucide-react'
import { ChevronDown, Plus, FolderOpen, Loader2, Trash2 } from 'lucide-react'
import type { ProjectSummary } from '../lib/types'
import { NewProjectModal } from './NewProjectModal'
import { ConfirmDialog } from './ConfirmDialog'
import { useDeleteProject } from '../hooks/useProjects'
interface ProjectSelectorProps {
projects: ProjectSummary[]
@@ -18,12 +20,42 @@ export function ProjectSelector({
}: ProjectSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const [showNewProjectModal, setShowNewProjectModal] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<string | null>(null)
const deleteProject = useDeleteProject()
const handleProjectCreated = (projectName: string) => {
onSelectProject(projectName)
setIsOpen(false)
}
const handleDeleteClick = (e: React.MouseEvent, projectName: string) => {
// Prevent the click from selecting the project
e.stopPropagation()
setProjectToDelete(projectName)
}
const handleConfirmDelete = async () => {
if (!projectToDelete) return
try {
await deleteProject.mutateAsync(projectToDelete)
// If the deleted project was selected, clear the selection
if (selectedProject === projectToDelete) {
onSelectProject(null)
}
setProjectToDelete(null)
} catch (error) {
// Error is handled by the mutation, just close the dialog
console.error('Failed to delete project:', error)
setProjectToDelete(null)
}
}
const handleCancelDelete = () => {
setProjectToDelete(null)
}
const selectedProjectData = projects.find(p => p.name === selectedProject)
return (
@@ -70,28 +102,39 @@ export function ProjectSelector({
{projects.length > 0 ? (
<div className="max-h-[300px] overflow-auto">
{projects.map(project => (
<button
<div
key={project.name}
onClick={() => {
onSelectProject(project.name)
setIsOpen(false)
}}
className={`w-full neo-dropdown-item flex items-center justify-between ${
className={`flex items-center ${
project.name === selectedProject
? 'bg-[var(--color-neo-pending)]'
: ''
}`}
>
<span className="flex items-center gap-2">
<FolderOpen size={16} />
{project.name}
</span>
{project.stats.total > 0 && (
<span className="text-sm font-mono">
{project.stats.passing}/{project.stats.total}
<button
onClick={() => {
onSelectProject(project.name)
setIsOpen(false)
}}
className="flex-1 neo-dropdown-item flex items-center justify-between"
>
<span className="flex items-center gap-2">
<FolderOpen size={16} />
{project.name}
</span>
)}
</button>
{project.stats.total > 0 && (
<span className="text-sm font-mono">
{project.stats.passing}/{project.stats.total}
</span>
)}
</button>
<button
onClick={(e) => handleDeleteClick(e, project.name)}
className="p-2 mr-2 text-[var(--color-neo-text-secondary)] hover:text-[var(--color-neo-danger)] hover:bg-[var(--color-neo-danger)]/10 transition-colors rounded"
title={`Delete ${project.name}`}
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
) : (
@@ -124,6 +167,19 @@ export function ProjectSelector({
onClose={() => setShowNewProjectModal(false)}
onProjectCreated={handleProjectCreated}
/>
{/* Delete Confirmation Dialog */}
<ConfirmDialog
isOpen={projectToDelete !== null}
title="Delete Project"
message={`Are you sure you want to remove "${projectToDelete}" from the registry? This will unregister the project but preserve its files on disk.`}
confirmLabel="Delete"
cancelLabel="Cancel"
variant="danger"
isLoading={deleteProject.isPending}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</div>
)
}

View File

@@ -43,7 +43,7 @@ export function SpecCreationChat({
const [yoloEnabled, setYoloEnabled] = useState(false)
const [pendingAttachments, setPendingAttachments] = useState<ImageAttachment[]>([])
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const {
@@ -98,6 +98,10 @@ export function SpecCreationChat({
sendMessage(trimmed, pendingAttachments.length > 0 ? pendingAttachments : undefined)
setInput('')
setPendingAttachments([]) // Clear attachments after sending
// Reset textarea height after sending
if (inputRef.current) {
inputRef.current.style.height = 'auto'
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -355,11 +359,15 @@ export function SpecCreationChat({
<Paperclip size={18} />
</button>
<input
<textarea
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onChange={(e) => {
setInput(e.target.value)
// Auto-resize the textarea
e.target.style.height = 'auto'
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`
}}
onKeyDown={handleKeyDown}
placeholder={
currentQuestions
@@ -368,8 +376,9 @@ export function SpecCreationChat({
? 'Add a message with your image(s)...'
: 'Type your response... (or /exit to go to project)'
}
className="neo-input flex-1"
className="neo-input flex-1 resize-none min-h-[46px] max-h-[200px] overflow-y-auto"
disabled={(isLoading && !currentQuestions) || connectionStatus !== 'connected'}
rows={1}
/>
<button
onClick={handleSendMessage}
@@ -386,7 +395,7 @@ export function SpecCreationChat({
{/* Help text */}
<p className="text-xs text-[var(--color-neo-text-secondary)] mt-2">
Press Enter to send. Drag & drop or click <Paperclip size={12} className="inline" /> to attach images (JPEG/PNG, max 5MB).
Press Enter to send, Shift+Enter for new line. Drag & drop or click <Paperclip size={12} className="inline" /> to attach images (JPEG/PNG, max 5MB).
</p>
</div>
)}