feat: CodePilot - desktop GUI for Claude Code

Full-featured desktop application built with Next.js + Electron:
- Streaming chat with Claude Code via Agent SDK
- Session management with SQLite persistence
- Project file tree and file preview
- MCP server and custom skills management
- Permission controls with Code/Plan/Ask modes
- Dark/light theme, token tracking
- macOS DMG packaging with electron-builder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-06 21:04:05 +08:00
1 parent 6776cc6297
commit ff1fb38735
149 files changed
+31117 -122

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
import { NextRequest } from 'next/server';
import { resolvePendingPermission } from '@/lib/permission-registry';
import type { PermissionResponseRequest } from '@/types';
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest) {
try {
const body: PermissionResponseRequest = await request.json();
const { permissionRequestId, decision } = body;
if (!permissionRequestId || !decision) {
return new Response(
JSON.stringify({ error: 'permissionRequestId and decision are required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
);
}
let result: PermissionResult;
if (decision.behavior === 'allow') {
result = {
behavior: 'allow',
updatedPermissions: decision.updatedPermissions as unknown as PermissionUpdate[],
};
} else {
result = {
behavior: 'deny',
message: decision.message || 'User denied permission',
};
}
const found = resolvePendingPermission(permissionRequestId, result);
if (!found) {
return new Response(
JSON.stringify({ error: 'Permission request not found or already resolved' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return new Response(
JSON.stringify({ error: message }),
{ status: 500, headers: { 'Content-Type': 'application/json' } },
);
}
}
+164
View File
@@ -0,0 +1,164 @@
import { NextRequest } from 'next/server';
import { streamClaude } from '@/lib/claude-client';
import { addMessage, getSession, updateSessionTitle, updateSdkSessionId, getSetting } from '@/lib/db';
import type { SendMessageRequest, SSEEvent, TokenUsage } from '@/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest) {
try {
const body: SendMessageRequest = await request.json();
const { session_id, content, model, mode } = body;
if (!session_id || !content) {
return new Response(JSON.stringify({ error: 'session_id and content are required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const session = getSession(session_id);
if (!session) {
return new Response(JSON.stringify({ error: 'Session not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// Save user message
addMessage(session_id, 'user', content);
// Auto-generate title from first message if still default
if (session.title === 'New Chat') {
const title = content.slice(0, 50) + (content.length > 50 ? '...' : '');
updateSessionTitle(session_id, title);
}
// Determine model: request override > session model > default setting
const effectiveModel = model || session.model || getSetting('default_model') || undefined;
// Determine permission mode from chat mode: code → acceptEdits, plan → plan, ask → default (no tools)
const effectiveMode = mode || session.mode || 'code';
let permissionMode: string;
let systemPromptOverride: string | undefined;
switch (effectiveMode) {
case 'plan':
permissionMode = 'plan';
break;
case 'ask':
permissionMode = 'default';
systemPromptOverride = (session.system_prompt || '') +
'\n\nYou are in Ask mode. Answer questions and provide information only. Do not use any tools, do not read or write files, do not execute commands. Only respond with text.';
break;
default: // 'code'
permissionMode = 'acceptEdits';
break;
}
const abortController = new AbortController();
// Handle client disconnect
request.signal.addEventListener('abort', () => {
abortController.abort();
});
// Stream Claude response, using SDK session ID for resume if available
const stream = streamClaude({
prompt: content,
sessionId: session_id,
sdkSessionId: session.sdk_session_id || undefined,
model: effectiveModel,
systemPrompt: systemPromptOverride || session.system_prompt || undefined,
workingDirectory: session.working_directory || undefined,
abortController,
permissionMode,
});
// Tee the stream: one for client, one for collecting the response
const [streamForClient, streamForCollect] = stream.tee();
// Save assistant message in background
collectStreamResponse(streamForCollect, session_id);
return new Response(streamForClient, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
async function collectStreamResponse(stream: ReadableStream<string>, sessionId: string) {
const reader = stream.getReader();
let fullText = '';
let tokenUsage: TokenUsage | null = null;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = value.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event: SSEEvent = JSON.parse(line.slice(6));
if (event.type === 'permission_request' || event.type === 'tool_output') {
// Skip permission_request and tool_output events - not saved as message content
} else if (event.type === 'text') {
fullText += event.data;
} else if (event.type === 'status') {
// Capture SDK session_id from init event and persist it
try {
const statusData = JSON.parse(event.data);
if (statusData.session_id) {
updateSdkSessionId(sessionId, statusData.session_id);
}
} catch {
// skip malformed status data
}
} else if (event.type === 'result') {
try {
const resultData = JSON.parse(event.data);
if (resultData.usage) {
tokenUsage = resultData.usage;
}
// Also capture session_id from result if we missed it from init
if (resultData.session_id) {
updateSdkSessionId(sessionId, resultData.session_id);
}
} catch {
// skip malformed result data
}
}
} catch {
// skip malformed lines
}
}
}
}
if (fullText.trim()) {
addMessage(
sessionId,
'assistant',
fullText.trim(),
tokenUsage ? JSON.stringify(tokenUsage) : null,
);
}
} catch {
// Stream reading error - best effort save
if (fullText.trim()) {
addMessage(sessionId, 'assistant', fullText.trim());
}
}
}
@@ -0,0 +1,23 @@
import { NextRequest } from 'next/server';
import { getMessages, getSession } from '@/lib/db';
import type { MessagesResponse } from '@/types';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const session = getSession(id);
if (!session) {
return Response.json({ error: 'Session not found' }, { status: 404 });
}
const messages = getMessages(id);
const response: MessagesResponse = { messages };
return Response.json(response);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch messages';
return Response.json({ error: message }, { status: 500 });
}
}
+69
View File
@@ -0,0 +1,69 @@
import { NextRequest } from 'next/server';
import { deleteSession, getSession, updateSessionWorkingDirectory, updateSessionTitle, updateSessionMode } from '@/lib/db';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const session = getSession(id);
if (!session) {
return Response.json({ error: 'Session not found' }, { status: 404 });
}
return Response.json({ session });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get session';
return Response.json({ error: message }, { status: 500 });
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const session = getSession(id);
if (!session) {
return Response.json({ error: 'Session not found' }, { status: 404 });
}
const body = await request.json();
if (body.working_directory) {
updateSessionWorkingDirectory(id, body.working_directory);
}
if (body.title) {
updateSessionTitle(id, body.title);
}
if (body.mode) {
updateSessionMode(id, body.mode);
}
const updated = getSession(id);
return Response.json({ session: updated });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update session';
return Response.json({ error: message }, { status: 500 });
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const session = getSession(id);
if (!session) {
return Response.json({ error: 'Session not found' }, { status: 404 });
}
deleteSession(id);
return Response.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete session';
return Response.json({ error: message }, { status: 500 });
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest } from 'next/server';
import { getAllSessions, createSession } from '@/lib/db';
import type { CreateSessionRequest, SessionsResponse, SessionResponse } from '@/types';
export async function GET() {
try {
const sessions = getAllSessions();
const response: SessionsResponse = { sessions };
return Response.json(response);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to fetch sessions';
return Response.json({ error: message }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body: CreateSessionRequest = await request.json();
const session = createSession(
body.title,
body.model,
body.system_prompt,
body.working_directory,
body.mode,
);
const response: SessionResponse = { session };
return Response.json(response, { status: 201 });
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create session';
return Response.json({ error: message }, { status: 500 });
}
}