mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-09 10:59:47 +00:00
feat: Update README to include Cursor CLI support and enhance chat message handling with streaming improvements
This commit is contained in:
23
README.md
23
README.md
@@ -4,7 +4,7 @@
|
||||
</div>
|
||||
|
||||
|
||||
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), Anthropic's official CLI for AI-assisted coding. You can use it locally or remotely to view your active projects and sessions in claude code and make changes to them the same way you would do it in claude code CLI. This gives you a proper interface that works everywhere.
|
||||
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), and [Cursor CLI](https://docs.cursor.com/en/cli/overview). You can use it locally or remotely to view your active projects and sessions in Claude Code or Cursor and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere. Supports models including **Claude Sonnet 4**, **Opus 4.1**, and **GPT-5**
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -25,6 +25,14 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
<em>Responsive mobile design with touch navigation</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<h3>CLI Selection</h3>
|
||||
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
|
||||
<br>
|
||||
<em>Select between Claude Code and Cursor CLI</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -34,11 +42,12 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
## Features
|
||||
|
||||
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code from mobile
|
||||
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code
|
||||
- **Integrated Shell Terminal** - Direct access to Claude Code CLI through built-in shell functionality
|
||||
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code or Cursor
|
||||
- **Integrated Shell Terminal** - Direct access to Claude Code or Cursor CLI through built-in shell functionality
|
||||
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
||||
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
- **Model Compatibility** - Works with Claude Sonnet 4, Opus 4.1, and GPT-5
|
||||
|
||||
|
||||
## Quick Start
|
||||
@@ -46,7 +55,8 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) v20 or higher
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured
|
||||
|
||||
### Installation
|
||||
|
||||
@@ -108,9 +118,10 @@ The UI automatically discovers Claude Code projects from `~/.claude/projects/` a
|
||||
- **Visual Project Browser** - All available projects with metadata and session counts
|
||||
- **Project Actions** - Rename, delete, and organize projects
|
||||
- **Smart Navigation** - Quick access to recent projects and sessions
|
||||
- **MCP support** - Add your own MCP servers through the UI
|
||||
|
||||
#### Chat Interface
|
||||
- **Use responsive chat or Claude Code CLI** - You can either use the adapted chat interface or use the shell button to connect to Claude Code CLI.
|
||||
- **Use responsive chat or Claude Code/Cursor CLI** - You can either use the adapted chat interface or use the shell button to connect to your selected CLI.
|
||||
- **Real-time Communication** - Stream responses from Claude with WebSocket connection
|
||||
- **Session Management** - Resume previous conversations or start fresh sessions
|
||||
- **Message History** - Complete conversation history with timestamps and metadata
|
||||
@@ -152,7 +163,7 @@ The UI automatically discovers Claude Code projects from `~/.claude/projects/` a
|
||||
### Backend (Node.js + Express)
|
||||
- **Express Server** - RESTful API with static file serving
|
||||
- **WebSocket Server** - Communication for chats and project refresh
|
||||
- **Claude CLI Integration** - Process spawning and management
|
||||
- **CLI Integration (Claude Code / Cursor)** - Process spawning and management
|
||||
- **Session Management** - JSONL parsing and conversation persistence
|
||||
- **File System API** - Exposing file browser for projects
|
||||
|
||||
|
||||
BIN
public/screenshots/cli-selection.png
Normal file
BIN
public/screenshots/cli-selection.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
@@ -26,8 +26,8 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
const args = [];
|
||||
|
||||
// Build flags allowing both resume and prompt together (reply in existing session)
|
||||
if (resume && sessionId) {
|
||||
// Resume existing session
|
||||
// Treat presence of sessionId as intention to resume, regardless of resume flag
|
||||
if (sessionId) {
|
||||
args.push('--resume=' + sessionId);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
args.push('-p', command);
|
||||
|
||||
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
|
||||
if (!resume && model) {
|
||||
if (!sessionId && model) {
|
||||
args.push('--model', model);
|
||||
}
|
||||
|
||||
|
||||
@@ -1151,6 +1151,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const messagesEndRef = useRef(null);
|
||||
const textareaRef = useRef(null);
|
||||
const scrollContainerRef = useRef(null);
|
||||
// Streaming throttle buffers
|
||||
const streamBufferRef = useRef('');
|
||||
const streamTimerRef = useRef(null);
|
||||
const [debouncedInput, setDebouncedInput] = useState('');
|
||||
const [showFileDropdown, setShowFileDropdown] = useState(false);
|
||||
const [fileList, setFileList] = useState([]);
|
||||
@@ -1839,7 +1842,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
};
|
||||
|
||||
loadMessages();
|
||||
}, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange, isLoading]);
|
||||
}, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]);
|
||||
|
||||
// Update chatMessages when convertedMessages changes
|
||||
useEffect(() => {
|
||||
@@ -1910,27 +1913,56 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Handle Cursor streaming format (content_block_delta / content_block_stop)
|
||||
if (messageData && typeof messageData === 'object' && messageData.type) {
|
||||
if (messageData.type === 'content_block_delta' && messageData.delta?.text) {
|
||||
setChatMessages(prev => {
|
||||
// Check if the last message is an assistant message we can append to
|
||||
if (prev.length > 0 && prev[prev.length - 1].type === 'assistant' && !prev[prev.length - 1].isToolUse) {
|
||||
// Append to the last assistant message
|
||||
const updatedMessages = [...prev];
|
||||
const lastMessage = updatedMessages[updatedMessages.length - 1];
|
||||
lastMessage.content = (lastMessage.content || '') + messageData.delta.text;
|
||||
return updatedMessages;
|
||||
} else {
|
||||
// Create a new assistant message for the first delta
|
||||
return [...prev, {
|
||||
type: 'assistant',
|
||||
content: messageData.delta.text,
|
||||
timestamp: new Date()
|
||||
}];
|
||||
}
|
||||
});
|
||||
// Buffer deltas and flush periodically to reduce rerenders
|
||||
streamBufferRef.current += messageData.delta.text;
|
||||
if (!streamTimerRef.current) {
|
||||
streamTimerRef.current = setTimeout(() => {
|
||||
const chunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
streamTimerRef.current = null;
|
||||
if (!chunk) return;
|
||||
setChatMessages(prev => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||||
last.content = (last.content || '') + chunk;
|
||||
} else {
|
||||
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (messageData.type === 'content_block_stop') {
|
||||
// Nothing specific to do; leave as-is
|
||||
// Flush any buffered text and mark streaming message complete
|
||||
if (streamTimerRef.current) {
|
||||
clearTimeout(streamTimerRef.current);
|
||||
streamTimerRef.current = null;
|
||||
}
|
||||
const chunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
if (chunk) {
|
||||
setChatMessages(prev => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||||
last.content = (last.content || '') + chunk;
|
||||
} else {
|
||||
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
setChatMessages(prev => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last && last.type === 'assistant' && last.isStreaming) {
|
||||
last.isStreaming = false;
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2075,11 +2107,30 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
break;
|
||||
|
||||
case 'claude-output':
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: latestMessage.data,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
{
|
||||
const cleaned = String(latestMessage.data || '');
|
||||
if (cleaned.trim()) {
|
||||
streamBufferRef.current += (streamBufferRef.current ? `\n${cleaned}` : cleaned);
|
||||
if (!streamTimerRef.current) {
|
||||
streamTimerRef.current = setTimeout(() => {
|
||||
const chunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
streamTimerRef.current = null;
|
||||
if (!chunk) return;
|
||||
setChatMessages(prev => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||||
last.content = last.content ? `${last.content}\n${chunk}` : chunk;
|
||||
} else {
|
||||
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'claude-interactive-prompt':
|
||||
// Handle interactive prompts from CLI
|
||||
@@ -2163,13 +2214,28 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
try {
|
||||
const r = latestMessage.data || {};
|
||||
const textResult = typeof r.result === 'string' ? r.result : '';
|
||||
if (textResult && textResult.trim()) {
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: r.is_error ? 'error' : 'assistant',
|
||||
content: textResult,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
// Flush buffered deltas before finalizing
|
||||
if (streamTimerRef.current) {
|
||||
clearTimeout(streamTimerRef.current);
|
||||
streamTimerRef.current = null;
|
||||
}
|
||||
const pendingChunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
|
||||
setChatMessages(prev => {
|
||||
const updated = [...prev];
|
||||
// Try to consolidate into the last streaming assistant message
|
||||
const last = updated[updated.length - 1];
|
||||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||||
// Replace streaming content with the final content so deltas don't remain
|
||||
const finalContent = textResult && textResult.trim() ? textResult : (last.content || '') + (pendingChunk || '');
|
||||
last.content = finalContent;
|
||||
last.isStreaming = false;
|
||||
} else if (textResult && textResult.trim()) {
|
||||
updated.push({ type: r.is_error ? 'error' : 'assistant', content: textResult, timestamp: new Date(), isStreaming: false });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Error handling cursor-result message:', e);
|
||||
}
|
||||
@@ -2198,23 +2264,25 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const raw = String(latestMessage.data ?? '');
|
||||
const cleaned = raw.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '').trim();
|
||||
if (cleaned) {
|
||||
setChatMessages(prev => {
|
||||
// If the last message is from assistant and not a tool use, append to it
|
||||
if (prev.length > 0 && prev[prev.length - 1].type === 'assistant' && !prev[prev.length - 1].isToolUse) {
|
||||
const updatedMessages = [...prev];
|
||||
const lastMessage = updatedMessages[updatedMessages.length - 1];
|
||||
// Append with a newline if there's already content
|
||||
lastMessage.content = lastMessage.content ? `${lastMessage.content}\n${cleaned}` : cleaned;
|
||||
return updatedMessages;
|
||||
} else {
|
||||
// Otherwise create a new assistant message
|
||||
return [...prev, {
|
||||
type: 'assistant',
|
||||
content: cleaned,
|
||||
timestamp: new Date()
|
||||
}];
|
||||
}
|
||||
});
|
||||
streamBufferRef.current += (streamBufferRef.current ? `\n${cleaned}` : cleaned);
|
||||
if (!streamTimerRef.current) {
|
||||
streamTimerRef.current = setTimeout(() => {
|
||||
const chunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
streamTimerRef.current = null;
|
||||
if (!chunk) return;
|
||||
setChatMessages(prev => {
|
||||
const updated = [...prev];
|
||||
const last = updated[updated.length - 1];
|
||||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||||
last.content = last.content ? `${last.content}\n${chunk}` : chunk;
|
||||
} else {
|
||||
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error handling cursor-output message:', e);
|
||||
@@ -2636,12 +2704,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
setIsUserScrolledUp(false); // Reset scroll state so auto-scroll works for Claude's response
|
||||
setTimeout(() => scrollToBottom(), 100); // Longer delay to ensure message is rendered
|
||||
|
||||
// Determine effective session id for replies to avoid race on state updates
|
||||
const effectiveSessionId = currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
||||
|
||||
// Session Protection: Mark session as active to prevent automatic project updates during conversation
|
||||
// This is crucial for maintaining chat state integrity. We handle two cases:
|
||||
// 1. Existing sessions: Use the real currentSessionId
|
||||
// 2. New sessions: Generate temporary identifier "new-session-{timestamp}" since real ID comes via WebSocket later
|
||||
// This ensures no gap in protection between message send and session creation
|
||||
const sessionToActivate = currentSessionId || `new-session-${Date.now()}`;
|
||||
// Use existing session if available; otherwise a temporary placeholder until backend provides real ID
|
||||
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
|
||||
if (onSessionActive) {
|
||||
onSessionActive(sessionToActivate);
|
||||
}
|
||||
@@ -2672,13 +2740,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
sendMessage({
|
||||
type: 'cursor-command',
|
||||
command: input,
|
||||
sessionId: currentSessionId,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
// Prefer fullPath (actual cwd for project), fallback to path
|
||||
cwd: selectedProject.fullPath || selectedProject.path,
|
||||
projectPath: selectedProject.fullPath || selectedProject.path,
|
||||
sessionId: currentSessionId,
|
||||
resume: !!currentSessionId,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: !!effectiveSessionId,
|
||||
model: cursorModel,
|
||||
skipPermissions: toolsSettings?.skipPermissions || false,
|
||||
toolsSettings: toolsSettings
|
||||
@@ -3073,7 +3141,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
<div className="chat-message assistant">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-gray-600">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-transparent">
|
||||
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? (
|
||||
<CursorLogo className="w-full h-full" />
|
||||
) : (
|
||||
@@ -3104,7 +3172,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-16 sm:pb-4 md:pb-6'
|
||||
}`}>
|
||||
|
||||
|
||||
<div className="flex-1">
|
||||
<ClaudeStatus
|
||||
status={claudeStatus}
|
||||
isLoading={isLoading}
|
||||
onAbort={handleAbortSession}
|
||||
provider={provider}
|
||||
/>
|
||||
</div>
|
||||
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
|
||||
<div className="max-w-4xl mx-auto mb-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
|
||||
Reference in New Issue
Block a user