feat: Update README to include Cursor CLI support and enhance chat message handling with streaming improvements

This commit is contained in:
simos
2025-08-12 15:05:36 +03:00
parent 50f6cdfac9
commit db7ce4dd74
4 changed files with 152 additions and 66 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

View File

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

View File

@@ -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">