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 });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
export async function GET() {
try {
const { stdout } = await execFileAsync('claude', ['--version'], {
timeout: 5000,
env: { ...process.env, PATH: process.env.PATH },
});
const version = stdout.trim();
return NextResponse.json({ connected: true, version });
} catch {
return NextResponse.json({ connected: false, version: null });
}
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import os from 'os';
import type { ErrorResponse } from '@/types';
// List only directories for folder browsing (no safety restriction since user is choosing where to work)
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const dir = searchParams.get('dir') || os.homedir();
const resolvedDir = path.resolve(dir);
if (!fs.existsSync(resolvedDir)) {
return NextResponse.json<ErrorResponse>(
{ error: 'Directory does not exist' },
{ status: 404 }
);
}
try {
const entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
const directories = entries
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
.map((e) => ({
name: e.name,
path: path.join(resolvedDir, e.name),
}))
.sort((a, b) => a.name.localeCompare(b.name));
return NextResponse.json({
current: resolvedDir,
parent: path.dirname(resolvedDir) !== resolvedDir ? path.dirname(resolvedDir) : null,
directories,
});
} catch {
return NextResponse.json<ErrorResponse>(
{ error: 'Cannot read directory' },
{ status: 500 }
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { readFilePreview, isPathSafe } from '@/lib/files';
import type { FilePreviewResponse, ErrorResponse } from '@/types';
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const filePath = searchParams.get('path');
const maxLines = parseInt(searchParams.get('maxLines') || '200', 10);
if (!filePath) {
return NextResponse.json<ErrorResponse>(
{ error: 'Missing path parameter' },
{ status: 400 }
);
}
const path = require('path');
const resolvedPath = path.resolve(filePath);
// Basic safety: the file must exist under some reasonable base
// We check it resolves to an absolute path and is not a traversal attempt
const dir = path.dirname(resolvedPath);
if (!isPathSafe(dir, resolvedPath)) {
return NextResponse.json<ErrorResponse>(
{ error: 'Invalid file path' },
{ status: 403 }
);
}
try {
const preview = readFilePreview(resolvedPath, Math.min(maxLines, 1000));
return NextResponse.json<FilePreviewResponse>({ preview });
} catch (error) {
return NextResponse.json<ErrorResponse>(
{ error: error instanceof Error ? error.message : 'Failed to read file' },
{ status: 500 }
);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { scanDirectory, isPathSafe } from '@/lib/files';
import type { FileTreeResponse, ErrorResponse } from '@/types';
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const dir = searchParams.get('dir');
const depth = parseInt(searchParams.get('depth') || '3', 10);
if (!dir) {
return NextResponse.json<ErrorResponse>(
{ error: 'Missing dir parameter' },
{ status: 400 }
);
}
// Safety: resolve absolute path and validate
const path = require('path');
const resolvedDir = path.resolve(dir);
// Only allow scanning within the provided directory
if (!isPathSafe(resolvedDir, resolvedDir)) {
return NextResponse.json<ErrorResponse>(
{ error: 'Invalid directory path' },
{ status: 403 }
);
}
try {
const tree = scanDirectory(resolvedDir, Math.min(depth, 5));
return NextResponse.json<FileTreeResponse>({ tree, root: resolvedDir });
} catch (error) {
return NextResponse.json<ErrorResponse>(
{ error: error instanceof Error ? error.message : 'Failed to scan directory' },
{ status: 500 }
);
}
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ status: 'ok' });
}
+113
View File
@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import os from 'os';
import type { PluginInfo, ErrorResponse, SuccessResponse } from '@/types';
function getClaudeDir(): string {
return path.join(os.homedir(), '.claude');
}
function getSettingsPath(): string {
return path.join(getClaudeDir(), 'settings.json');
}
function readSettings(): Record<string, unknown> {
const settingsPath = getSettingsPath();
if (!fs.existsSync(settingsPath)) return {};
try {
return JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
} catch {
return {};
}
}
function writeSettings(settings: Record<string, unknown>): void {
const settingsPath = getSettingsPath();
const dir = path.dirname(settingsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
}
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
): Promise<NextResponse<{ plugin: PluginInfo } | ErrorResponse>> {
const { id } = await params;
const pluginName = decodeURIComponent(id);
// Check in commands directory
const commandsDir = path.join(getClaudeDir(), 'commands');
const filePath = path.join(commandsDir, `${pluginName}.md`);
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
const firstLine = content.split('\n')[0]?.trim() || '';
return NextResponse.json({
plugin: {
name: pluginName,
description: firstLine.startsWith('#')
? firstLine.replace(/^#+\s*/, '')
: `Skill: /${pluginName}`,
enabled: true,
},
});
}
// Check in settings customCommands
const settings = readSettings();
const customCommands = (settings.customCommands || {}) as Record<
string,
{ description?: string; enabled?: boolean }
>;
if (customCommands[pluginName]) {
const cmd = customCommands[pluginName];
return NextResponse.json({
plugin: {
name: pluginName,
description: cmd.description || `Custom command: /${pluginName}`,
enabled: cmd.enabled !== false,
},
});
}
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
): Promise<NextResponse<SuccessResponse | ErrorResponse>> {
try {
const { id } = await params;
const pluginName = decodeURIComponent(id);
const body = await request.json();
const { enabled } = body as { enabled: boolean };
const settings = readSettings();
if (!settings.customCommands) {
settings.customCommands = {};
}
const customCommands = settings.customCommands as Record<
string,
{ description?: string; enabled?: boolean }
>;
if (customCommands[pluginName]) {
customCommands[pluginName].enabled = enabled;
} else {
customCommands[pluginName] = { enabled };
}
writeSettings(settings);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to update plugin' },
{ status: 500 }
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import os from 'os';
import type { MCPServerConfig, ErrorResponse, SuccessResponse } from '@/types';
function getSettingsPath(): string {
return path.join(os.homedir(), '.claude', 'settings.json');
}
function readSettings(): Record<string, unknown> {
const settingsPath = getSettingsPath();
if (!fs.existsSync(settingsPath)) return {};
try {
return JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
} catch {
return {};
}
}
function writeSettings(settings: Record<string, unknown>): void {
const settingsPath = getSettingsPath();
const dir = path.dirname(settingsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
): Promise<NextResponse<SuccessResponse | ErrorResponse>> {
try {
const { name } = await params;
const serverName = decodeURIComponent(name);
const settings = readSettings();
const mcpServers = (settings.mcpServers || {}) as Record<string, MCPServerConfig>;
if (!mcpServers[serverName]) {
return NextResponse.json(
{ error: `MCP server "${serverName}" not found` },
{ status: 404 }
);
}
delete mcpServers[serverName];
settings.mcpServers = mcpServers;
writeSettings(settings);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to delete MCP server' },
{ status: 500 }
);
}
}
+105
View File
@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import os from 'os';
import type {
MCPServerConfig,
MCPConfigResponse,
ErrorResponse,
SuccessResponse,
} from '@/types';
function getSettingsPath(): string {
return path.join(os.homedir(), '.claude', 'settings.json');
}
function readSettings(): Record<string, unknown> {
const settingsPath = getSettingsPath();
if (!fs.existsSync(settingsPath)) return {};
try {
return JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
} catch {
return {};
}
}
function writeSettings(settings: Record<string, unknown>): void {
const settingsPath = getSettingsPath();
const dir = path.dirname(settingsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
}
export async function GET(): Promise<NextResponse<MCPConfigResponse | ErrorResponse>> {
try {
const settings = readSettings();
const mcpServers = (settings.mcpServers || {}) as Record<string, MCPServerConfig>;
return NextResponse.json({ mcpServers });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to read MCP config' },
{ status: 500 }
);
}
}
export async function PUT(
request: NextRequest
): Promise<NextResponse<SuccessResponse | ErrorResponse>> {
try {
const body = await request.json();
const { mcpServers } = body as { mcpServers: Record<string, MCPServerConfig> };
const settings = readSettings();
settings.mcpServers = mcpServers;
writeSettings(settings);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to update MCP config' },
{ status: 500 }
);
}
}
export async function POST(
request: NextRequest
): Promise<NextResponse<SuccessResponse | ErrorResponse>> {
try {
const body = await request.json();
const { name, server } = body as { name: string; server: MCPServerConfig };
if (!name || !server || !server.command) {
return NextResponse.json(
{ error: 'Name and server command are required' },
{ status: 400 }
);
}
const settings = readSettings();
if (!settings.mcpServers) {
settings.mcpServers = {};
}
const mcpServers = settings.mcpServers as Record<string, MCPServerConfig>;
if (mcpServers[name]) {
return NextResponse.json(
{ error: `MCP server "${name}" already exists` },
{ status: 409 }
);
}
mcpServers[name] = server;
writeSettings(settings);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to add MCP server' },
{ status: 500 }
);
}
}
+99
View File
@@ -0,0 +1,99 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import os from 'os';
import type { ErrorResponse } from '@/types';
export interface SkillInfo {
name: string;
description: string;
source: 'global' | 'project';
content: string;
filePath: string;
enabled: boolean;
}
export interface SkillsResponse {
plugins: SkillInfo[];
}
function getClaudeDir(): string {
return path.join(os.homedir(), '.claude');
}
function discoverSkills(): SkillInfo[] {
const claudeDir = getClaudeDir();
const skills: SkillInfo[] = [];
// Scan for .md skill files in global commands directory
const globalCommandsDir = path.join(claudeDir, 'commands');
if (fs.existsSync(globalCommandsDir)) {
try {
const files = fs.readdirSync(globalCommandsDir);
for (const file of files) {
if (file.endsWith('.md')) {
const name = file.replace(/\.md$/, '');
const filePath = path.join(globalCommandsDir, file);
const content = fs.readFileSync(filePath, 'utf-8');
const firstLine = content.split('\n')[0]?.trim() || '';
const description = firstLine.startsWith('#')
? firstLine.replace(/^#+\s*/, '')
: `Skill: /${name}`;
skills.push({
name,
description,
source: 'global',
content,
filePath,
enabled: true,
});
}
}
} catch {
// ignore read errors
}
}
// Scan project-level .claude/commands
const projectCommandsDir = path.join(process.cwd(), '.claude', 'commands');
if (fs.existsSync(projectCommandsDir)) {
try {
const files = fs.readdirSync(projectCommandsDir);
for (const file of files) {
if (file.endsWith('.md')) {
const name = file.replace(/\.md$/, '');
const filePath = path.join(projectCommandsDir, file);
const content = fs.readFileSync(filePath, 'utf-8');
const firstLine = content.split('\n')[0]?.trim() || '';
const description = firstLine.startsWith('#')
? firstLine.replace(/^#+\s*/, '')
: `Project skill: /${name}`;
skills.push({
name: `project:${name}`,
description,
source: 'project',
content,
filePath,
enabled: true,
});
}
}
} catch {
// ignore read errors
}
}
return skills;
}
export async function GET(): Promise<NextResponse<SkillsResponse | ErrorResponse>> {
try {
const plugins = discoverSkills();
return NextResponse.json({ plugins });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to load plugins' },
{ status: 500 }
);
}
}
+60
View File
@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import os from "os";
const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
function readSettingsFile(): Record<string, unknown> {
try {
if (fs.existsSync(SETTINGS_PATH)) {
const content = fs.readFileSync(SETTINGS_PATH, "utf-8");
return JSON.parse(content);
}
} catch {
// Return empty object if file doesn't exist or is invalid
}
return {};
}
function writeSettingsFile(data: Record<string, unknown>): void {
const dir = path.dirname(SETTINGS_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(data, null, 2), "utf-8");
}
export async function GET() {
try {
const settings = readSettingsFile();
return NextResponse.json({ settings });
} catch (error) {
return NextResponse.json(
{ error: "Failed to read settings" },
{ status: 500 }
);
}
}
export async function PUT(request: Request) {
try {
const body = await request.json();
const { settings } = body;
if (!settings || typeof settings !== "object") {
return NextResponse.json(
{ error: "Invalid settings data" },
{ status: 400 }
);
}
writeSettingsFile(settings);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: "Failed to save settings" },
{ status: 500 }
);
}
}
+129
View File
@@ -0,0 +1,129 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import os from "os";
function getGlobalCommandsDir(): string {
return path.join(os.homedir(), ".claude", "commands");
}
function getProjectCommandsDir(): string {
return path.join(process.cwd(), ".claude", "commands");
}
function getInstalledSkillsDir(): string {
return path.join(os.homedir(), ".agents", "skills");
}
function findSkillFile(
name: string
): { filePath: string; source: "global" | "project" | "installed" } | null {
// Check project first, then global, then installed (~/.agents/skills/)
const projectPath = path.join(getProjectCommandsDir(), `${name}.md`);
if (fs.existsSync(projectPath)) {
return { filePath: projectPath, source: "project" };
}
const globalPath = path.join(getGlobalCommandsDir(), `${name}.md`);
if (fs.existsSync(globalPath)) {
return { filePath: globalPath, source: "global" };
}
// Installed skills: ~/.agents/skills/{name}/SKILL.md
const installedPath = path.join(getInstalledSkillsDir(), name, "SKILL.md");
if (fs.existsSync(installedPath)) {
return { filePath: installedPath, source: "installed" };
}
return null;
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const found = findSkillFile(name);
if (!found) {
return NextResponse.json({ error: "Skill not found" }, { status: 404 });
}
const content = fs.readFileSync(found.filePath, "utf-8");
const firstLine = content.split("\n")[0]?.trim() || "";
const description = firstLine.startsWith("#")
? firstLine.replace(/^#+\s*/, "")
: firstLine || `Skill: /${name}`;
return NextResponse.json({
skill: {
name,
description,
content,
source: found.source,
filePath: found.filePath,
},
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to read skill" },
{ status: 500 }
);
}
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const body = await request.json();
const { content } = body as { content: string };
const found = findSkillFile(name);
if (!found) {
return NextResponse.json({ error: "Skill not found" }, { status: 404 });
}
fs.writeFileSync(found.filePath, content ?? "", "utf-8");
const firstLine = (content ?? "").split("\n")[0]?.trim() || "";
const description = firstLine.startsWith("#")
? firstLine.replace(/^#+\s*/, "")
: firstLine || `Skill: /${name}`;
return NextResponse.json({
skill: {
name,
description,
content: content ?? "",
source: found.source,
filePath: found.filePath,
},
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to update skill" },
{ status: 500 }
);
}
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const found = findSkillFile(name);
if (!found) {
return NextResponse.json({ error: "Skill not found" }, { status: 404 });
}
fs.unlinkSync(found.filePath);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to delete skill" },
{ status: 500 }
);
}
}
+246
View File
@@ -0,0 +1,246 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import os from "os";
interface SkillFile {
name: string;
description: string;
content: string;
source: "global" | "project" | "plugin" | "installed";
filePath: string;
}
function getGlobalCommandsDir(): string {
return path.join(os.homedir(), ".claude", "commands");
}
function getProjectCommandsDir(): string {
return path.join(process.cwd(), ".claude", "commands");
}
function getPluginCommandsDirs(): string[] {
const dirs: string[] = [];
const marketplacesDir = path.join(os.homedir(), ".claude", "plugins", "marketplaces");
if (!fs.existsSync(marketplacesDir)) return dirs;
try {
// Scan marketplaces -> each marketplace -> plugins -> each plugin -> commands
const marketplaces = fs.readdirSync(marketplacesDir);
for (const marketplace of marketplaces) {
const pluginsDir = path.join(marketplacesDir, marketplace, "plugins");
if (!fs.existsSync(pluginsDir)) continue;
const plugins = fs.readdirSync(pluginsDir);
for (const plugin of plugins) {
const commandsDir = path.join(pluginsDir, plugin, "commands");
if (fs.existsSync(commandsDir)) {
dirs.push(commandsDir);
}
}
}
} catch {
// ignore
}
return dirs;
}
function getInstalledSkillsDir(): string {
return path.join(os.homedir(), ".agents", "skills");
}
/**
* Parse YAML front matter from SKILL.md content.
* Extracts `name` and `description` fields from the --- delimited block.
*/
function parseSkillFrontMatter(content: string): { name?: string; description?: string } {
// Extract front matter between --- delimiters
const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
if (!fmMatch) return {};
const frontMatter = fmMatch[1];
const lines = frontMatter.split(/\r?\n/);
const result: { name?: string; description?: string } = {};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Match name: value
const nameMatch = line.match(/^name:\s*(.+)/);
if (nameMatch) {
result.name = nameMatch[1].trim();
continue;
}
// Match description: | (multi-line YAML block scalar) — check FIRST
if (/^description:\s*\|/.test(line)) {
const descLines: string[] = [];
for (let j = i + 1; j < lines.length; j++) {
if (/^\s+/.test(lines[j])) {
descLines.push(lines[j].trim());
} else {
break;
}
}
if (descLines.length > 0) {
result.description = descLines.filter(Boolean).join(" ");
}
continue;
}
// Match description: value (single-line)
const descMatch = line.match(/^description:\s+(.+)/);
if (descMatch) {
result.description = descMatch[1].trim();
}
}
return result;
}
/**
* Scan ~/.agents/skills/ for installed skills (npx skills add).
* Each skill is a directory containing a SKILL.md with YAML front matter.
*/
function scanInstalledSkills(): SkillFile[] {
const skills: SkillFile[] = [];
const dir = getInstalledSkillsDir();
if (!fs.existsSync(dir)) return skills;
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
const skillMdPath = path.join(dir, entry.name, "SKILL.md");
if (!fs.existsSync(skillMdPath)) continue;
const content = fs.readFileSync(skillMdPath, "utf-8");
const meta = parseSkillFrontMatter(content);
const name = meta.name || entry.name;
const description = meta.description || `Installed skill: /${name}`;
skills.push({
name,
description,
content,
source: "installed",
filePath: skillMdPath,
});
}
} catch {
// ignore read errors
}
return skills;
}
function scanDirectory(
dir: string,
source: "global" | "project" | "plugin"
): SkillFile[] {
const skills: SkillFile[] = [];
if (!fs.existsSync(dir)) return skills;
try {
const files = fs.readdirSync(dir);
for (const file of files) {
if (!file.endsWith(".md")) continue;
const name = file.replace(/\.md$/, "");
const filePath = path.join(dir, file);
const content = fs.readFileSync(filePath, "utf-8");
const firstLine = content.split("\n")[0]?.trim() || "";
const description = firstLine.startsWith("#")
? firstLine.replace(/^#+\s*/, "")
: firstLine || `Skill: /${name}`;
skills.push({ name, description, content, source, filePath });
}
} catch {
// ignore read errors
}
return skills;
}
export async function GET() {
try {
const globalSkills = scanDirectory(getGlobalCommandsDir(), "global");
const projectSkills = scanDirectory(getProjectCommandsDir(), "project");
const installedSkills = scanInstalledSkills();
// Scan installed plugin skills
const pluginSkills: SkillFile[] = [];
for (const dir of getPluginCommandsDirs()) {
pluginSkills.push(...scanDirectory(dir, "plugin"));
}
return NextResponse.json({ skills: [...globalSkills, ...projectSkills, ...installedSkills, ...pluginSkills] });
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to load skills" },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { name, content, scope } = body as {
name: string;
content: string;
scope: "global" | "project";
};
if (!name || typeof name !== "string") {
return NextResponse.json(
{ error: "Skill name is required" },
{ status: 400 }
);
}
// Sanitize name: only allow alphanumeric, hyphens, underscores
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "-");
if (!safeName) {
return NextResponse.json(
{ error: "Invalid skill name" },
{ status: 400 }
);
}
const dir =
scope === "project" ? getProjectCommandsDir() : getGlobalCommandsDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const filePath = path.join(dir, `${safeName}.md`);
if (fs.existsSync(filePath)) {
return NextResponse.json(
{ error: "A skill with this name already exists" },
{ status: 409 }
);
}
fs.writeFileSync(filePath, content || "", "utf-8");
const firstLine = (content || "").split("\n")[0]?.trim() || "";
const description = firstLine.startsWith("#")
? firstLine.replace(/^#+\s*/, "")
: firstLine || `Skill: /${safeName}`;
return NextResponse.json(
{
skill: {
name: safeName,
description,
content: content || "",
source: scope || "global",
filePath,
},
},
{ status: 201 }
);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to create skill" },
{ status: 500 }
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { updateTask, deleteTask, getTask } from '@/lib/db';
import type { TaskResponse, ErrorResponse, UpdateTaskRequest } from '@/types';
interface RouteContext {
params: Promise<{ id: string }>;
}
export async function PATCH(request: NextRequest, context: RouteContext) {
const { id } = await context.params;
try {
const body: UpdateTaskRequest = await request.json();
const existing = getTask(id);
if (!existing) {
return NextResponse.json<ErrorResponse>(
{ error: 'Task not found' },
{ status: 404 }
);
}
const updated = updateTask(id, body);
if (!updated) {
return NextResponse.json<ErrorResponse>(
{ error: 'Failed to update task' },
{ status: 500 }
);
}
return NextResponse.json<TaskResponse>({ task: updated });
} catch (error) {
return NextResponse.json<ErrorResponse>(
{ error: error instanceof Error ? error.message : 'Failed to update task' },
{ status: 500 }
);
}
}
export async function DELETE(_request: NextRequest, context: RouteContext) {
const { id } = await context.params;
try {
const deleted = deleteTask(id);
if (!deleted) {
return NextResponse.json<ErrorResponse>(
{ error: 'Task not found' },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json<ErrorResponse>(
{ error: error instanceof Error ? error.message : 'Failed to delete task' },
{ status: 500 }
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { getTasksBySession, createTask } from '@/lib/db';
import type { TasksResponse, TaskResponse, ErrorResponse, CreateTaskRequest } from '@/types';
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const sessionId = searchParams.get('session_id');
if (!sessionId) {
return NextResponse.json<ErrorResponse>(
{ error: 'Missing session_id parameter' },
{ status: 400 }
);
}
try {
const tasks = getTasksBySession(sessionId);
return NextResponse.json<TasksResponse>({ tasks });
} catch (error) {
return NextResponse.json<ErrorResponse>(
{ error: error instanceof Error ? error.message : 'Failed to get tasks' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body: CreateTaskRequest = await request.json();
if (!body.session_id || !body.title) {
return NextResponse.json<ErrorResponse>(
{ error: 'Missing session_id or title' },
{ status: 400 }
);
}
const task = createTask(body.session_id, body.title, body.description);
return NextResponse.json<TaskResponse>({ task }, { status: 201 });
} catch (error) {
return NextResponse.json<ErrorResponse>(
{ error: error instanceof Error ? error.message : 'Failed to create task' },
{ status: 500 }
);
}
}
+110
View File
@@ -0,0 +1,110 @@
'use client';
import { useEffect, useState, use } from 'react';
import type { Message, MessagesResponse, ChatSession } from '@/types';
import { ChatView } from '@/components/chat/ChatView';
import { HugeiconsIcon } from "@hugeicons/react";
import { Loading02Icon } from "@hugeicons/core-free-icons";
import { usePanel } from '@/hooks/usePanel';
interface ChatSessionPageProps {
params: Promise<{ id: string }>;
}
export default function ChatSessionPage({ params }: ChatSessionPageProps) {
const { id } = use(params);
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sessionTitle, setSessionTitle] = useState<string>('');
const [sessionModel, setSessionModel] = useState<string>('');
const [sessionMode, setSessionMode] = useState<string>('');
const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle, setPanelOpen } = usePanel();
// Load session info and set working directory
useEffect(() => {
async function loadSession() {
try {
const res = await fetch(`/api/chat/sessions/${id}`);
if (res.ok) {
const data: { session: ChatSession } = await res.json();
if (data.session.working_directory) {
setWorkingDirectory(data.session.working_directory);
}
setSessionId(id);
setPanelOpen(true);
const title = data.session.title || 'New Conversation';
setSessionTitle(title);
setPanelSessionTitle(title);
setSessionModel(data.session.model || '');
setSessionMode(data.session.mode || 'code');
}
} catch {
// Session info load failed - panel will still work without directory
}
}
loadSession();
}, [id, setWorkingDirectory, setSessionId, setPanelSessionTitle, setPanelOpen]);
useEffect(() => {
async function loadMessages() {
try {
const res = await fetch(`/api/chat/sessions/${id}/messages`);
if (!res.ok) {
if (res.status === 404) {
setError('Session not found');
return;
}
throw new Error('Failed to load messages');
}
const data: MessagesResponse = await res.json();
setMessages(data.messages);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load messages');
} finally {
setLoading(false);
}
}
loadMessages();
}, [id]);
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<HugeiconsIcon icon={Loading02Icon} className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center space-y-2">
<p className="text-destructive font-medium">{error}</p>
<a href="/chat" className="text-sm text-muted-foreground hover:underline">
Start a new chat
</a>
</div>
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
{/* Chat title bar */}
{sessionTitle && (
<div
className="flex items-center justify-center px-4 py-2"
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
>
<h2 className="text-sm font-medium text-foreground/80 truncate max-w-md">
{sessionTitle}
</h2>
</div>
)}
<ChatView sessionId={id} initialMessages={messages} modelName={sessionModel} initialMode={sessionMode} />
</div>
);
}
+370
View File
@@ -0,0 +1,370 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestEvent } from '@/types';
import { MessageList } from '@/components/chat/MessageList';
import { MessageInput } from '@/components/chat/MessageInput';
import { usePanel } from '@/hooks/usePanel';
interface ToolUseInfo {
id: string;
name: string;
input: unknown;
}
interface ToolResultInfo {
tool_use_id: string;
content: string;
}
export default function NewChatPage() {
const router = useRouter();
const { setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel();
const [messages, setMessages] = useState<Message[]>([]);
const [streamingContent, setStreamingContent] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [toolUses, setToolUses] = useState<ToolUseInfo[]>([]);
const [toolResults, setToolResults] = useState<ToolResultInfo[]>([]);
const [statusText, setStatusText] = useState<string | undefined>();
const [workingDir, setWorkingDir] = useState('');
const [mode, setMode] = useState('code');
const [currentModel, setCurrentModel] = useState('sonnet');
const [pendingPermission, setPendingPermission] = useState<PermissionRequestEvent | null>(null);
const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null);
const [streamingToolOutput, setStreamingToolOutput] = useState('');
const abortControllerRef = useRef<AbortController | null>(null);
const handleWorkingDirectoryChange = useCallback((dir: string) => {
setWorkingDir(dir);
setWorkingDirectory(dir);
setPanelOpen(true);
}, [setWorkingDirectory, setPanelOpen]);
const stopStreaming = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);
const handlePermissionResponse = useCallback(async (decision: 'allow' | 'allow_session' | 'deny') => {
if (!pendingPermission) return;
const body: { permissionRequestId: string; decision: { behavior: 'allow'; updatedPermissions?: unknown[] } | { behavior: 'deny'; message?: string } } = {
permissionRequestId: pendingPermission.permissionRequestId,
decision: decision === 'deny'
? { behavior: 'deny', message: 'User denied permission' }
: {
behavior: 'allow',
...(decision === 'allow_session' && pendingPermission.suggestions
? { updatedPermissions: pendingPermission.suggestions }
: {}),
},
};
setPermissionResolved(decision === 'deny' ? 'deny' : 'allow');
setPendingApprovalSessionId('');
try {
await fetch('/api/chat/permission', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch {
// Best effort
}
setTimeout(() => {
setPendingPermission(null);
setPermissionResolved(null);
}, 1000);
}, [pendingPermission, setPendingApprovalSessionId]);
const sendFirstMessage = useCallback(
async (content: string) => {
if (isStreaming) return;
setIsStreaming(true);
setStreamingContent('');
setToolUses([]);
setToolResults([]);
setStatusText(undefined);
const controller = new AbortController();
abortControllerRef.current = controller;
let sessionId = '';
try {
// Create a new session with optional working directory
const createBody: Record<string, string> = {
title: content.slice(0, 50),
mode,
};
if (workingDir.trim()) {
createBody.working_directory = workingDir.trim();
}
const createRes = await fetch('/api/chat/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createBody),
});
if (!createRes.ok) throw new Error('Failed to create session');
const { session }: SessionResponse = await createRes.json();
sessionId = session.id;
// Notify ChatListPanel to refresh immediately
window.dispatchEvent(new CustomEvent('session-created'));
// Add user message to UI
const userMessage: Message = {
id: 'temp-' + Date.now(),
session_id: session.id,
role: 'user',
content,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages([userMessage]);
// Send the message via streaming API
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: session.id, content, mode, model: currentModel }),
signal: controller.signal,
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || 'Failed to send message');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response stream');
const decoder = new TextDecoder();
let accumulated = '';
let tokenUsage: TokenUsage | null = null;
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const event: SSEEvent = JSON.parse(line.slice(6));
switch (event.type) {
case 'text': {
accumulated += event.data;
setStreamingContent(accumulated);
break;
}
case 'tool_use': {
try {
const toolData = JSON.parse(event.data);
setStreamingToolOutput('');
setToolUses((prev) => {
if (prev.some((t) => t.id === toolData.id)) return prev;
return [...prev, { id: toolData.id, name: toolData.name, input: toolData.input }];
});
} catch { /* skip */ }
break;
}
case 'tool_result': {
try {
const resultData = JSON.parse(event.data);
setStreamingToolOutput('');
setToolResults((prev) => [...prev, { tool_use_id: resultData.tool_use_id, content: resultData.content }]);
} catch { /* skip */ }
break;
}
case 'tool_output': {
try {
const parsed = JSON.parse(event.data);
if (parsed._progress) {
setStatusText(`Running ${parsed.tool_name}... (${Math.round(parsed.elapsed_time_seconds)}s)`);
break;
}
} catch {
// Not JSON — raw stderr output
}
setStreamingToolOutput((prev) => {
const next = prev + (prev ? '\n' : '') + event.data;
return next.length > 5000 ? next.slice(-5000) : next;
});
break;
}
case 'status': {
try {
const statusData = JSON.parse(event.data);
if (statusData.session_id) {
setStatusText(`Connected (${statusData.model || 'claude'})`);
setTimeout(() => setStatusText(undefined), 2000);
} else if (statusData.notification) {
setStatusText(statusData.message || statusData.title || undefined);
} else {
setStatusText(event.data || undefined);
}
} catch {
setStatusText(event.data || undefined);
}
break;
}
case 'result': {
try {
const resultData = JSON.parse(event.data);
if (resultData.usage) tokenUsage = resultData.usage;
} catch { /* skip */ }
setStatusText(undefined);
break;
}
case 'permission_request': {
try {
const permData: PermissionRequestEvent = JSON.parse(event.data);
setPendingPermission(permData);
setPermissionResolved(null);
setPendingApprovalSessionId(sessionId);
} catch {
// skip malformed permission_request data
}
break;
}
case 'error': {
accumulated += '\n\n**Error:** ' + event.data;
setStreamingContent(accumulated);
break;
}
case 'done':
break;
}
} catch {
// skip
}
}
}
// Add the completed assistant message
if (accumulated.trim()) {
const assistantMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: session.id,
role: 'assistant',
content: accumulated.trim(),
created_at: new Date().toISOString(),
token_usage: tokenUsage ? JSON.stringify(tokenUsage) : null,
};
setMessages((prev) => [...prev, assistantMessage]);
}
// Navigate to the session page after response is complete
router.push(`/chat/${session.id}`);
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
// User stopped - navigate to session if we have one
if (sessionId) {
router.push(`/chat/${sessionId}`);
}
} else {
const errMsg = error instanceof Error ? error.message : 'Unknown error';
const errorMessage: Message = {
id: 'temp-error-' + Date.now(),
session_id: '',
role: 'assistant',
content: `**Error:** ${errMsg}`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, errorMessage]);
}
} finally {
setIsStreaming(false);
setStreamingContent('');
setToolUses([]);
setToolResults([]);
setStreamingToolOutput('');
setStatusText(undefined);
setPendingPermission(null);
setPermissionResolved(null);
setPendingApprovalSessionId('');
abortControllerRef.current = null;
}
},
[isStreaming, router, workingDir, mode, currentModel, setPendingApprovalSessionId]
);
const handleCommand = useCallback((command: string) => {
switch (command) {
case '/help': {
const helpMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: '',
role: 'assistant',
content: `## Available Commands\n\n- **/help** - Show this help message\n- **/clear** - Clear conversation history\n- **/compact** - Compress conversation context\n- **/cost** - Show token usage statistics\n- **/doctor** - Check system health\n- **/init** - Initialize CLAUDE.md\n- **/review** - Start code review\n- **/terminal-setup** - Configure terminal\n\n**Tips:**\n- Type \`@\` to mention files\n- Use Shift+Enter for new line\n- Select a project folder to enable file operations`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, helpMessage]);
break;
}
case '/clear':
setMessages([]);
break;
case '/cost': {
const costMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: '',
role: 'assistant',
content: `## Token Usage\n\nToken usage tracking is available after sending messages. Check the token count displayed at the bottom of each assistant response.`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, costMessage]);
break;
}
default:
sendFirstMessage(command);
}
}, [sendFirstMessage]);
return (
<div className="flex h-full min-h-0 flex-col">
<MessageList
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
toolUses={toolUses}
toolResults={toolResults}
streamingToolOutput={streamingToolOutput}
statusText={statusText}
pendingPermission={pendingPermission}
onPermissionResponse={handlePermissionResponse}
permissionResolved={permissionResolved}
/>
<MessageInput
onSend={sendFirstMessage}
onCommand={handleCommand}
onStop={stopStreaming}
disabled={false}
isStreaming={isStreaming}
modelName={currentModel}
onModelChange={setCurrentModel}
workingDirectory={workingDir}
onWorkingDirectoryChange={handleWorkingDirectoryChange}
mode={mode}
onModeChange={setMode}
/>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { HugeiconsIcon } from "@hugeicons/react";
import { Loading02Icon } from "@hugeicons/core-free-icons";
import { SkillsManager } from "@/components/skills/SkillsManager";
import { McpManager } from "@/components/plugins/McpManager";
type ExtTab = "skills" | "mcp";
export default function ExtensionsPage() {
return (
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
}
>
<ExtensionsPageInner />
</Suspense>
);
}
function ExtensionsPageInner() {
const searchParams = useSearchParams();
const initialTab = (searchParams.get("tab") as ExtTab) || "skills";
const [tab, setTab] = useState<ExtTab>(initialTab);
return (
<div className="flex h-full flex-col">
<div className="border-b border-border/50 px-6 pt-4 pb-0">
<h1 className="text-xl font-semibold mb-3">Extensions</h1>
<Tabs value={tab} onValueChange={(v) => setTab(v as ExtTab)}>
<TabsList>
<TabsTrigger value="skills">Skills</TabsTrigger>
<TabsTrigger value="mcp">MCP Servers</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex-1 overflow-auto p-6">
{tab === "skills" && <SkillsManager />}
{tab === "mcp" && <McpManager />}
</div>
</div>
);
}
+187 -14
View File
@@ -1,26 +1,199 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--radius: 0.75rem;
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
--card: oklch(1 0 0);
--card-foreground: oklch(0.147 0.004 49.25);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.147 0.004 49.25);
--primary: oklch(0.457 0.24 277.023);
--primary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.97 0.001 106.424);
--secondary-foreground: oklch(0.216 0.006 56.043);
--muted: oklch(0.97 0.001 106.424);
--muted-foreground: oklch(0.553 0.013 58.071);
--accent: oklch(0.97 0.001 106.424);
--accent-foreground: oklch(0.216 0.006 56.043);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.923 0.003 48.717);
--input: oklch(0.923 0.003 48.717);
--ring: oklch(0.457 0.24 277.023);
--chart-1: oklch(0.457 0.24 277.023);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: oklch(0.457 0.24 277.023);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: oklch(0.457 0.24 277.023);
}
.dark {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
--card: oklch(0.216 0.006 56.043);
--card-foreground: oklch(0.985 0.001 106.423);
--popover: oklch(0.216 0.006 56.043);
--popover-foreground: oklch(0.985 0.001 106.423);
--primary: oklch(0.585 0.233 277.117);
--primary-foreground: oklch(0.985 0.001 106.423);
--secondary: oklch(0.268 0.007 34.298);
--secondary-foreground: oklch(0.985 0.001 106.423);
--muted: oklch(0.268 0.007 34.298);
--muted-foreground: oklch(0.709 0.01 56.259);
--accent: oklch(0.268 0.007 34.298);
--accent-foreground: oklch(0.985 0.001 106.423);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.585 0.233 277.117);
--chart-1: oklch(0.585 0.233 277.117);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: oklch(0.585 0.233 277.117);
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.585 0.233 277.117);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
/* Selection color */
::selection {
background: oklch(0.7 0.15 280 / 30%);
}
.dark ::selection {
background: oklch(0.6 0.2 280 / 35%);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(0.7 0 0 / 25%);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.6 0 0 / 40%);
}
.dark ::-webkit-scrollbar-thumb {
background: oklch(0.5 0 0 / 30%);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: oklch(0.5 0 0 / 50%);
}
/* Prose (markdown) refinements */
.prose {
--tw-prose-headings: var(--foreground);
line-height: 1.7;
}
.prose p {
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.prose h1,
.prose h2,
.prose h3 {
margin-top: 1.25em;
margin-bottom: 0.5em;
font-weight: 600;
}
.prose ul,
.prose ol {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.prose li {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
.prose blockquote {
border-left-color: oklch(0.7 0.15 280 / 40%);
font-style: normal;
}
.prose a {
color: oklch(0.55 0.2 260);
text-decoration: underline;
text-underline-offset: 2px;
}
.dark .prose a {
color: oklch(0.7 0.18 260);
}
.prose hr {
border-color: var(--border);
}
.prose table th {
font-weight: 600;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+8 -4
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/layout/ThemeProvider";
import { AppShell } from "@/components/layout/AppShell";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "CodePilot",
description: "A desktop GUI for Claude Code",
};
export default function RootLayout({
@@ -23,11 +25,13 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<ThemeProvider>
<AppShell>{children}</AppShell>
</ThemeProvider>
</body>
</html>
);
+2 -62
View File
@@ -1,65 +1,5 @@
import Image from "next/image";
import { redirect } from "next/navigation";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
redirect("/chat");
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function McpRedirect() {
const router = useRouter();
useEffect(() => {
router.replace("/extensions?tab=mcp");
}, [router]);
return null;
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function PluginsRedirect() {
const router = useRouter();
useEffect(() => {
router.replace("/extensions");
}, [router]);
return null;
}
+387
View File
@@ -0,0 +1,387 @@
"use client";
import { Suspense, useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { HugeiconsIcon } from "@hugeicons/react";
import {
FloppyDiskIcon,
ReloadIcon,
CodeIcon,
SlidersHorizontalIcon,
Loading02Icon,
} from "@hugeicons/core-free-icons";
interface SettingsData {
[key: string]: unknown;
}
// Structured known fields from ~/.claude/settings.json
const KNOWN_FIELDS = [
{
key: "permissions",
label: "Permissions",
description: "Configure permission settings for Claude CLI",
type: "object" as const,
},
{
key: "env",
label: "Environment Variables",
description: "Environment variables passed to Claude",
type: "object" as const,
},
] as const;
export default function SettingsPage() {
return (
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
}
>
<SettingsPageInner />
</Suspense>
);
}
function SettingsPageInner() {
const [settings, setSettings] = useState<SettingsData>({});
const [originalSettings, setOriginalSettings] = useState<SettingsData>({});
const [jsonText, setJsonText] = useState("");
const [jsonError, setJsonError] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingSaveAction, setPendingSaveAction] = useState<
"form" | "json" | null
>(null);
const fetchSettings = useCallback(async () => {
try {
const res = await fetch("/api/settings");
if (res.ok) {
const data = await res.json();
const s = data.settings || {};
setSettings(s);
setOriginalSettings(s);
setJsonText(JSON.stringify(s, null, 2));
}
} catch {
setSettings({});
setOriginalSettings({});
setJsonText("{}");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
const hasChanges =
JSON.stringify(settings) !== JSON.stringify(originalSettings);
const handleSave = async (source: "form" | "json") => {
let dataToSave: SettingsData;
if (source === "json") {
try {
dataToSave = JSON.parse(jsonText);
setJsonError("");
} catch {
setJsonError("Invalid JSON format");
return;
}
} else {
dataToSave = settings;
}
setSaving(true);
try {
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ settings: dataToSave }),
});
if (res.ok) {
setSettings(dataToSave);
setOriginalSettings(dataToSave);
setJsonText(JSON.stringify(dataToSave, null, 2));
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
}
} catch {
// Handle error silently
} finally {
setSaving(false);
setShowConfirmDialog(false);
setPendingSaveAction(null);
}
};
const handleReset = () => {
setSettings(originalSettings);
setJsonText(JSON.stringify(originalSettings, null, 2));
setJsonError("");
};
const handleFormatJson = () => {
try {
const parsed = JSON.parse(jsonText);
setJsonText(JSON.stringify(parsed, null, 2));
setJsonError("");
} catch {
setJsonError("Cannot format: invalid JSON");
}
};
const confirmSave = (source: "form" | "json") => {
setPendingSaveAction(source);
setShowConfirmDialog(true);
};
const updateField = (key: string, value: unknown) => {
setSettings((prev) => ({ ...prev, [key]: value }));
};
return (
<div className="flex h-full flex-col">
<div className="border-b border-border/50 px-6 pt-4 pb-4">
<h1 className="text-xl font-semibold">Settings</h1>
<p className="text-sm text-muted-foreground">
Manage your Claude CLI settings (~/.claude/settings.json)
</p>
</div>
<div className="flex-1 overflow-auto p-6">
<div className="max-w-3xl">
{loading ? (
<div className="flex items-center justify-center py-12">
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">
Loading settings...
</span>
</div>
) : (
<Tabs defaultValue="form">
<TabsList className="mb-4">
<TabsTrigger value="form" className="gap-2">
<HugeiconsIcon icon={SlidersHorizontalIcon} className="h-4 w-4" />
Visual Editor
</TabsTrigger>
<TabsTrigger value="json" className="gap-2">
<HugeiconsIcon icon={CodeIcon} className="h-4 w-4" />
JSON Editor
</TabsTrigger>
</TabsList>
<TabsContent value="form">
<div className="space-y-6">
{KNOWN_FIELDS.map((field) => (
<div
key={field.key}
className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm"
>
<Label className="text-sm font-medium">
{field.label}
</Label>
<p className="mb-2 text-xs text-muted-foreground">
{field.description}
</p>
<Textarea
value={
typeof settings[field.key] === "object"
? JSON.stringify(settings[field.key], null, 2)
: String(settings[field.key] ?? "")
}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
updateField(field.key, parsed);
} catch {
updateField(field.key, e.target.value);
}
}}
className="font-mono text-sm"
rows={4}
/>
</div>
))}
{Object.entries(settings)
.filter(
([key]) => !KNOWN_FIELDS.some((f) => f.key === key)
)
.map(([key, value]) => (
<div
key={key}
className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm"
>
<Label className="text-sm font-medium">{key}</Label>
{typeof value === "boolean" ? (
<div className="mt-2 flex items-center gap-2">
<Switch
checked={value}
onCheckedChange={(checked) =>
updateField(key, checked)
}
/>
<span className="text-sm text-muted-foreground">
{value ? "Enabled" : "Disabled"}
</span>
</div>
) : typeof value === "string" ? (
<Input
value={value}
onChange={(e) =>
updateField(key, e.target.value)
}
className="mt-2"
/>
) : (
<Textarea
value={JSON.stringify(value, null, 2)}
onChange={(e) => {
try {
updateField(key, JSON.parse(e.target.value));
} catch {
updateField(key, e.target.value);
}
}}
className="mt-2 font-mono text-sm"
rows={4}
/>
)}
</div>
))}
<div className="flex items-center gap-3">
<Button
onClick={() => confirmSave("form")}
disabled={!hasChanges || saving}
className="gap-2"
>
{saving ? (
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
) : (
<HugeiconsIcon icon={FloppyDiskIcon} className="h-4 w-4" />
)}
{saving ? "Saving..." : "Save Changes"}
</Button>
<Button
variant="outline"
onClick={handleReset}
disabled={!hasChanges}
className="gap-2"
>
<HugeiconsIcon icon={ReloadIcon} className="h-4 w-4" />
Reset
</Button>
{saveSuccess && (
<span className="text-sm text-green-600 dark:text-green-400">
Settings saved successfully
</span>
)}
</div>
</div>
</TabsContent>
<TabsContent value="json">
<div className="space-y-4">
<Textarea
value={jsonText}
onChange={(e) => {
setJsonText(e.target.value);
setJsonError("");
}}
className="min-h-[400px] font-mono text-sm"
placeholder='{"key": "value"}'
/>
{jsonError && (
<p className="text-sm text-destructive">{jsonError}</p>
)}
<div className="flex items-center gap-3">
<Button
onClick={() => confirmSave("json")}
disabled={saving}
className="gap-2"
>
{saving ? (
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
) : (
<HugeiconsIcon icon={FloppyDiskIcon} className="h-4 w-4" />
)}
{saving ? "Saving..." : "Save JSON"}
</Button>
<Button
variant="outline"
onClick={handleFormatJson}
className="gap-2"
>
<HugeiconsIcon icon={CodeIcon} className="h-4 w-4" />
Format
</Button>
<Button
variant="outline"
onClick={handleReset}
className="gap-2"
>
<HugeiconsIcon icon={ReloadIcon} className="h-4 w-4" />
Reset
</Button>
{saveSuccess && (
<span className="text-sm text-green-600 dark:text-green-400">
Settings saved successfully
</span>
)}
</div>
</div>
</TabsContent>
</Tabs>
)}
</div>
</div>
{/* Confirmation dialog */}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Save</AlertDialogTitle>
<AlertDialogDescription>
This will overwrite your current ~/.claude/settings.json file. Are
you sure you want to continue?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => pendingSaveAction && handleSave(pendingSaveAction)}
>
Save
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}