├── .nvmrc
├── public
├── favicon.png
├── screenshots
│ ├── TOP.png
│ ├── Setting.png
│ ├── gemini-cli-ui-diagram.png
│ └── gemini-cli-ui-diagram-en.png
├── icons
│ ├── icon-128x128.png
│ ├── icon-144x144.png
│ ├── icon-152x152.png
│ ├── icon-192x192.png
│ ├── icon-384x384.png
│ ├── icon-512x512.png
│ ├── icon-72x72.png
│ ├── icon-96x96.png
│ ├── icon-template.svg
│ ├── generate-icons.md
│ ├── gemini-ai-icon.svg
│ ├── icon-128x128.svg
│ ├── icon-512x512.svg
│ ├── icon-96x96.svg
│ ├── icon-192x192.svg
│ ├── icon-384x384.svg
│ ├── icon-144x144.svg
│ ├── icon-72x72.svg
│ └── icon-152x152.svg
├── logo.svg
├── sw.js
├── favicon.svg
├── manifest.json
├── convert-icons.md
├── generate-icons.js
└── sounds
│ └── generate-notification.html
├── postcss.config.js
├── src
├── lib
│ └── utils.js
├── components
│ ├── GeminiLogo.jsx
│ ├── ui
│ │ ├── scroll-area.jsx
│ │ ├── input.jsx
│ │ ├── badge.jsx
│ │ └── button.jsx
│ ├── ProtectedRoute.jsx
│ ├── DarkModeToggle.jsx
│ ├── ImageViewer.jsx
│ ├── ErrorBoundary.jsx
│ ├── MobileNav.jsx
│ ├── GeminiStatus.jsx
│ ├── TodoList.jsx
│ ├── LoginForm.jsx
│ ├── SetupForm.jsx
│ ├── MicButton.jsx
│ ├── MessageRenderer.jsx
│ ├── EnhancedMessageRenderer.jsx
│ └── QuickSettingsPanel.jsx
├── main.jsx
├── utils
│ ├── whisper.js
│ ├── notificationSound.js
│ ├── api.js
│ └── websocket.js
├── hooks
│ ├── useVersionCheck.js
│ └── useAudioRecorder.js
├── contexts
│ ├── ThemeContext.jsx
│ └── AuthContext.jsx
└── index.css
├── CHANGELOG.md
├── .env.example
├── server
├── database
│ ├── init.sql
│ └── db.js
├── middleware
│ └── auth.js
├── routes
│ ├── auth.js
│ └── mcp.js
├── sessionManager.js
└── gemini-response-handler.js
├── vite.config.js
├── .gitignore
├── tailwind.config.js
├── package.json
├── index.html
├── README_jp.md
└── README.md
/.nvmrc:
--------------------------------------------------------------------------------
1 | v20.19.3
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cruzyjapan/Gemini-CLI-UI/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/public/screenshots/TOP.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cruzyjapan/Gemini-CLI-UI/HEAD/public/screenshots/TOP.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
--------------------------------------------------------------------------------
/public/icons/icon-128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cruzyjapan/Gemini-CLI-UI/HEAD/public/icons/icon-128x128.png
--------------------------------------------------------------------------------
/public/icons/icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cruzyjapan/Gemini-CLI-UI/HEAD/public/icons/icon-144x144.png
--------------------------------------------------------------------------------
/public/icons/icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cruzyjapan/Gemini-CLI-UI/HEAD/public/icons/icon-152x152.png
--------------------------------------------------------------------------------
/public/icons/icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cruzyjapan/Gemini-CLI-UI/HEAD/public/icons/icon-192x192.png
--------------------------------------------------------------------------------
/public/icons/icon-384x384.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cruzyjapan/Gemini-CLI-UI/HEAD/public/icons/icon-384x384.png
--------------------------------------------------------------------------------
/public/icons/icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cruzyjapan/Gemini-CLI-UI/HEAD/public/icons/icon-512x512.png
--------------------------------------------------------------------------------
/public/icons/icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cruzyjapan/Gemini-CLI-UI/HEAD/public/icons/icon-72x72.png
--------------------------------------------------------------------------------
/public/icons/icon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cruzyjapan/Gemini-CLI-UI/HEAD/public/icons/icon-96x96.png
--------------------------------------------------------------------------------
/public/screenshots/Setting.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cruzyjapan/Gemini-CLI-UI/HEAD/public/screenshots/Setting.png
--------------------------------------------------------------------------------
/public/screenshots/gemini-cli-ui-diagram.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cruzyjapan/Gemini-CLI-UI/HEAD/public/screenshots/gemini-cli-ui-diagram.png
--------------------------------------------------------------------------------
/public/screenshots/gemini-cli-ui-diagram-en.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cruzyjapan/Gemini-CLI-UI/HEAD/public/screenshots/gemini-cli-ui-diagram-en.png
--------------------------------------------------------------------------------
/src/lib/utils.js:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs) {
5 | return twMerge(clsx(inputs))
6 | }
--------------------------------------------------------------------------------
/src/components/GeminiLogo.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const GeminiLogo = ({className = 'w-5 h-5'}) => {
4 | return (
5 |
6 | );
7 | };
8 |
9 | export default GeminiLogo;
--------------------------------------------------------------------------------
/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App.jsx'
4 | // import TestApp from './TestApp.jsx'
5 | import './index.css'
6 |
7 | ReactDOM.createRoot(document.getElementById('root')).render(
8 |
9 |
10 | ,
11 | )
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## [1.5.0] - 2025-07-15
4 |
5 | ### 追加
6 | - Gemini 2.5 Proモデルのサポート
7 | - モデル選択機能(設定画面から選択可能)
8 | - YOLOモード(--yoloフラグ対応)
9 | - チャット画面にモデル表示
10 | - 画像添付機能の改善
11 | - 日本語README
12 |
13 | ### 変更
14 | - Claude CLI UIからGemini CLI UIへの移行
15 | - デフォルトモデルをgemini-2.5-proに変更
16 | - UIの各種ブランディング更新
17 | - 不要なデバッグログの削除
18 |
19 | ### 修正
20 | - チャット入力欄の配置問題
21 | - レイアウトの初期読み込み時の崩れ
22 | - セッション管理の改善
23 |
24 | ### 削除
25 | - Claude関連のファイルとコンポーネント
26 | - MCP Servers機能(一時的に)
27 | - 不要なデバッグログ出力
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Backend server port
2 | PORT=4008
3 |
4 | # Frontend port (Vite)
5 | VITE_PORT=4009
6 |
7 | # Gemini CLI path (optional)
8 | # If gemini is not in your PATH, specify the full path here
9 | # Example: GEMINI_PATH=/home/username/.nvm/versions/node/v22.17.0/bin/gemini
10 | # GEMINI_PATH=gemini
11 |
12 | # Database file path (optional)
13 | # DATABASE_PATH=./data/database.sqlite
14 |
15 | # JWT Secret (required for production)
16 | # Generate a secure random string for production use
17 | JWT_SECRET=your-secret-key-here
18 |
19 | # Node environment
20 | NODE_ENV=development
--------------------------------------------------------------------------------
/public/icons/icon-template.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cn } from "../../lib/utils"
3 |
4 | const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
5 |
10 |
17 | {children}
18 |
19 |
20 | ))
21 | ScrollArea.displayName = "ScrollArea"
22 |
23 | export { ScrollArea }
--------------------------------------------------------------------------------
/public/icons/generate-icons.md:
--------------------------------------------------------------------------------
1 | # PWA Icons Required
2 |
3 | Create the following icon files in this directory:
4 |
5 | - icon-72x72.png
6 | - icon-96x96.png
7 | - icon-128x128.png
8 | - icon-144x144.png
9 | - icon-152x152.png
10 | - icon-192x192.png
11 | - icon-384x384.png
12 | - icon-512x512.png
13 |
14 | You can use any icon generator tool or create them manually. The icons should be square and represent your Claude Code UI application.
15 |
16 | For a quick solution, you can:
17 | 1. Create a simple square PNG icon (512x512)
18 | 2. Use online tools like realfavicongenerator.net to generate all sizes
19 | 3. Or use ImageMagick: `convert icon-512x512.png -resize 192x192 icon-192x192.png`
--------------------------------------------------------------------------------
/server/database/init.sql:
--------------------------------------------------------------------------------
1 | -- Initialize authentication database
2 | PRAGMA foreign_keys = ON;
3 |
4 | -- Users table (single user system) - prefixed with geminicliui_ to avoid conflicts
5 | CREATE TABLE IF NOT EXISTS geminicliui_users (
6 | id INTEGER PRIMARY KEY AUTOINCREMENT,
7 | username TEXT UNIQUE NOT NULL,
8 | password_hash TEXT NOT NULL,
9 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
10 | last_login DATETIME,
11 | is_active BOOLEAN DEFAULT 1
12 | );
13 |
14 | -- Indexes for performance
15 | CREATE INDEX IF NOT EXISTS idx_geminicliui_users_username ON geminicliui_users(username);
16 | CREATE INDEX IF NOT EXISTS idx_geminicliui_users_active ON geminicliui_users(is_active);
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig, loadEnv } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | export default defineConfig(({ command, mode }) => {
5 | // Load env file based on `mode` in the current working directory.
6 | const env = loadEnv(mode, process.cwd(), '')
7 |
8 |
9 | return {
10 | plugins: [react()],
11 | server: {
12 | port: parseInt(env.VITE_PORT) || 4009,
13 | proxy: {
14 | '/api': `http://localhost:${env.PORT || 4008}`,
15 | '/ws': {
16 | target: `ws://localhost:${env.PORT || 4008}`,
17 | ws: true
18 | }
19 | }
20 | },
21 | build: {
22 | outDir: 'dist'
23 | }
24 | }
25 | })
--------------------------------------------------------------------------------
/public/icons/gemini-ai-icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/components/ui/input.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cn } from "../../lib/utils"
3 |
4 | const Input = React.forwardRef(({ className, type, ...props }, ref) => {
5 | return (
6 |
15 | )
16 | })
17 | Input.displayName = "Input"
18 |
19 | export { Input }
--------------------------------------------------------------------------------
/src/components/ui/badge.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva } from "class-variance-authority"
3 | import { cn } from "../../lib/utils"
4 |
5 | const badgeVariants = cva(
6 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
7 | {
8 | variants: {
9 | variant: {
10 | default:
11 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
12 | secondary:
13 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
14 | destructive:
15 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
16 | outline: "text-foreground",
17 | },
18 | },
19 | defaultVariants: {
20 | variant: "default",
21 | },
22 | }
23 | )
24 |
25 | function Badge({ className, variant, ...props }) {
26 | return (
27 |
28 | )
29 | }
30 |
31 | export { Badge, badgeVariants }
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/public/sw.js:
--------------------------------------------------------------------------------
1 | // Service Worker for Gemini CLI UI PWA
2 | const CACHE_NAME = 'gemini-ui-v1';
3 | const urlsToCache = [
4 | '/',
5 | '/index.html',
6 | '/manifest.json'
7 | ];
8 |
9 | // Install event
10 | self.addEventListener('install', event => {
11 | event.waitUntil(
12 | caches.open(CACHE_NAME)
13 | .then(cache => {
14 | return cache.addAll(urlsToCache);
15 | })
16 | );
17 | self.skipWaiting();
18 | });
19 |
20 | // Fetch event
21 | self.addEventListener('fetch', event => {
22 | event.respondWith(
23 | caches.match(event.request)
24 | .then(response => {
25 | // Return cached response if found
26 | if (response) {
27 | return response;
28 | }
29 | // Otherwise fetch from network
30 | return fetch(event.request);
31 | }
32 | )
33 | );
34 | });
35 |
36 | // Activate event
37 | self.addEventListener('activate', event => {
38 | event.waitUntil(
39 | caches.keys().then(cacheNames => {
40 | return Promise.all(
41 | cacheNames.map(cacheName => {
42 | if (cacheName !== CACHE_NAME) {
43 | return caches.delete(cacheName);
44 | }
45 | })
46 | );
47 | })
48 | );
49 | });
--------------------------------------------------------------------------------
/src/utils/whisper.js:
--------------------------------------------------------------------------------
1 | import { api } from './api';
2 |
3 | export async function transcribeWithWhisper(audioBlob, onStatusChange) {
4 | const formData = new FormData();
5 | const fileName = `recording_${Date.now()}.webm`;
6 | const file = new File([audioBlob], fileName, { type: audioBlob.type });
7 |
8 | formData.append('audio', file);
9 |
10 | const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
11 | formData.append('mode', whisperMode);
12 |
13 | try {
14 | // Start with transcribing state
15 | if (onStatusChange) {
16 | onStatusChange('transcribing');
17 | }
18 |
19 | const response = await api.transcribe(formData);
20 |
21 | if (!response.ok) {
22 | const errorData = await response.json().catch(() => ({}));
23 | throw new Error(
24 | errorData.error ||
25 | `Transcription error: ${response.status} ${response.statusText}`
26 | );
27 | }
28 |
29 | const data = await response.json();
30 | return data.text || '';
31 | } catch (error) {
32 | if (error.name === 'TypeError' && error.message.includes('fetch')) {
33 | throw new Error('Cannot connect to server. Please ensure the backend is running.');
34 | }
35 | throw error;
36 | }
37 | }
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/public/icons/icon-128x128.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/public/icons/icon-512x512.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/public/icons/icon-96x96.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/public/icons/icon-192x192.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/public/icons/icon-384x384.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/public/icons/icon-144x144.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/public/icons/icon-72x72.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/public/icons/icon-152x152.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/components/ProtectedRoute.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useAuth } from '../contexts/AuthContext';
3 | import SetupForm from './SetupForm';
4 | import LoginForm from './LoginForm';
5 | import { MessageSquare } from 'lucide-react';
6 |
7 | const LoadingScreen = () => (
8 |
9 |
10 |
15 |
Gemini Code UI
16 |
21 |
Loading...
22 |
23 |
24 | );
25 |
26 | const ProtectedRoute = ({ children }) => {
27 | const { user, isLoading, needsSetup } = useAuth();
28 |
29 | if (isLoading) {
30 | return ;
31 | }
32 |
33 | if (needsSetup) {
34 | return ;
35 | }
36 |
37 | if (!user) {
38 | return ;
39 | }
40 |
41 | return children;
42 | };
43 |
44 | export default ProtectedRoute;
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Gemini CLI UI",
3 | "short_name": "Gemini UI",
4 | "description": "Gemini CLI UI - A stylish web interface for Gemini",
5 | "start_url": "/",
6 | "display": "standalone",
7 | "background_color": "#ffffff",
8 | "theme_color": "#0891b2",
9 | "orientation": "portrait-primary",
10 | "scope": "/",
11 | "icons": [
12 | {
13 | "src": "/icons/icon-72x72.png",
14 | "sizes": "72x72",
15 | "type": "image/png",
16 | "purpose": "maskable any"
17 | },
18 | {
19 | "src": "/icons/icon-96x96.png",
20 | "sizes": "96x96",
21 | "type": "image/png",
22 | "purpose": "maskable any"
23 | },
24 | {
25 | "src": "/icons/icon-128x128.png",
26 | "sizes": "128x128",
27 | "type": "image/png",
28 | "purpose": "maskable any"
29 | },
30 | {
31 | "src": "/icons/icon-144x144.png",
32 | "sizes": "144x144",
33 | "type": "image/png",
34 | "purpose": "maskable any"
35 | },
36 | {
37 | "src": "/icons/icon-152x152.png",
38 | "sizes": "152x152",
39 | "type": "image/png",
40 | "purpose": "maskable any"
41 | },
42 | {
43 | "src": "/icons/icon-192x192.png",
44 | "sizes": "192x192",
45 | "type": "image/png",
46 | "purpose": "maskable any"
47 | },
48 | {
49 | "src": "/icons/icon-384x384.png",
50 | "sizes": "384x384",
51 | "type": "image/png",
52 | "purpose": "maskable any"
53 | },
54 | {
55 | "src": "/icons/icon-512x512.png",
56 | "sizes": "512x512",
57 | "type": "image/png",
58 | "purpose": "maskable any"
59 | }
60 | ]
61 | }
--------------------------------------------------------------------------------
/src/components/DarkModeToggle.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useTheme } from '../contexts/ThemeContext';
3 |
4 | function DarkModeToggle() {
5 | const { isDarkMode, toggleDarkMode } = useTheme();
6 |
7 | return (
8 |
15 | Toggle dark mode
16 |
21 | {isDarkMode ? (
22 |
23 |
24 |
25 | ) : (
26 |
27 |
28 |
29 | )}
30 |
31 |
32 | );
33 | }
34 |
35 | export default DarkModeToggle;
--------------------------------------------------------------------------------
/src/hooks/useVersionCheck.js:
--------------------------------------------------------------------------------
1 | // hooks/useVersionCheck.js
2 | import { useState, useEffect } from 'react';
3 | import { version } from '../../package.json';
4 |
5 | export const useVersionCheck = (owner, repo) => {
6 | const [updateAvailable, setUpdateAvailable] = useState(false);
7 | const [latestVersion, setLatestVersion] = useState(null);
8 |
9 | useEffect(() => {
10 | // Version check is disabled - always return no update available
11 | // This prevents showing "Update Available" messages
12 | setUpdateAvailable(false);
13 | setLatestVersion(null);
14 |
15 | // Original version check code (disabled):
16 | /*
17 | const checkVersion = async () => {
18 | try {
19 | const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
20 | const data = await response.json();
21 |
22 | // Handle the case where there might not be any releases
23 | if (data.tag_name) {
24 | const latest = data.tag_name.replace(/^v/, '');
25 | setLatestVersion(latest);
26 | setUpdateAvailable(version !== latest);
27 | } else {
28 | // No releases found, don't show update notification
29 | setUpdateAvailable(false);
30 | setLatestVersion(null);
31 | }
32 | } catch (error) {
33 | // console.error('Version check failed:', error);
34 | // On error, don't show update notification
35 | setUpdateAvailable(false);
36 | setLatestVersion(null);
37 | }
38 | };
39 |
40 | checkVersion();
41 | const interval = setInterval(checkVersion, 5 * 60 * 1000); // Check every 5 minutes
42 | return () => clearInterval(interval);
43 | */
44 | }, [owner, repo]);
45 |
46 | return { updateAvailable, latestVersion, currentVersion: version };
47 | };
--------------------------------------------------------------------------------
/public/convert-icons.md:
--------------------------------------------------------------------------------
1 | # Convert SVG Icons to PNG
2 |
3 | I've created SVG versions of the app icons that match the MessageSquare design from the sidebar. To convert them to PNG format, you can use one of these methods:
4 |
5 | ## Method 1: Online Converter (Easiest)
6 | 1. Go to https://cloudconvert.com/svg-to-png
7 | 2. Upload each SVG file from the `/icons/` directory
8 | 3. Download the PNG versions
9 | 4. Replace the existing PNG files
10 |
11 | ## Method 2: Using Node.js (if you have it)
12 | ```bash
13 | npm install sharp
14 | node -e "
15 | const sharp = require('sharp');
16 | const fs = require('fs');
17 | const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
18 | sizes.forEach(size => {
19 | const svgPath = \`./icons/icon-\${size}x\${size}.svg\`;
20 | const pngPath = \`./icons/icon-\${size}x\${size}.png\`;
21 | if (fs.existsSync(svgPath)) {
22 | sharp(svgPath).png().toFile(pngPath);
23 | console.log(\`Converted \${svgPath} to \${pngPath}\`);
24 | }
25 | });
26 | "
27 | ```
28 |
29 | ## Method 3: Using ImageMagick (if installed)
30 | ```bash
31 | cd public/icons
32 | for size in 72 96 128 144 152 192 384 512; do
33 | convert "icon-${size}x${size}.svg" "icon-${size}x${size}.png"
34 | done
35 | ```
36 |
37 | ## Method 4: Using Inkscape (if installed)
38 | ```bash
39 | cd public/icons
40 | for size in 72 96 128 144 152 192 384 512; do
41 | inkscape --export-type=png "icon-${size}x${size}.svg"
42 | done
43 | ```
44 |
45 | ## Icon Design
46 | The new icons feature:
47 | - Clean MessageSquare (chat bubble) design matching the sidebar
48 | - Primary color background with rounded corners
49 | - White stroke icon that's clearly visible
50 | - Consistent sizing and proportions across all sizes
51 | - Proper PWA-compliant format
52 |
53 | Once converted, the PNG files will replace the existing ones and provide a consistent icon experience across all platforms.
--------------------------------------------------------------------------------
/src/components/ui/button.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva } from "class-variance-authority"
3 | import { cn } from "../../lib/utils"
4 |
5 | const buttonVariants = cva(
6 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 transform hover:-translate-y-0.5 active:translate-y-0",
7 | {
8 | variants: {
9 | variant: {
10 | default:
11 | "bg-primary text-primary-foreground shadow-md hover:shadow-lg hover:bg-primary/90 gemini-shadow",
12 | destructive:
13 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
14 | outline:
15 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground gemini-hover",
16 | secondary:
17 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
18 | ghost: "hover:bg-accent hover:text-accent-foreground",
19 | link: "text-primary underline-offset-4 hover:underline",
20 | gemini: "gemini-button text-white shadow-lg hover:shadow-xl",
21 | },
22 | size: {
23 | default: "h-9 px-4 py-2",
24 | sm: "h-8 rounded-md px-3 text-xs",
25 | lg: "h-10 rounded-md px-8",
26 | icon: "h-9 w-9",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | const Button = React.forwardRef(({ className, variant, size, ...props }, ref) => {
37 | return (
38 |
43 | )
44 | })
45 | Button.displayName = "Button"
46 |
47 | export { Button, buttonVariants }
--------------------------------------------------------------------------------
/src/components/ImageViewer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Button } from './ui/button';
3 | import { X } from 'lucide-react';
4 |
5 | function ImageViewer({ file, onClose }) {
6 | const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;
7 |
8 | return (
9 |
10 |
11 |
12 |
13 | {file.name}
14 |
15 |
21 |
22 |
23 |
24 |
25 |
26 |
{
31 | e.target.style.display = 'none';
32 | e.target.nextSibling.style.display = 'block';
33 | }}
34 | />
35 |
39 |
Unable to load image
40 |
{file.path}
41 |
42 |
43 |
44 |
45 |
46 | {file.path}
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
54 | export default ImageViewer;
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules/
3 | npm-debug.log*
4 | yarn-debug.log*
5 | yarn-error.log*
6 | pnpm-debug.log*
7 | lerna-debug.log*
8 |
9 | # Build outputs
10 | dist/
11 | dist-ssr/
12 | build/
13 | out/
14 |
15 | # Environment variables
16 | .env
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | # IDE and editor files
23 | .vscode/
24 | .idea/
25 | *.swp
26 | *.swo
27 | *~
28 |
29 | # OS generated files
30 | .DS_Store
31 | .DS_Store?
32 | ._*
33 | .Spotlight-V100
34 | .Trashes
35 | ehthumbs.db
36 | Thumbs.db
37 |
38 | # Logs
39 | *.log
40 | logs/
41 |
42 | # Runtime data
43 | pids
44 | *.pid
45 | *.seed
46 | *.pid.lock
47 |
48 | # Coverage directory used by tools like istanbul
49 | coverage/
50 | *.lcov
51 |
52 | # nyc test coverage
53 | .nyc_output
54 |
55 | # Dependency directories
56 | jspm_packages/
57 |
58 | # Optional npm cache directory
59 | .npm
60 |
61 | # Optional eslint cache
62 | .eslintcache
63 |
64 | # Optional REPL history
65 | .node_repl_history
66 |
67 | # Output of 'npm pack'
68 | *.tgz
69 |
70 | # Yarn Integrity file
71 | .yarn-integrity
72 |
73 | # dotenv environment variables file
74 | .env.test
75 |
76 | # parcel-bundler cache (https://parceljs.org/)
77 | .cache
78 | .parcel-cache
79 |
80 | # Next.js build output
81 | .next
82 |
83 | # Nuxt.js build / generate output
84 | .nuxt
85 |
86 | # Storybook build outputs
87 | .out
88 | .storybook-out
89 |
90 | # Temporary folders
91 | tmp/
92 | temp/
93 | .tmp/
94 |
95 | # Vite
96 | .vite/
97 |
98 | # Local Netlify folder
99 | .netlify
100 |
101 | # Claude specific
102 | .claude/
103 |
104 | # Database files
105 | *.db
106 | *.sqlite
107 | *.sqlite3
108 |
109 | # Backup files
110 | *.backup
111 | *.bak
112 |
113 | # Windows Zone.Identifier files
114 | *:Zone.Identifier
115 |
116 | # Development logs
117 | dev.log
118 | dev_*.log
119 | *_dev.log
120 |
121 | # Test files
122 | test-*.html
123 | test-*.js
124 | test-*.jsx
125 | *.test.js
126 | *.test.jsx
127 | *.spec.js
128 | *.spec.jsx
129 | TestApp.jsx
130 |
131 | # Project data and sessions (exclude from git)
132 | projects_data/
133 | session_data/
134 | user_uploads/
--------------------------------------------------------------------------------
/src/components/ErrorBoundary.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | class ErrorBoundary extends React.Component {
4 | constructor(props) {
5 | super(props);
6 | this.state = { hasError: false, error: null };
7 | }
8 |
9 | static getDerivedStateFromError(error) {
10 | return { hasError: true };
11 | }
12 |
13 | componentDidCatch(error, errorInfo) {
14 | // Log error to console in development
15 | if (process.env.NODE_ENV === 'development') {
16 | console.error('Error caught by ErrorBoundary:', error, errorInfo);
17 | }
18 | }
19 |
20 | render() {
21 | if (this.state.hasError) {
22 | return (
23 |
24 |
25 |
26 |
32 |
38 |
39 |
40 | Oops! Something went wrong
41 |
42 |
43 | An unexpected error occurred. Please refresh the page to try again.
44 |
45 |
window.location.reload()}
47 | className="mt-4 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
48 | >
49 | Refresh Page
50 |
51 |
52 |
53 |
54 | );
55 | }
56 |
57 | return this.props.children;
58 | }
59 | }
60 |
61 | export default ErrorBoundary;
--------------------------------------------------------------------------------
/server/middleware/auth.js:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken';
2 | import { userDb } from '../database/db.js';
3 |
4 | // Get JWT secret from environment or use default (for development)
5 | const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
6 |
7 | // Optional API key middleware
8 | const validateApiKey = (req, res, next) => {
9 | // Skip API key validation if not configured
10 | if (!process.env.API_KEY) {
11 | return next();
12 | }
13 |
14 | const apiKey = req.headers['x-api-key'];
15 | if (apiKey !== process.env.API_KEY) {
16 | return res.status(401).json({ error: 'Invalid API key' });
17 | }
18 | next();
19 | };
20 |
21 | // JWT authentication middleware
22 | const authenticateToken = async (req, res, next) => {
23 | const authHeader = req.headers['authorization'];
24 | const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
25 |
26 | if (!token) {
27 | return res.status(401).json({ error: 'Access denied. No token provided.' });
28 | }
29 |
30 | try {
31 | const decoded = jwt.verify(token, JWT_SECRET);
32 |
33 | // Verify user still exists and is active
34 | const user = userDb.getUserById(decoded.userId);
35 | if (!user) {
36 | return res.status(401).json({ error: 'Invalid token. User not found.' });
37 | }
38 |
39 | req.user = user;
40 | next();
41 | } catch (error) {
42 | console.error('Token verification error:', error);
43 | return res.status(403).json({ error: 'Invalid token' });
44 | }
45 | };
46 |
47 | // Generate JWT token (never expires)
48 | const generateToken = (user) => {
49 | return jwt.sign(
50 | {
51 | userId: user.id,
52 | username: user.username
53 | },
54 | JWT_SECRET
55 | // No expiration - token lasts forever
56 | );
57 | };
58 |
59 | // WebSocket authentication function
60 | const authenticateWebSocket = (token) => {
61 | if (!token) {
62 | return null;
63 | }
64 |
65 | try {
66 | const decoded = jwt.verify(token, JWT_SECRET);
67 | return decoded;
68 | } catch (error) {
69 | console.error('WebSocket token verification error:', error);
70 | return null;
71 | }
72 | };
73 |
74 | export {
75 | validateApiKey,
76 | authenticateToken,
77 | generateToken,
78 | authenticateWebSocket,
79 | JWT_SECRET
80 | };
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | darkMode: ["class"],
4 | content: [
5 | "./index.html",
6 | "./src/**/*.{js,ts,jsx,tsx}",
7 | ],
8 | theme: {
9 | container: {
10 | center: true,
11 | padding: "2rem",
12 | screens: {
13 | "2xl": "1400px",
14 | },
15 | },
16 | extend: {
17 | colors: {
18 | gemini: {
19 | 50: '#f0fdfa',
20 | 100: '#ccfbf1',
21 | 200: '#99f6e4',
22 | 300: '#5eead4',
23 | 400: '#2dd4bf',
24 | 500: '#14b8a6',
25 | 600: '#0d9488',
26 | 700: '#0f766e',
27 | 800: '#115e59',
28 | 900: '#134e4a',
29 | 950: '#042f2e',
30 | },
31 | border: "hsl(var(--border))",
32 | input: "hsl(var(--input))",
33 | ring: "hsl(var(--ring))",
34 | background: "hsl(var(--background))",
35 | foreground: "hsl(var(--foreground))",
36 | primary: {
37 | DEFAULT: "hsl(var(--primary))",
38 | foreground: "hsl(var(--primary-foreground))",
39 | },
40 | secondary: {
41 | DEFAULT: "hsl(var(--secondary))",
42 | foreground: "hsl(var(--secondary-foreground))",
43 | },
44 | destructive: {
45 | DEFAULT: "hsl(var(--destructive))",
46 | foreground: "hsl(var(--destructive-foreground))",
47 | },
48 | muted: {
49 | DEFAULT: "hsl(var(--muted))",
50 | foreground: "hsl(var(--muted-foreground))",
51 | },
52 | accent: {
53 | DEFAULT: "hsl(var(--accent))",
54 | foreground: "hsl(var(--accent-foreground))",
55 | },
56 | popover: {
57 | DEFAULT: "hsl(var(--popover))",
58 | foreground: "hsl(var(--popover-foreground))",
59 | },
60 | card: {
61 | DEFAULT: "hsl(var(--card))",
62 | foreground: "hsl(var(--card-foreground))",
63 | },
64 | },
65 | borderRadius: {
66 | lg: "var(--radius)",
67 | md: "calc(var(--radius) - 2px)",
68 | sm: "calc(var(--radius) - 4px)",
69 | },
70 | spacing: {
71 | 'safe-area-inset-bottom': 'env(safe-area-inset-bottom)',
72 | },
73 | },
74 | },
75 | plugins: [require('@tailwindcss/typography')],
76 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gemini-cli-ui",
3 | "version": "1.5.0",
4 | "description": "A stylish web-based UI for Gemini CLI",
5 | "type": "module",
6 | "main": "server/index.js",
7 | "scripts": {
8 | "dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
9 | "server": "node server/index.js",
10 | "client": "vite --host",
11 | "build": "vite build",
12 | "preview": "vite preview",
13 | "start": "npm run build && npm run server"
14 | },
15 | "keywords": [
16 | "gemini",
17 | "ai",
18 | "code",
19 | "ui",
20 | "assistant"
21 | ],
22 | "author": "Gemini CLI UI Contributors",
23 | "license": "MIT",
24 | "dependencies": {
25 | "@codemirror/lang-css": "^6.3.1",
26 | "@codemirror/lang-html": "^6.4.9",
27 | "@codemirror/lang-javascript": "^6.2.4",
28 | "@codemirror/lang-json": "^6.0.1",
29 | "@codemirror/lang-markdown": "^6.3.3",
30 | "@codemirror/lang-python": "^6.2.1",
31 | "@codemirror/theme-one-dark": "^6.1.2",
32 | "@google/generative-ai": "^0.21.0",
33 | "@tailwindcss/typography": "^0.5.16",
34 | "@uiw/react-codemirror": "^4.23.13",
35 | "@xterm/addon-clipboard": "^0.1.0",
36 | "@xterm/addon-webgl": "^0.18.0",
37 | "bcrypt": "^6.0.0",
38 | "better-sqlite3": "^12.2.0",
39 | "chokidar": "^4.0.3",
40 | "class-variance-authority": "^0.7.1",
41 | "clsx": "^2.1.1",
42 | "cors": "^2.8.5",
43 | "express": "^4.18.2",
44 | "jsonwebtoken": "^9.0.2",
45 | "lucide-react": "^0.515.0",
46 | "mime-types": "^3.0.1",
47 | "multer": "^2.0.1",
48 | "node-fetch": "^2.7.0",
49 | "node-pty": "^1.0.0",
50 | "react": "^18.2.0",
51 | "react-dom": "^18.2.0",
52 | "react-dropzone": "^14.2.3",
53 | "react-markdown": "^10.1.0",
54 | "react-router-dom": "^6.8.1",
55 | "react-syntax-highlighter": "^15.6.6",
56 | "remark-breaks": "^4.0.0",
57 | "remark-gfm": "^4.0.1",
58 | "tailwind-merge": "^3.3.1",
59 | "ws": "^8.14.2",
60 | "xterm": "^5.3.0",
61 | "xterm-addon-fit": "^0.8.0"
62 | },
63 | "devDependencies": {
64 | "@types/react": "^18.2.43",
65 | "@types/react-dom": "^18.2.17",
66 | "@vitejs/plugin-react": "^4.6.0",
67 | "autoprefixer": "^10.4.16",
68 | "concurrently": "^8.2.2",
69 | "postcss": "^8.4.32",
70 | "sharp": "^0.34.2",
71 | "tailwindcss": "^3.4.0",
72 | "vite": "^7.0.4"
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/server/database/db.js:
--------------------------------------------------------------------------------
1 | import Database from 'better-sqlite3';
2 | import path from 'path';
3 | import fs from 'fs';
4 | import { fileURLToPath } from 'url';
5 | import { dirname } from 'path';
6 |
7 | const __filename = fileURLToPath(import.meta.url);
8 | const __dirname = dirname(__filename);
9 |
10 | const DB_PATH = path.join(__dirname, 'geminicliui_auth.db');
11 | const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
12 |
13 | // Create database connection
14 | const db = new Database(DB_PATH);
15 | // console.log('Connected to SQLite database');
16 |
17 | // Initialize database with schema
18 | const initializeDatabase = async () => {
19 | try {
20 | const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
21 | db.exec(initSQL);
22 | // console.log('Database initialized successfully');
23 | } catch (error) {
24 | // console.error('Error initializing database:', error.message);
25 | throw error;
26 | }
27 | };
28 |
29 | // User database operations
30 | const userDb = {
31 | // Check if any users exist
32 | hasUsers: () => {
33 | try {
34 | const row = db.prepare('SELECT COUNT(*) as count FROM geminicliui_users').get();
35 | return row.count > 0;
36 | } catch (err) {
37 | throw err;
38 | }
39 | },
40 |
41 | // Create a new user
42 | createUser: (username, passwordHash) => {
43 | try {
44 | const stmt = db.prepare('INSERT INTO geminicliui_users (username, password_hash) VALUES (?, ?)');
45 | const result = stmt.run(username, passwordHash);
46 | return { id: result.lastInsertRowid, username };
47 | } catch (err) {
48 | throw err;
49 | }
50 | },
51 |
52 | // Get user by username
53 | getUserByUsername: (username) => {
54 | try {
55 | const row = db.prepare('SELECT * FROM geminicliui_users WHERE username = ? AND is_active = 1').get(username);
56 | return row;
57 | } catch (err) {
58 | throw err;
59 | }
60 | },
61 |
62 | // Update last login time
63 | updateLastLogin: (userId) => {
64 | try {
65 | db.prepare('UPDATE geminicliui_users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
66 | } catch (err) {
67 | throw err;
68 | }
69 | },
70 |
71 | // Get user by ID
72 | getUserById: (userId) => {
73 | try {
74 | const row = db.prepare('SELECT id, username, created_at, last_login FROM geminicliui_users WHERE id = ? AND is_active = 1').get(userId);
75 | return row;
76 | } catch (err) {
77 | throw err;
78 | }
79 | }
80 | };
81 |
82 | export {
83 | db,
84 | initializeDatabase,
85 | userDb
86 | };
--------------------------------------------------------------------------------
/src/components/MobileNav.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { MessageSquare, Folder, Terminal, GitBranch, Globe } from 'lucide-react';
3 |
4 | function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
5 | // Detect dark mode
6 | const isDarkMode = document.documentElement.classList.contains('dark');
7 | const navItems = [
8 | {
9 | id: 'chat',
10 | icon: MessageSquare,
11 | onClick: () => setActiveTab('chat')
12 | },
13 | {
14 | id: 'shell',
15 | icon: Terminal,
16 | onClick: () => setActiveTab('shell')
17 | },
18 | {
19 | id: 'files',
20 | icon: Folder,
21 | onClick: () => setActiveTab('files')
22 | },
23 | {
24 | id: 'git',
25 | icon: GitBranch,
26 | onClick: () => setActiveTab('git')
27 | }
28 | ];
29 |
30 | return (
31 | <>
32 |
42 |
47 |
48 | {navItems.map((item) => {
49 | const Icon = item.icon;
50 | const isActive = activeTab === item.id;
51 |
52 | return (
53 |
{
57 | e.preventDefault();
58 | item.onClick();
59 | }}
60 | className={`flex items-center justify-center p-2 rounded-lg min-h-[40px] min-w-[40px] relative touch-manipulation ${
61 | isActive
62 | ? 'text-blue-600 dark:text-blue-400'
63 | : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
64 | }`}
65 | aria-label={item.id}
66 | >
67 |
68 | {isActive && (
69 |
70 | )}
71 |
72 | );
73 | })}
74 |
75 |
76 | >
77 | );
78 | }
79 |
80 | export default MobileNav;
--------------------------------------------------------------------------------
/src/utils/notificationSound.js:
--------------------------------------------------------------------------------
1 | let audioContext = null;
2 |
3 | // Create a simple notification sound using Web Audio API
4 | function createNotificationSound(context) {
5 | const duration = 0.3;
6 | const sampleRate = context.sampleRate;
7 | const numSamples = duration * sampleRate;
8 |
9 | // Create buffer
10 | const buffer = context.createBuffer(1, numSamples, sampleRate);
11 | const data = buffer.getChannelData(0);
12 |
13 | // Generate a pleasant notification sound (two-tone chime)
14 | for (let i = 0; i < numSamples; i++) {
15 | const t = i / sampleRate;
16 | let sample = 0;
17 |
18 | // First tone (higher pitch)
19 | if (t < 0.15) {
20 | const envelope = Math.sin(Math.PI * t / 0.15);
21 | sample += envelope * 0.3 * Math.sin(2 * Math.PI * 880 * t); // A5
22 | }
23 |
24 | // Second tone (lower pitch)
25 | if (t >= 0.15 && t < 0.3) {
26 | const envelope = Math.sin(Math.PI * (t - 0.15) / 0.15);
27 | sample += envelope * 0.3 * Math.sin(2 * Math.PI * 659.25 * t); // E5
28 | }
29 |
30 | data[i] = sample;
31 | }
32 |
33 | return buffer;
34 | }
35 |
36 | // Play the notification sound
37 | export async function playNotificationSound() {
38 | try {
39 | // Check if sound is enabled
40 | const settings = JSON.parse(localStorage.getItem('gemini-tools-settings') || '{}');
41 | // console.log('Notification settings:', settings);
42 | // console.log('Sound enabled:', settings.enableNotificationSound);
43 | if (!settings.enableNotificationSound) {
44 | // console.log('Notification sound is disabled');
45 | return;
46 | }
47 |
48 | // console.log('Sound is enabled, initializing audio...');
49 |
50 | // Create or resume audio context
51 | if (!audioContext) {
52 | audioContext = new (window.AudioContext || window.webkitAudioContext)();
53 | // console.log('Created audio context');
54 | }
55 |
56 | // Resume context if it's suspended (required for some browsers)
57 | if (audioContext.state === 'suspended') {
58 | await audioContext.resume();
59 | // console.log('Resumed audio context');
60 | }
61 |
62 | // Create the notification sound
63 | const buffer = createNotificationSound(audioContext);
64 | // console.log('Created sound buffer');
65 |
66 | // Play the sound
67 | const source = audioContext.createBufferSource();
68 | source.buffer = buffer;
69 |
70 | // Create gain node for volume control
71 | const gainNode = audioContext.createGain();
72 | gainNode.gain.value = 0.5; // 50% volume
73 |
74 | source.connect(gainNode);
75 | gainNode.connect(audioContext.destination);
76 | source.start();
77 | // console.log('Sound playback started');
78 |
79 | } catch (error) {
80 | // console.error('Failed to play notification sound:', error);
81 | }
82 | }
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Gemini CLI UI
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
86 |
87 |
--------------------------------------------------------------------------------
/src/utils/api.js:
--------------------------------------------------------------------------------
1 | // Utility function for authenticated API calls
2 | export const authenticatedFetch = (url, options = {}) => {
3 | const token = localStorage.getItem('auth-token');
4 |
5 | const defaultHeaders = {
6 | 'Content-Type': 'application/json',
7 | };
8 |
9 | if (token) {
10 | defaultHeaders['Authorization'] = `Bearer ${token}`;
11 | }
12 |
13 | return fetch(url, {
14 | ...options,
15 | headers: {
16 | ...defaultHeaders,
17 | ...options.headers,
18 | },
19 | });
20 | };
21 |
22 | // API endpoints
23 | export const api = {
24 | // Auth endpoints (no token required)
25 | auth: {
26 | status: () => fetch('/api/auth/status'),
27 | login: (username, password) => fetch('/api/auth/login', {
28 | method: 'POST',
29 | headers: { 'Content-Type': 'application/json' },
30 | body: JSON.stringify({ username, password }),
31 | }),
32 | register: (username, password) => fetch('/api/auth/register', {
33 | method: 'POST',
34 | headers: { 'Content-Type': 'application/json' },
35 | body: JSON.stringify({ username, password }),
36 | }),
37 | user: () => authenticatedFetch('/api/auth/user'),
38 | logout: () => authenticatedFetch('/api/auth/logout', { method: 'POST' }),
39 | },
40 |
41 | // Protected endpoints
42 | config: () => authenticatedFetch('/api/config'),
43 | projects: () => authenticatedFetch('/api/projects'),
44 | sessions: (projectName, limit = 5, offset = 0) =>
45 | authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
46 | sessionMessages: (projectName, sessionId) =>
47 | authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}/messages`),
48 | renameProject: (projectName, displayName) =>
49 | authenticatedFetch(`/api/projects/${projectName}/rename`, {
50 | method: 'PUT',
51 | body: JSON.stringify({ displayName }),
52 | }),
53 | deleteSession: (projectName, sessionId) =>
54 | authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
55 | method: 'DELETE',
56 | }),
57 | deleteProject: (projectName) =>
58 | authenticatedFetch(`/api/projects/${projectName}`, {
59 | method: 'DELETE',
60 | }),
61 | createProject: (path) =>
62 | authenticatedFetch('/api/projects/create', {
63 | method: 'POST',
64 | body: JSON.stringify({ path }),
65 | }),
66 | readFile: (projectName, filePath) =>
67 | authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),
68 | saveFile: (projectName, filePath, content) =>
69 | authenticatedFetch(`/api/projects/${projectName}/file`, {
70 | method: 'PUT',
71 | body: JSON.stringify({ filePath, content }),
72 | }),
73 | getFiles: (projectName) =>
74 | authenticatedFetch(`/api/projects/${projectName}/files`),
75 | transcribe: (formData) =>
76 | authenticatedFetch('/api/transcribe', {
77 | method: 'POST',
78 | body: formData,
79 | headers: {}, // Let browser set Content-Type for FormData
80 | }),
81 | };
--------------------------------------------------------------------------------
/src/contexts/ThemeContext.jsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useState, useEffect } from 'react';
2 |
3 | const ThemeContext = createContext();
4 |
5 | export const useTheme = () => {
6 | const context = useContext(ThemeContext);
7 | if (!context) {
8 | throw new Error('useTheme must be used within a ThemeProvider');
9 | }
10 | return context;
11 | };
12 |
13 | export const ThemeProvider = ({ children }) => {
14 | // Check for saved theme preference or default to system preference
15 | const [isDarkMode, setIsDarkMode] = useState(() => {
16 | // Check localStorage first
17 | const savedTheme = localStorage.getItem('theme');
18 | if (savedTheme) {
19 | return savedTheme === 'dark';
20 | }
21 |
22 | // Check system preference
23 | if (window.matchMedia) {
24 | return window.matchMedia('(prefers-color-scheme: dark)').matches;
25 | }
26 |
27 | return false;
28 | });
29 |
30 | // Update document class and localStorage when theme changes
31 | useEffect(() => {
32 | if (isDarkMode) {
33 | document.documentElement.classList.add('dark');
34 | localStorage.setItem('theme', 'dark');
35 |
36 | // Update iOS status bar style and theme color for dark mode
37 | const statusBarMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
38 | if (statusBarMeta) {
39 | statusBarMeta.setAttribute('content', 'black-translucent');
40 | }
41 |
42 | const themeColorMeta = document.querySelector('meta[name="theme-color"]');
43 | if (themeColorMeta) {
44 | themeColorMeta.setAttribute('content', '#0c1117'); // Dark background color (hsl(222.2 84% 4.9%))
45 | }
46 | } else {
47 | document.documentElement.classList.remove('dark');
48 | localStorage.setItem('theme', 'light');
49 |
50 | // Update iOS status bar style and theme color for light mode
51 | const statusBarMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
52 | if (statusBarMeta) {
53 | statusBarMeta.setAttribute('content', 'default');
54 | }
55 |
56 | const themeColorMeta = document.querySelector('meta[name="theme-color"]');
57 | if (themeColorMeta) {
58 | themeColorMeta.setAttribute('content', '#ffffff'); // Light background color
59 | }
60 | }
61 | }, [isDarkMode]);
62 |
63 | // Listen for system theme changes
64 | useEffect(() => {
65 | if (!window.matchMedia) return;
66 |
67 | const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
68 | const handleChange = (e) => {
69 | // Only update if user hasn't manually set a preference
70 | const savedTheme = localStorage.getItem('theme');
71 | if (!savedTheme) {
72 | setIsDarkMode(e.matches);
73 | }
74 | };
75 |
76 | mediaQuery.addEventListener('change', handleChange);
77 | return () => mediaQuery.removeEventListener('change', handleChange);
78 | }, []);
79 |
80 | const toggleDarkMode = () => {
81 | setIsDarkMode(prev => !prev);
82 | };
83 |
84 | const value = {
85 | isDarkMode,
86 | toggleDarkMode,
87 | };
88 |
89 | return (
90 |
91 | {children}
92 |
93 | );
94 | };
--------------------------------------------------------------------------------
/src/hooks/useAudioRecorder.js:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useCallback } from 'react';
2 |
3 | export function useAudioRecorder() {
4 | const [isRecording, setRecording] = useState(false);
5 | const [audioBlob, setAudioBlob] = useState(null);
6 | const [error, setError] = useState(null);
7 | const mediaRecorderRef = useRef(null);
8 | const streamRef = useRef(null);
9 | const chunksRef = useRef([]);
10 |
11 | const start = useCallback(async () => {
12 | try {
13 | setError(null);
14 | setAudioBlob(null);
15 | chunksRef.current = [];
16 |
17 | // Request microphone access
18 | const stream = await navigator.mediaDevices.getUserMedia({
19 | audio: {
20 | echoCancellation: true,
21 | noiseSuppression: true,
22 | sampleRate: 16000,
23 | }
24 | });
25 |
26 | streamRef.current = stream;
27 |
28 | // Determine supported MIME type
29 | const mimeType = MediaRecorder.isTypeSupported('audio/webm')
30 | ? 'audio/webm'
31 | : 'audio/mp4';
32 |
33 | // Create media recorder
34 | const recorder = new MediaRecorder(stream, { mimeType });
35 | mediaRecorderRef.current = recorder;
36 |
37 | // Set up event handlers
38 | recorder.ondataavailable = (e) => {
39 | if (e.data.size > 0) {
40 | chunksRef.current.push(e.data);
41 | }
42 | };
43 |
44 | recorder.onstop = () => {
45 | // Create blob from chunks
46 | const blob = new Blob(chunksRef.current, { type: mimeType });
47 | setAudioBlob(blob);
48 |
49 | // Clean up stream
50 | if (streamRef.current) {
51 | streamRef.current.getTracks().forEach(track => track.stop());
52 | streamRef.current = null;
53 | }
54 | };
55 |
56 | recorder.onerror = (event) => {
57 | // console.error('MediaRecorder error:', event);
58 | setError('Recording failed');
59 | setRecording(false);
60 | };
61 |
62 | // Start recording
63 | recorder.start();
64 | setRecording(true);
65 | // Debug - Recording started
66 | } catch (err) {
67 | // console.error('Failed to start recording:', err);
68 | setError(err.message || 'Failed to start recording');
69 | setRecording(false);
70 | }
71 | }, []);
72 |
73 | const stop = useCallback(() => {
74 | // Debug - Stop called, recorder state
75 |
76 | try {
77 | if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
78 | mediaRecorderRef.current.stop();
79 | // Debug - Recording stopped
80 | }
81 | } catch (err) {
82 | // console.error('Error stopping recorder:', err);
83 | }
84 |
85 | // Always update state
86 | setRecording(false);
87 |
88 | // Clean up stream if still active
89 | if (streamRef.current) {
90 | streamRef.current.getTracks().forEach(track => track.stop());
91 | streamRef.current = null;
92 | }
93 | }, []);
94 |
95 | const reset = useCallback(() => {
96 | setAudioBlob(null);
97 | setError(null);
98 | chunksRef.current = [];
99 | }, []);
100 |
101 | return {
102 | isRecording,
103 | audioBlob,
104 | error,
105 | start,
106 | stop,
107 | reset
108 | };
109 | }
--------------------------------------------------------------------------------
/src/components/GeminiStatus.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { cn } from '../lib/utils';
3 |
4 | function GeminiStatus({ status, onAbort, isLoading }) {
5 | const [elapsedTime, setElapsedTime] = useState(0);
6 | const [animationPhase, setAnimationPhase] = useState(0);
7 |
8 | // Update elapsed time every second
9 | useEffect(() => {
10 | if (!isLoading) {
11 | setElapsedTime(0);
12 | return;
13 | }
14 |
15 | const startTime = Date.now();
16 | const timer = setInterval(() => {
17 | const elapsed = Math.floor((Date.now() - startTime) / 1000);
18 | setElapsedTime(elapsed);
19 | }, 1000);
20 |
21 | return () => clearInterval(timer);
22 | }, [isLoading]);
23 |
24 | // Animate the status indicator
25 | useEffect(() => {
26 | if (!isLoading) return;
27 |
28 | const timer = setInterval(() => {
29 | setAnimationPhase(prev => (prev + 1) % 4);
30 | }, 500);
31 |
32 | return () => clearInterval(timer);
33 | }, [isLoading]);
34 |
35 | if (!isLoading) return null;
36 |
37 | // Clever action words that cycle
38 | const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
39 | const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
40 |
41 | // Parse status data
42 | const statusText = status?.text || actionWords[actionIndex];
43 | const canInterrupt = status?.can_interrupt !== false;
44 |
45 | // Animation characters
46 | const spinners = ['✻', '✹', '✸', '✶'];
47 | const currentSpinner = spinners[animationPhase];
48 |
49 | return (
50 |
51 |
52 |
53 |
54 | {/* Animated spinner */}
55 |
59 | {currentSpinner}
60 |
61 |
62 | {/* Status text - first line */}
63 |
64 |
65 | {statusText}...
66 | ({elapsedTime}s)
67 |
68 |
69 |
70 |
71 |
72 | {/* Interrupt button */}
73 | {canInterrupt && onAbort && (
74 |
78 |
79 |
80 |
81 | Stop
82 |
83 | )}
84 |
85 |
86 | );
87 | }
88 |
89 | export default GeminiStatus;
--------------------------------------------------------------------------------
/src/components/TodoList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Badge } from './ui/badge';
3 | import { CheckCircle2, Clock, Circle } from 'lucide-react';
4 |
5 | const TodoList = ({ todos, isResult = false }) => {
6 | if (!todos || !Array.isArray(todos)) {
7 | return null;
8 | }
9 |
10 | const getStatusIcon = (status) => {
11 | switch (status) {
12 | case 'completed':
13 | return ;
14 | case 'in_progress':
15 | return ;
16 | case 'pending':
17 | default:
18 | return ;
19 | }
20 | };
21 |
22 | const getStatusColor = (status) => {
23 | switch (status) {
24 | case 'completed':
25 | return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800';
26 | case 'in_progress':
27 | return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800';
28 | case 'pending':
29 | default:
30 | return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700';
31 | }
32 | };
33 |
34 | const getPriorityColor = (priority) => {
35 | switch (priority) {
36 | case 'high':
37 | return 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800';
38 | case 'medium':
39 | return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800';
40 | case 'low':
41 | default:
42 | return 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700';
43 | }
44 | };
45 |
46 | return (
47 |
48 | {isResult && (
49 |
50 | Todo List ({todos.length} {todos.length === 1 ? 'item' : 'items'})
51 |
52 | )}
53 |
54 | {todos.map((todo) => (
55 |
59 |
60 | {getStatusIcon(todo.status)}
61 |
62 |
63 |
64 |
65 |
66 | {todo.content}
67 |
68 |
69 |
70 |
74 | {todo.priority}
75 |
76 |
80 | {todo.status.replace('_', ' ')}
81 |
82 |
83 |
84 |
85 |
86 | ))}
87 |
88 | );
89 | };
90 |
91 | export default TodoList;
--------------------------------------------------------------------------------
/public/generate-icons.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import path from 'path';
3 | import { fileURLToPath } from 'url';
4 | import sharp from 'sharp';
5 |
6 | const __filename = fileURLToPath(import.meta.url);
7 | const __dirname = path.dirname(__filename);
8 |
9 | // Icon sizes needed
10 | const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
11 |
12 | // SVG template function for Gemini logo
13 | function createIconSVG(size) {
14 | const cornerRadius = Math.round(size * 0.1875); // 18.75% corner radius for modern look
15 |
16 | return `
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | `;
47 | }
48 |
49 | // Generate SVG and PNG files for each size
50 | async function generateIcons() {
51 | for (const size of sizes) {
52 | const svgContent = createIconSVG(size);
53 | const svgFilename = `icon-${size}x${size}.svg`;
54 | const pngFilename = `icon-${size}x${size}.png`;
55 | const svgFilepath = path.join(__dirname, 'icons', svgFilename);
56 | const pngFilepath = path.join(__dirname, 'icons', pngFilename);
57 |
58 | // Write SVG file
59 | fs.writeFileSync(svgFilepath, svgContent);
60 | console.log(`Created ${svgFilename}`);
61 |
62 | // Convert SVG to PNG using sharp
63 | try {
64 | await sharp(Buffer.from(svgContent))
65 | .png()
66 | .toFile(pngFilepath);
67 | console.log(`Created ${pngFilename}`);
68 | } catch (error) {
69 | console.error(`Error creating ${pngFilename}:`, error.message);
70 | }
71 | }
72 |
73 | // Also create favicon.png from favicon.svg
74 | try {
75 | const faviconSvg = fs.readFileSync(path.join(__dirname, 'favicon.svg'), 'utf8');
76 | await sharp(Buffer.from(faviconSvg))
77 | .resize(64, 64)
78 | .png()
79 | .toFile(path.join(__dirname, 'favicon.png'));
80 | console.log('\nCreated favicon.png');
81 | } catch (error) {
82 | console.error('Error creating favicon.png:', error.message);
83 | }
84 | }
85 |
86 | generateIcons().then(() => {
87 | console.log('\nAll icons generated successfully!');
88 | }).catch(error => {
89 | console.error('Error generating icons:', error);
90 | });
--------------------------------------------------------------------------------
/src/utils/websocket.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react';
2 |
3 | export function useWebSocket() {
4 | const [ws, setWs] = useState(null);
5 | const [messages, setMessages] = useState([]);
6 | const [isConnected, setIsConnected] = useState(false);
7 | const reconnectTimeoutRef = useRef(null);
8 |
9 | useEffect(() => {
10 | connect();
11 |
12 | return () => {
13 | if (reconnectTimeoutRef.current) {
14 | clearTimeout(reconnectTimeoutRef.current);
15 | }
16 | if (ws) {
17 | ws.close();
18 | }
19 | };
20 | }, []);
21 |
22 | const connect = async () => {
23 | try {
24 | // Get authentication token
25 | const token = localStorage.getItem('auth-token');
26 | if (!token) {
27 | // console.warn('No authentication token found for WebSocket connection');
28 | return;
29 | }
30 |
31 | // Fetch server configuration to get the correct WebSocket URL
32 | let wsBaseUrl;
33 | try {
34 | const configResponse = await fetch('/api/config', {
35 | headers: {
36 | 'Authorization': `Bearer ${token}`
37 | }
38 | });
39 | const config = await configResponse.json();
40 | wsBaseUrl = config.wsUrl;
41 |
42 | // If the config returns localhost but we're not on localhost, use current host but with API server port
43 | if (wsBaseUrl.includes('localhost') && !window.location.hostname.includes('localhost')) {
44 | // console.warn('Config returned localhost, using current host with API server port instead');
45 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
46 | // For development, API server is typically on port 4008 when Vite is on 4009
47 | const apiPort = window.location.port === '4009' ? '4008' : window.location.port;
48 | wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
49 | }
50 | } catch (error) {
51 | // console.warn('Could not fetch server config, falling back to current host with API server port');
52 | const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
53 | // For development, API server is typically on port 4008 when Vite is on 4009
54 | const apiPort = window.location.port === '4009' ? '4008' : window.location.port;
55 | wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
56 | }
57 |
58 | // Include token in WebSocket URL as query parameter
59 | const wsUrl = `${wsBaseUrl}/ws?token=${encodeURIComponent(token)}`;
60 | const websocket = new WebSocket(wsUrl);
61 |
62 | websocket.onopen = () => {
63 | setIsConnected(true);
64 | setWs(websocket);
65 | };
66 |
67 | websocket.onmessage = (event) => {
68 | try {
69 | const data = JSON.parse(event.data);
70 | setMessages(prev => [...prev, data]);
71 | } catch (error) {
72 | // console.error('Error parsing WebSocket message:', error);
73 | }
74 | };
75 |
76 | websocket.onclose = () => {
77 | setIsConnected(false);
78 | setWs(null);
79 |
80 | // Attempt to reconnect after 3 seconds
81 | reconnectTimeoutRef.current = setTimeout(() => {
82 | connect();
83 | }, 3000);
84 | };
85 |
86 | websocket.onerror = (error) => {
87 | // console.error('WebSocket error:', error);
88 | };
89 |
90 | } catch (error) {
91 | // console.error('Error creating WebSocket connection:', error);
92 | }
93 | };
94 |
95 | const sendMessage = (message) => {
96 | if (ws && isConnected) {
97 | ws.send(JSON.stringify(message));
98 | } else {
99 | // console.warn('WebSocket not connected');
100 | }
101 | };
102 |
103 | return {
104 | ws,
105 | sendMessage,
106 | messages,
107 | isConnected
108 | };
109 | }
--------------------------------------------------------------------------------
/server/routes/auth.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import bcrypt from 'bcrypt';
3 | import { userDb } from '../database/db.js';
4 | import { generateToken, authenticateToken } from '../middleware/auth.js';
5 |
6 | const router = express.Router();
7 |
8 | // Check auth status and setup requirements
9 | router.get('/status', async (req, res) => {
10 | try {
11 | const hasUsers = await userDb.hasUsers();
12 | res.json({
13 | needsSetup: !hasUsers,
14 | isAuthenticated: false // Will be overridden by frontend if token exists
15 | });
16 | } catch (error) {
17 | console.error('Auth status error:', error);
18 | res.status(500).json({ error: 'Internal server error' });
19 | }
20 | });
21 |
22 | // User registration (setup) - only allowed if no users exist
23 | router.post('/register', async (req, res) => {
24 | try {
25 | const { username, password } = req.body;
26 |
27 | // Validate input
28 | if (!username || !password) {
29 | return res.status(400).json({ error: 'Username and password are required' });
30 | }
31 |
32 | if (username.length < 3 || password.length < 6) {
33 | return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' });
34 | }
35 |
36 | // Check if users already exist (only allow one user)
37 | const hasUsers = userDb.hasUsers();
38 | if (hasUsers) {
39 | return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
40 | }
41 |
42 | // Hash password
43 | const saltRounds = 12;
44 | const passwordHash = await bcrypt.hash(password, saltRounds);
45 |
46 | // Create user
47 | const user = userDb.createUser(username, passwordHash);
48 |
49 | // Generate token
50 | const token = generateToken(user);
51 |
52 | // Update last login
53 | userDb.updateLastLogin(user.id);
54 |
55 | res.json({
56 | success: true,
57 | user: { id: user.id, username: user.username },
58 | token
59 | });
60 |
61 | } catch (error) {
62 | console.error('Registration error:', error);
63 | if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
64 | res.status(409).json({ error: 'Username already exists' });
65 | } else {
66 | res.status(500).json({ error: 'Internal server error' });
67 | }
68 | }
69 | });
70 |
71 | // User login
72 | router.post('/login', async (req, res) => {
73 | try {
74 | const { username, password } = req.body;
75 |
76 | // Validate input
77 | if (!username || !password) {
78 | return res.status(400).json({ error: 'Username and password are required' });
79 | }
80 |
81 | // Get user from database
82 | const user = userDb.getUserByUsername(username);
83 | if (!user) {
84 | return res.status(401).json({ error: 'Invalid username or password' });
85 | }
86 |
87 | // Verify password
88 | const isValidPassword = await bcrypt.compare(password, user.password_hash);
89 | if (!isValidPassword) {
90 | return res.status(401).json({ error: 'Invalid username or password' });
91 | }
92 |
93 | // Generate token
94 | const token = generateToken(user);
95 |
96 | // Update last login
97 | userDb.updateLastLogin(user.id);
98 |
99 | res.json({
100 | success: true,
101 | user: { id: user.id, username: user.username },
102 | token
103 | });
104 |
105 | } catch (error) {
106 | console.error('Login error:', error);
107 | res.status(500).json({ error: 'Internal server error' });
108 | }
109 | });
110 |
111 | // Get current user (protected route)
112 | router.get('/user', authenticateToken, (req, res) => {
113 | res.json({
114 | user: req.user
115 | });
116 | });
117 |
118 | // Logout (client-side token removal, but this endpoint can be used for logging)
119 | router.post('/logout', authenticateToken, (req, res) => {
120 | // In a simple JWT system, logout is mainly client-side
121 | // This endpoint exists for consistency and potential future logging
122 | res.json({ success: true, message: 'Logged out successfully' });
123 | });
124 |
125 | export default router;
--------------------------------------------------------------------------------
/src/components/LoginForm.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useAuth } from '../contexts/AuthContext';
3 | import { MessageSquare } from 'lucide-react';
4 |
5 | const LoginForm = () => {
6 | const [username, setUsername] = useState('');
7 | const [password, setPassword] = useState('');
8 | const [isLoading, setIsLoading] = useState(false);
9 | const [error, setError] = useState('');
10 |
11 | const { login } = useAuth();
12 |
13 | const handleSubmit = async (e) => {
14 | e.preventDefault();
15 | setError('');
16 |
17 | if (!username || !password) {
18 | setError('Please enter both username and password');
19 | return;
20 | }
21 |
22 | setIsLoading(true);
23 |
24 | const result = await login(username, password);
25 |
26 | if (!result.success) {
27 | setError(result.error);
28 | }
29 |
30 | setIsLoading(false);
31 | };
32 |
33 | return (
34 |
35 |
36 |
37 | {/* Logo and Title */}
38 |
39 |
44 |
Welcome Back
45 |
46 | Sign in to your Gemini CLI UI account
47 |
48 |
49 |
50 | {/* Login Form */}
51 |
98 |
99 |
100 |
101 | Enter your credentials to access Gemini Code UI
102 |
103 |
104 |
105 |
106 |
107 | );
108 | };
109 |
110 | export default LoginForm;
--------------------------------------------------------------------------------
/public/sounds/generate-notification.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Generate Notification Sound
5 |
6 |
7 | Notification Sound Generator
8 | Play Sound
9 | Download as notification.wav
10 |
11 |
104 |
105 |
--------------------------------------------------------------------------------
/src/contexts/AuthContext.jsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useEffect, useState } from 'react';
2 | import { api } from '../utils/api';
3 |
4 | const AuthContext = createContext({
5 | user: null,
6 | token: null,
7 | login: () => {},
8 | register: () => {},
9 | logout: () => {},
10 | isLoading: true,
11 | needsSetup: false,
12 | error: null
13 | });
14 |
15 | export const useAuth = () => {
16 | const context = useContext(AuthContext);
17 | if (!context) {
18 | throw new Error('useAuth must be used within an AuthProvider');
19 | }
20 | return context;
21 | };
22 |
23 | export const AuthProvider = ({ children }) => {
24 | const [user, setUser] = useState(null);
25 | const [token, setToken] = useState(localStorage.getItem('auth-token'));
26 | const [isLoading, setIsLoading] = useState(true);
27 | const [needsSetup, setNeedsSetup] = useState(false);
28 | const [error, setError] = useState(null);
29 |
30 | // Check authentication status on mount
31 | useEffect(() => {
32 | checkAuthStatus();
33 | }, []);
34 |
35 | const checkAuthStatus = async () => {
36 | try {
37 | setIsLoading(true);
38 | setError(null);
39 |
40 | // Check if system needs setup
41 | const statusResponse = await api.auth.status();
42 | const statusData = await statusResponse.json();
43 |
44 | if (statusData.needsSetup) {
45 | setNeedsSetup(true);
46 | setIsLoading(false);
47 | return;
48 | }
49 |
50 | // If we have a token, verify it
51 | if (token) {
52 | try {
53 | const userResponse = await api.auth.user();
54 |
55 | if (userResponse.ok) {
56 | const userData = await userResponse.json();
57 | setUser(userData.user);
58 | setNeedsSetup(false);
59 | } else {
60 | // Token is invalid
61 | localStorage.removeItem('auth-token');
62 | setToken(null);
63 | setUser(null);
64 | }
65 | } catch (error) {
66 | console.error('Token verification failed:', error);
67 | localStorage.removeItem('auth-token');
68 | setToken(null);
69 | setUser(null);
70 | }
71 | }
72 | } catch (error) {
73 | console.error('Auth status check failed:', error);
74 | setError('Failed to check authentication status');
75 | } finally {
76 | setIsLoading(false);
77 | }
78 | };
79 |
80 | const login = async (username, password) => {
81 | try {
82 | setError(null);
83 | const response = await api.auth.login(username, password);
84 |
85 | const data = await response.json();
86 |
87 | if (response.ok) {
88 | setToken(data.token);
89 | setUser(data.user);
90 | localStorage.setItem('auth-token', data.token);
91 | return { success: true };
92 | } else {
93 | setError(data.error || 'Login failed');
94 | return { success: false, error: data.error || 'Login failed' };
95 | }
96 | } catch (error) {
97 | console.error('Login error:', error);
98 | const errorMessage = 'Network error. Please try again.';
99 | setError(errorMessage);
100 | return { success: false, error: errorMessage };
101 | }
102 | };
103 |
104 | const register = async (username, password) => {
105 | try {
106 | setError(null);
107 | const response = await api.auth.register(username, password);
108 |
109 | const data = await response.json();
110 |
111 | if (response.ok) {
112 | setToken(data.token);
113 | setUser(data.user);
114 | setNeedsSetup(false);
115 | localStorage.setItem('auth-token', data.token);
116 | return { success: true };
117 | } else {
118 | setError(data.error || 'Registration failed');
119 | return { success: false, error: data.error || 'Registration failed' };
120 | }
121 | } catch (error) {
122 | console.error('Registration error:', error);
123 | const errorMessage = 'Network error. Please try again.';
124 | setError(errorMessage);
125 | return { success: false, error: errorMessage };
126 | }
127 | };
128 |
129 | const logout = () => {
130 | setToken(null);
131 | setUser(null);
132 | localStorage.removeItem('auth-token');
133 |
134 | // Optional: Call logout endpoint for logging
135 | if (token) {
136 | api.auth.logout().catch(error => {
137 | console.error('Logout endpoint error:', error);
138 | });
139 | }
140 | };
141 |
142 | const value = {
143 | user,
144 | token,
145 | login,
146 | register,
147 | logout,
148 | isLoading,
149 | needsSetup,
150 | error
151 | };
152 |
153 | return (
154 |
155 | {children}
156 |
157 | );
158 | };
--------------------------------------------------------------------------------
/src/components/SetupForm.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useAuth } from '../contexts/AuthContext';
3 | import GeminiLogo from './GeminiLogo';
4 |
5 | const SetupForm = () => {
6 | const [username, setUsername] = useState('');
7 | const [password, setPassword] = useState('');
8 | const [confirmPassword, setConfirmPassword] = useState('');
9 | const [isLoading, setIsLoading] = useState(false);
10 | const [error, setError] = useState('');
11 |
12 | const { register } = useAuth();
13 |
14 | const handleSubmit = async (e) => {
15 | e.preventDefault();
16 | setError('');
17 |
18 | if (password !== confirmPassword) {
19 | setError('Passwords do not match');
20 | return;
21 | }
22 |
23 | if (username.length < 3) {
24 | setError('Username must be at least 3 characters long');
25 | return;
26 | }
27 |
28 | if (password.length < 6) {
29 | setError('Password must be at least 6 characters long');
30 | return;
31 | }
32 |
33 | setIsLoading(true);
34 |
35 | const result = await register(username, password);
36 |
37 | if (!result.success) {
38 | setError(result.error);
39 | }
40 |
41 | setIsLoading(false);
42 | };
43 |
44 | return (
45 |
46 |
47 |
48 | {/* Logo and Title */}
49 |
50 |
51 |
52 |
53 |
Welcome to Gemini CLI UI
54 |
55 | Set up your account to get started
56 |
57 |
58 |
59 | {/* Setup Form */}
60 |
123 |
124 |
125 |
126 | This is a single-user system. Only one account can be created.
127 |
128 |
129 |
130 |
131 |
132 | );
133 | };
134 |
135 | export default SetupForm;
--------------------------------------------------------------------------------
/server/sessionManager.js:
--------------------------------------------------------------------------------
1 | import { promises as fs } from 'fs';
2 | import path from 'path';
3 | import os from 'os';
4 |
5 | class SessionManager {
6 | constructor() {
7 | // Store sessions in memory with conversation history
8 | this.sessions = new Map();
9 | this.sessionsDir = path.join(os.homedir(), '.gemini', 'sessions');
10 | this.initSessionsDir();
11 | }
12 |
13 | async initSessionsDir() {
14 | try {
15 | await fs.mkdir(this.sessionsDir, { recursive: true });
16 | } catch (error) {
17 | // console.error('Error creating sessions directory:', error);
18 | }
19 | }
20 |
21 | // Create a new session
22 | createSession(sessionId, projectPath) {
23 | const session = {
24 | id: sessionId,
25 | projectPath: projectPath,
26 | messages: [],
27 | createdAt: new Date(),
28 | lastActivity: new Date()
29 | };
30 |
31 | this.sessions.set(sessionId, session);
32 | this.saveSession(sessionId);
33 |
34 | return session;
35 | }
36 |
37 | // Add a message to session
38 | addMessage(sessionId, role, content) {
39 | let session = this.sessions.get(sessionId);
40 |
41 | if (!session) {
42 | // Create session if it doesn't exist
43 | session = this.createSession(sessionId, '');
44 | }
45 |
46 | const message = {
47 | role: role, // 'user' or 'assistant'
48 | content: content,
49 | timestamp: new Date()
50 | };
51 |
52 | session.messages.push(message);
53 | session.lastActivity = new Date();
54 |
55 | this.saveSession(sessionId);
56 |
57 | return session;
58 | }
59 |
60 | // Get session by ID
61 | getSession(sessionId) {
62 | return this.sessions.get(sessionId);
63 | }
64 |
65 | // Get all sessions for a project
66 | getProjectSessions(projectPath) {
67 | const sessions = [];
68 |
69 | for (const [id, session] of this.sessions) {
70 | if (session.projectPath === projectPath) {
71 | sessions.push({
72 | id: session.id,
73 | summary: this.getSessionSummary(session),
74 | messageCount: session.messages.length,
75 | lastActivity: session.lastActivity
76 | });
77 | }
78 | }
79 |
80 | return sessions.sort((a, b) =>
81 | new Date(b.lastActivity) - new Date(a.lastActivity)
82 | );
83 | }
84 |
85 | // Get session summary
86 | getSessionSummary(session) {
87 | if (session.messages.length === 0) {
88 | return 'New Session';
89 | }
90 |
91 | // Find first user message
92 | const firstUserMessage = session.messages.find(m => m.role === 'user');
93 | if (firstUserMessage) {
94 | const content = firstUserMessage.content;
95 | return content.length > 50 ? content.substring(0, 50) + '...' : content;
96 | }
97 |
98 | return 'New Session';
99 | }
100 |
101 | // Build conversation context for Gemini
102 | buildConversationContext(sessionId, maxMessages = 10) {
103 | const session = this.sessions.get(sessionId);
104 |
105 | if (!session || session.messages.length === 0) {
106 | return '';
107 | }
108 |
109 | // Get last N messages for context
110 | const recentMessages = session.messages.slice(-maxMessages);
111 |
112 | let context = '以下は過去の会話履歴です:\n\n';
113 |
114 | for (const msg of recentMessages) {
115 | if (msg.role === 'user') {
116 | context += `ユーザー: ${msg.content}\n`;
117 | } else {
118 | context += `アシスタント: ${msg.content}\n`;
119 | }
120 | }
121 |
122 | context += '\n上記の会話履歴を踏まえて、次の質問に答えてください:\n';
123 |
124 | return context;
125 | }
126 |
127 | // Save session to disk
128 | async saveSession(sessionId) {
129 | const session = this.sessions.get(sessionId);
130 | if (!session) return;
131 |
132 | try {
133 | const filePath = path.join(this.sessionsDir, `${sessionId}.json`);
134 | await fs.writeFile(filePath, JSON.stringify(session, null, 2));
135 | } catch (error) {
136 | // console.error('Error saving session:', error);
137 | }
138 | }
139 |
140 | // Load sessions from disk
141 | async loadSessions() {
142 | try {
143 | const files = await fs.readdir(this.sessionsDir);
144 |
145 | for (const file of files) {
146 | if (file.endsWith('.json')) {
147 | try {
148 | const filePath = path.join(this.sessionsDir, file);
149 | const data = await fs.readFile(filePath, 'utf8');
150 | const session = JSON.parse(data);
151 |
152 | // Convert dates
153 | session.createdAt = new Date(session.createdAt);
154 | session.lastActivity = new Date(session.lastActivity);
155 | session.messages.forEach(msg => {
156 | msg.timestamp = new Date(msg.timestamp);
157 | });
158 |
159 | this.sessions.set(session.id, session);
160 | } catch (error) {
161 | // console.error(`Error loading session ${file}:`, error);
162 | }
163 | }
164 | }
165 | } catch (error) {
166 | // console.error('Error loading sessions:', error);
167 | }
168 | }
169 |
170 | // Delete a session
171 | async deleteSession(sessionId) {
172 | this.sessions.delete(sessionId);
173 |
174 | try {
175 | const filePath = path.join(this.sessionsDir, `${sessionId}.json`);
176 | await fs.unlink(filePath);
177 | } catch (error) {
178 | // console.error('Error deleting session file:', error);
179 | }
180 | }
181 |
182 | // Get session messages for display
183 | getSessionMessages(sessionId) {
184 | const session = this.sessions.get(sessionId);
185 | if (!session) return [];
186 |
187 | return session.messages.map(msg => ({
188 | type: 'message',
189 | message: {
190 | role: msg.role,
191 | content: msg.content
192 | },
193 | timestamp: msg.timestamp.toISOString()
194 | }));
195 | }
196 | }
197 |
198 | // Singleton instance
199 | const sessionManager = new SessionManager();
200 |
201 | // Load existing sessions on startup
202 | sessionManager.loadSessions();
203 |
204 | export default sessionManager;
--------------------------------------------------------------------------------
/server/gemini-response-handler.js:
--------------------------------------------------------------------------------
1 | // Gemini Response Handler - Intelligent message buffering
2 | class GeminiResponseHandler {
3 | constructor(ws, options = {}) {
4 | this.ws = ws;
5 | this.buffer = '';
6 | this.lastSentTime = Date.now();
7 | this.flushTimer = null;
8 | this.isAccumulating = false;
9 | this.inCodeBlock = false;
10 | this.codeBlockDepth = 0;
11 |
12 | // Configuration
13 | this.config = {
14 | // Wait time before sending partial message (ms)
15 | partialDelay: 500,
16 | // Maximum time to wait for complete message (ms)
17 | maxWaitTime: 2000,
18 | // Minimum buffer size before sending
19 | minBufferSize: 50,
20 | // Pattern to detect message completion
21 | completionPatterns: [
22 | /\.\s*$/, // Ends with period
23 | /\?\s*$/, // Ends with question mark
24 | /!\s*$/, // Ends with exclamation
25 | /```\s*$/, // Ends with code block
26 | /:\s*$/, // Ends with colon
27 | /\n\n$/, // Double line break
28 | ],
29 | ...options
30 | };
31 | }
32 |
33 | // Process incoming data from Gemini
34 | processData(data) {
35 | // Add to buffer
36 | this.buffer += data;
37 |
38 | // Update code block tracking
39 | this.updateCodeBlockState();
40 |
41 | // If we're in a code block, wait for it to complete
42 | if (this.inCodeBlock) {
43 | // Only flush if we have a complete code block
44 | if (this.isCodeBlockComplete()) {
45 | this.flush();
46 | } else {
47 | // Wait for more data, but set a max timeout
48 | this.scheduleFlush(1500); // Wait longer for code blocks
49 | }
50 | } else {
51 | // Normal processing for non-code content
52 | if (this.shouldSendImmediately()) {
53 | this.flush();
54 | } else {
55 | this.scheduleFlush();
56 | }
57 | }
58 | }
59 |
60 | // Update code block tracking state
61 | updateCodeBlockState() {
62 | const codeBlockStarts = (this.buffer.match(/```/g) || []).length;
63 | this.codeBlockDepth = codeBlockStarts;
64 | this.inCodeBlock = (codeBlockStarts % 2) !== 0;
65 | }
66 |
67 | // Check if current code block is complete
68 | isCodeBlockComplete() {
69 | const codeBlockCount = (this.buffer.match(/```/g) || []).length;
70 | return codeBlockCount > 0 && codeBlockCount % 2 === 0;
71 | }
72 |
73 | // Check if buffer contains a complete logical unit
74 | shouldSendImmediately() {
75 | // Don't send tiny fragments
76 | if (this.buffer.length < this.config.minBufferSize) {
77 | return false;
78 | }
79 |
80 | // Never split in the middle of a code block
81 | if (this.inCodeBlock) {
82 | return false;
83 | }
84 |
85 | // Check for completion patterns
86 | const trimmedBuffer = this.buffer.trim();
87 |
88 | // Check if it ends with a complete sentence or logical unit
89 | for (const pattern of this.config.completionPatterns) {
90 | if (pattern.test(trimmedBuffer)) {
91 | return true;
92 | }
93 | }
94 |
95 | // Check for complete list items
96 | if (trimmedBuffer.match(/^\d+\.\s+.+$/m) || trimmedBuffer.match(/^[-*]\s+.+$/m)) {
97 | // If it's a list and ends with newline, consider it complete
98 | if (trimmedBuffer.endsWith('\n')) {
99 | return true;
100 | }
101 | }
102 |
103 | // Check if enough time has passed since last send
104 | const timeSinceLastSend = Date.now() - this.lastSentTime;
105 | if (timeSinceLastSend > this.config.maxWaitTime && this.buffer.length > 0) {
106 | return true;
107 | }
108 |
109 | return false;
110 | }
111 |
112 | // Schedule a delayed flush
113 | scheduleFlush(customDelay = null) {
114 | // Clear existing timer
115 | if (this.flushTimer) {
116 | clearTimeout(this.flushTimer);
117 | }
118 |
119 | // Use custom delay or default
120 | const delay = customDelay || this.config.partialDelay;
121 |
122 | // Set new timer
123 | this.flushTimer = setTimeout(() => {
124 | if (this.buffer.length > 0) {
125 | this.flush();
126 | }
127 | }, delay);
128 | }
129 |
130 | // Send buffered content
131 | flush() {
132 | if (this.buffer.length === 0) {
133 | return;
134 | }
135 |
136 | // Clear timer
137 | if (this.flushTimer) {
138 | clearTimeout(this.flushTimer);
139 | this.flushTimer = null;
140 | }
141 |
142 | // Process buffer content
143 | let content = this.buffer.trim();
144 |
145 | // Fix common formatting issues
146 | content = this.fixFormatting(content);
147 |
148 | // Send if we have content
149 | if (content) {
150 | this.ws.send(JSON.stringify({
151 | type: 'gemini-response',
152 | data: {
153 | type: 'message',
154 | content: content,
155 | isPartial: !this.isComplete(content)
156 | }
157 | }));
158 |
159 | this.lastSentTime = Date.now();
160 | }
161 |
162 | // Clear buffer
163 | this.buffer = '';
164 | }
165 |
166 | // Fix common formatting issues
167 | fixFormatting(content) {
168 | // First, protect code blocks from other formatting
169 | const codeBlocks = [];
170 | let protectedContent = content.replace(/```[\s\S]*?```/g, (match) => {
171 | const index = codeBlocks.length;
172 | codeBlocks.push(match);
173 | return `__CODE_BLOCK_${index}__`;
174 | });
175 |
176 | // Remove excessive line breaks (but not in code blocks)
177 | protectedContent = protectedContent.replace(/\n{4,}/g, '\n\n\n');
178 |
179 | // Fix list formatting
180 | protectedContent = protectedContent.replace(/(\d+\.\s+[^\n]+)\n\n+(\d+\.)/g, '$1\n$2');
181 | protectedContent = protectedContent.replace(/([-*]\s+[^\n]+)\n\n+([-*])/g, '$1\n$2');
182 |
183 | // Ensure proper spacing around headers
184 | protectedContent = protectedContent.replace(/\n{3,}(#{1,6}\s)/g, '\n\n$1');
185 | protectedContent = protectedContent.replace(/(#{1,6}\s[^\n]+)\n{3,}/g, '$1\n\n');
186 |
187 | // Restore code blocks
188 | codeBlocks.forEach((block, index) => {
189 | // Clean up the code block itself
190 | let cleanBlock = block;
191 | // Remove excessive newlines at the start and end of code blocks
192 | cleanBlock = cleanBlock.replace(/```(\w*)\n{2,}/g, '```$1\n');
193 | cleanBlock = cleanBlock.replace(/\n{2,}```/g, '\n```');
194 |
195 | protectedContent = protectedContent.replace(`__CODE_BLOCK_${index}__`, cleanBlock);
196 | });
197 |
198 | return protectedContent.trim();
199 | }
200 |
201 | // Check if content appears complete
202 | isComplete(content) {
203 | const trimmed = content.trim();
204 |
205 | // Check for completion indicators
206 | if (trimmed.endsWith('.') ||
207 | trimmed.endsWith('!') ||
208 | trimmed.endsWith('?') ||
209 | trimmed.endsWith('```')) {
210 | return true;
211 | }
212 |
213 | // Check for balanced code blocks
214 | const codeBlockCount = (trimmed.match(/```/g) || []).length;
215 | if (codeBlockCount > 0 && codeBlockCount % 2 !== 0) {
216 | return false;
217 | }
218 |
219 | return true;
220 | }
221 |
222 | // Force flush any remaining content
223 | forceFlush() {
224 | if (this.flushTimer) {
225 | clearTimeout(this.flushTimer);
226 | this.flushTimer = null;
227 | }
228 |
229 | if (this.buffer.length > 0) {
230 | this.flush();
231 | }
232 | }
233 |
234 | // Clean up
235 | destroy() {
236 | this.forceFlush();
237 | if (this.flushTimer) {
238 | clearTimeout(this.flushTimer);
239 | this.flushTimer = null;
240 | }
241 | }
242 | }
243 |
244 | export default GeminiResponseHandler;
--------------------------------------------------------------------------------
/README_jp.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Gemini CLI UI
4 |
5 |
6 | [Gemini CLI](https://github.com/google-gemini/gemini-cli) GoogleのAI支援コーディング用公式CLIのデスクトップ・モバイル対応UIです。ローカルまたはリモートで使用でき、Gemini CLIのアクティブなプロジェクトとセッションを表示し、CLIと同じように変更を加えることができます。どこでも動作する適切なインターフェースを提供します。
7 |
8 |
9 | ## Screenshots
10 |
11 |
12 |
13 |
14 |
15 | Chat View
16 |
17 |
18 | Main interface showing project overview and chat
19 |
20 |
21 | Setting
22 |
23 |
24 | Setting
25 |
26 |
27 |
28 |
29 |
30 |
31 | Chat View
32 |
33 |
34 | Gemini CLI UI Diagram
35 |
36 |
37 |
38 |
39 | ## 機能
40 |
41 | - **レスポンシブデザイン** - デスクトップ、タブレット、モバイルでシームレスに動作し、モバイルからもGemini CLIを使用可能
42 | - **インタラクティブなチャットインターフェース** - Gemini CLIとのシームレスな通信のための組み込みチャットインターフェース
43 | - **統合シェルターミナル** - 組み込みシェル機能によるGemini CLIへの直接アクセス
44 | - **ファイルエクスプローラー** - シンタックスハイライトとライブ編集機能を備えたインタラクティブなファイルツリー
45 | - **Gitエクスプローラー** - 変更の表示、ステージング、コミット。ブランチの切り替えも可能
46 | - **セッション管理** - 会話の再開、複数セッションの管理、履歴の追跡
47 | - **モデル選択** - Gemini 2.5 Proを含む複数のGeminiモデルから選択可能
48 | - **YOLOモード** - 確認プロンプトをスキップして高速操作(注意して使用)
49 |
50 | ## クイックスタート
51 |
52 | ### 前提条件
53 |
54 | - [Node.js](https://nodejs.org/) v20以上
55 | - [Gemini CLI](https://github.com/google-gemini/gemini-cli)がインストールされ、設定済みであること
56 |
57 | ### インストール
58 |
59 | 1. **リポジトリをクローン:**
60 | ```bash
61 | git clone https://github.com/cruzyjapan/Gemini-CLI-UI.git
62 | cd Gemini-CLI-UI
63 | ```
64 |
65 | 2. **依存関係をインストール:**
66 | ```bash
67 | npm install
68 | ```
69 |
70 | 3. **環境設定:**
71 | ```bash
72 | cp .env.example .env
73 | # お好みの設定で.envを編集
74 | ```
75 |
76 | **注意**: `.env`ファイルはセキュリティのため削除されています。使用時は必ず`.env.example`をコピーして`.env`を作成し、必要に応じて設定を変更してください。
77 |
78 | 4. **アプリケーションを起動:**
79 | ```bash
80 | # 開発モード(ホットリロード付き)
81 | npm run dev
82 | ```
83 | アプリケーションは.envで指定したポートで起動します
84 |
85 | 5. **ブラウザを開く:**
86 | - 開発環境: `http://localhost:4009`
87 |
88 | ## セキュリティとツール設定
89 |
90 | **🔒 重要なお知らせ**: すべてのGemini CLIツールは**デフォルトで無効**になっています。これにより、潜在的に有害な操作が自動的に実行されることを防ぎます。
91 |
92 | ### ツールの有効化
93 |
94 | Gemini CLIの全機能を使用するには、手動でツールを有効にする必要があります:
95 |
96 | 1. **ツール設定を開く** - サイドバーの歯車アイコンをクリック
97 | 2. **必要に応じて有効化** - 必要なツールのみをオンにする
98 | 3. **設定を適用** - 設定はローカルに保存されます
99 |
100 |
101 | ### YOLOモードについて
102 |
103 | YOLOモード(「You Only Live Once」)は、Gemini CLIの `--yolo` フラグに相当し、すべての確認プロンプトをスキップします。このモードは作業を高速化しますが、注意して使用してください。
104 |
105 | **推奨アプローチ**: 基本的なツールから始めて、必要に応じて追加していきます。設定はいつでも調整できます。
106 |
107 | ## 使用ガイド
108 |
109 | ### コア機能
110 |
111 | #### プロジェクト管理
112 | UIは `~/.gemini/projects/` からGemini CLIプロジェクトを自動的に検出し、以下を提供します:
113 | - **ビジュアルプロジェクトブラウザー** - メタデータとセッション数を含むすべての利用可能なプロジェクト
114 | - **プロジェクトアクション** - プロジェクトの名前変更、削除、整理
115 | - **スマートナビゲーション** - 最近のプロジェクトとセッションへのクイックアクセス
116 |
117 | #### チャットインターフェース
118 | - **レスポンシブチャットまたはGemini CLIを使用** - 適応されたチャットインターフェースを使用するか、シェルボタンを使用してGemini CLIに接続できます
119 | - **リアルタイム通信** - WebSocket接続によるGeminiからのストリームレスポンス
120 | - **セッション管理** - 以前の会話を再開するか、新しいセッションを開始
121 | - **メッセージ履歴** - タイムスタンプとメタデータを含む完全な会話履歴
122 | - **マルチフォーマットサポート** - テキスト、コードブロック、ファイル参照
123 | - **画像添付** - チャットで画像をアップロードして質問可能
124 |
125 | #### ファイルエクスプローラーとエディター
126 | - **インタラクティブファイルツリー** - 展開/折りたたみナビゲーションでプロジェクト構造を閲覧
127 | - **ライブファイル編集** - インターフェース内で直接ファイルを読み取り、変更、保存
128 | - **シンタックスハイライト** - 複数のプログラミング言語をサポート
129 | - **ファイル操作** - ファイルとディレクトリの作成、名前変更、削除
130 |
131 | #### Gitエクスプローラー
132 | - **変更の可視化** - 現在の変更をリアルタイムで表示
133 | - **ステージングとコミット** - UIから直接Gitコミットを作成
134 | - **ブランチ管理** - ブランチの切り替えと管理
135 |
136 | #### セッション管理
137 | - **セッション永続性** - すべての会話を自動的に保存
138 | - **セッション整理** - プロジェクトとタイムスタンプでセッションをグループ化
139 | - **セッションアクション** - 会話履歴の名前変更、削除、エクスポート
140 | - **クロスデバイス同期** - どのデバイスからでもセッションにアクセス
141 |
142 | ### モバイルアプリ
143 | - **レスポンシブデザイン** - すべての画面サイズに最適化
144 | - **タッチフレンドリーインターフェース** - スワイプジェスチャーとタッチナビゲーション
145 | - **モバイルナビゲーション** - 簡単な親指ナビゲーションのための下部タブバー
146 | - **適応レイアウト** - 折りたたみ可能なサイドバーとスマートコンテンツの優先順位付け
147 | - **ホーム画面にショートカットを追加** - ホーム画面にショートカットを追加すると、アプリはPWAのように動作します
148 |
149 | ## アーキテクチャ
150 |
151 | ### システム概要
152 |
153 | ```
154 | ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
155 | │ フロントエンド │ │ バックエンド │ │ Gemini CLI │
156 | │ (React/Vite) │◄──►│ (Express/WS) │◄──►│ 統合 │
157 | └─────────────────┘ └─────────────────┘ └─────────────────┘
158 | ```
159 |
160 | ### バックエンド(Node.js + Express)
161 | - **Expressサーバー** - 静的ファイルサービングを備えたRESTful API(ポート: 4008)
162 | - **WebSocketサーバー** - チャットとプロジェクト更新のための通信
163 | - **Gemini CLI統合** - プロセスの生成と管理
164 | - **セッション管理** - JSONLパースと会話の永続化
165 | - **ファイルシステムAPI** - プロジェクト用のファイルブラウザーを公開
166 | - **認証システム** - セキュアなログインとセッション管理(SQLiteデータベース: geminicliui_auth.db)
167 |
168 | ### フロントエンド(React + Vite)
169 | - **React 18** - フックを使用したモダンなコンポーネントアーキテクチャ
170 | - **CodeMirror** - シンタックスハイライト付きの高度なコードエディター
171 | - **Tailwind CSS** - ユーティリティファーストのCSSフレームワーク
172 | - **レスポンシブデザイン** - モバイルファーストのアプローチ
173 |
174 | ## 設定詳細
175 |
176 | ### ポート設定
177 | - **APIサーバー**: 4008番ポート(デフォルト)
178 | - **フロントエンド開発サーバー**: 4009番ポート(デフォルト)
179 | - これらのポートは`.env`ファイルで変更可能です
180 |
181 | ### データベース設定
182 |
183 | #### 初期設定とテーブル構造
184 | - **データベースファイル**: `server/database/geminicliui_auth.db`
185 | - **データベースタイプ**: SQLite 3
186 | - **初期化**: サーバー起動時に自動的に作成・初期化されます
187 |
188 | #### ユーザーテーブル詳細
189 |
190 | **テーブル名**: `geminicliui_users`
191 |
192 | | カラム名 | データ型 | 制約 | 説明 |
193 | |---------|----------|------|------|
194 | | `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | ユーザーの一意識別子 |
195 | | `username` | TEXT | UNIQUE NOT NULL | ログインユーザー名(メールアドレス推奨) |
196 | | `password_hash` | TEXT | NOT NULL | bcryptでハッシュ化されたパスワード |
197 | | `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | アカウント作成日時 |
198 | | `last_login` | DATETIME | NULL | 最終ログイン日時 |
199 | | `is_active` | BOOLEAN | DEFAULT 1 | アカウントの有効/無効状態 |
200 |
201 | **インデックス**:
202 | - `idx_geminicliui_users_username`: ユーザー名での高速検索用
203 | - `idx_geminicliui_users_active`: アクティブユーザーのフィルタリング用
204 |
205 | #### 初回起動時の設定
206 | 1. サーバー初回起動時、データベースファイルが存在しない場合は自動作成
207 | 2. `server/database/init.sql` からテーブル構造を読み込み
208 | 3. 初回アクセス時にユーザー登録画面が表示される
209 | 4. 最初のユーザーが管理者として登録される
210 |
211 | #### セキュリティ機能
212 | - パスワードは bcrypt でハッシュ化して保存
213 | - JWT トークンベースの認証システム
214 | - セッション管理とタイムアウト機能
215 | - SQLインジェクション対策(プリペアドステートメント使用)
216 |
217 | ## トラブルシューティング
218 |
219 | ### よくある問題と解決策
220 |
221 | #### "Geminiプロジェクトが見つかりません"
222 | **問題**: UIにプロジェクトが表示されない、またはプロジェクトリストが空
223 | **解決策**:
224 | - Gemini CLIが適切にインストールされていることを確認
225 | - 少なくとも1つのプロジェクトディレクトリで`gemini`コマンドを実行して初期化
226 | - `~/.gemini/projects/`ディレクトリが存在し、適切な権限があることを確認
227 |
228 | #### ファイルエクスプローラーの問題
229 | **問題**: ファイルが読み込まれない、権限エラー、空のディレクトリ
230 | **解決策**:
231 | - プロジェクトディレクトリの権限を確認(ターミナルで`ls -la`)
232 | - プロジェクトパスが存在し、アクセス可能であることを確認
233 | - 詳細なエラーメッセージについてサーバーコンソールログを確認
234 | - プロジェクトスコープ外のシステムディレクトリにアクセスしようとしていないか確認
235 |
236 | #### モデル選択が機能しない
237 | **問題**: 選択したモデルが使用されない
238 | **解決策**:
239 | - 設定でモデルを選択後、必ず「設定を保存」をクリック
240 | - ブラウザのローカルストレージをクリアして再度設定
241 | - チャット画面でモデル名が正しく表示されているか確認
242 |
243 | ## ライセンス
244 |
245 | GNU General Public License v3.0 - 詳細は[LICENSE](LICENSE)ファイルを参照してください。
246 |
247 | このプロジェクトはオープンソースであり、GPL v3ライセンスの下で自由に使用、変更、配布できます。
248 |
249 | ### オリジナルプロジェクト
250 |
251 | このプロジェクトは[Claude Code UI](https://github.com/siteboon/claudecodeui) (GPL v3.0)をベースにカスタマイズしています。
252 |
253 | **主な変更点:**
254 | - Claude CLIからGemini CLIへの対応変更
255 | - 認証システムの追加(SQLiteベース)
256 | - Gemini専用のモデル選択機能
257 | - 日本語対応の強化
258 | - UIの調整とGeminiブランディング
259 |
260 | オリジナルのClaude Code UIプロジェクトに感謝します。
261 |
262 | ## 謝辞
263 |
264 | ### 使用技術
265 | - **[Gemini CLI](https://github.com/google-gemini/gemini-cli)** - Googleの公式CLI
266 | - **[React](https://react.dev/)** - ユーザーインターフェースライブラリ
267 | - **[Vite](https://vitejs.dev/)** - 高速ビルドツールと開発サーバー
268 | - **[Tailwind CSS](https://tailwindcss.com/)** - ユーティリティファーストのCSSフレームワーク
269 | - **[CodeMirror](https://codemirror.net/)** - 高度なコードエディター
270 |
271 | ## サポートとコミュニティ
272 |
273 | ### 最新情報を入手
274 | - このリポジトリに**スター**を付けてサポートを表明
275 | - アップデートと新リリースを**ウォッチ**
276 | - アナウンスのためにプロジェクトを**フォロー**
277 |
278 | ---
279 |
--------------------------------------------------------------------------------
/src/components/MicButton.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useRef } from 'react';
2 | import { Mic, Loader2, Brain } from 'lucide-react';
3 | import { transcribeWithWhisper } from '../utils/whisper';
4 |
5 | export function MicButton({ onTranscript, className = '' }) {
6 | const [state, setState] = useState('idle'); // idle, recording, transcribing, processing
7 | const [error, setError] = useState(null);
8 | const [isSupported, setIsSupported] = useState(true);
9 |
10 | const mediaRecorderRef = useRef(null);
11 | const streamRef = useRef(null);
12 | const chunksRef = useRef([]);
13 | const lastTapRef = useRef(0);
14 |
15 | // Check microphone support on mount
16 | useEffect(() => {
17 | const checkSupport = () => {
18 | if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
19 | setIsSupported(false);
20 | setError('Microphone not supported. Please use HTTPS or a modern browser.');
21 | return;
22 | }
23 |
24 | // Additional check for secure context
25 | if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
26 | setIsSupported(false);
27 | setError('Microphone requires HTTPS. Please use a secure connection.');
28 | return;
29 | }
30 |
31 | setIsSupported(true);
32 | setError(null);
33 | };
34 |
35 | checkSupport();
36 | }, []);
37 |
38 | // Start recording
39 | const startRecording = async () => {
40 | try {
41 | // console.log('Starting recording...');
42 | setError(null);
43 | chunksRef.current = [];
44 |
45 | // Check if getUserMedia is available
46 | if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
47 | throw new Error('Microphone access not available. Please use HTTPS or a supported browser.');
48 | }
49 |
50 | const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
51 | streamRef.current = stream;
52 |
53 | const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4';
54 | const recorder = new MediaRecorder(stream, { mimeType });
55 | mediaRecorderRef.current = recorder;
56 |
57 | recorder.ondataavailable = (e) => {
58 | if (e.data.size > 0) {
59 | chunksRef.current.push(e.data);
60 | }
61 | };
62 |
63 | recorder.onstop = async () => {
64 | // console.log('Recording stopped, creating blob...');
65 | const blob = new Blob(chunksRef.current, { type: mimeType });
66 |
67 | // Clean up stream
68 | if (streamRef.current) {
69 | streamRef.current.getTracks().forEach(track => track.stop());
70 | streamRef.current = null;
71 | }
72 |
73 | // Start transcribing
74 | setState('transcribing');
75 |
76 | // Check if we're in an enhancement mode
77 | const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
78 | const isEnhancementMode = whisperMode === 'prompt' || whisperMode === 'vibe' || whisperMode === 'instructions' || whisperMode === 'architect';
79 |
80 | // Set up a timer to switch to processing state for enhancement modes
81 | let processingTimer;
82 | if (isEnhancementMode) {
83 | processingTimer = setTimeout(() => {
84 | setState('processing');
85 | }, 2000); // Switch to processing after 2 seconds
86 | }
87 |
88 | try {
89 | const text = await transcribeWithWhisper(blob);
90 | if (text && onTranscript) {
91 | onTranscript(text);
92 | }
93 | } catch (err) {
94 | console.error('Transcription error:', err);
95 | setError(err.message);
96 | } finally {
97 | if (processingTimer) {
98 | clearTimeout(processingTimer);
99 | }
100 | setState('idle');
101 | }
102 | };
103 |
104 | recorder.start();
105 | setState('recording');
106 | // console.log('Recording started successfully');
107 | } catch (err) {
108 | console.error('Failed to start recording:', err);
109 |
110 | // Provide specific error messages based on error type
111 | let errorMessage = 'Microphone access failed';
112 |
113 | if (err.name === 'NotAllowedError') {
114 | errorMessage = 'Microphone access denied. Please allow microphone permissions.';
115 | } else if (err.name === 'NotFoundError') {
116 | errorMessage = 'No microphone found. Please check your audio devices.';
117 | } else if (err.name === 'NotSupportedError') {
118 | errorMessage = 'Microphone not supported by this browser.';
119 | } else if (err.name === 'NotReadableError') {
120 | errorMessage = 'Microphone is being used by another application.';
121 | } else if (err.message.includes('HTTPS')) {
122 | errorMessage = err.message;
123 | }
124 |
125 | setError(errorMessage);
126 | setState('idle');
127 | }
128 | };
129 |
130 | // Stop recording
131 | const stopRecording = () => {
132 | // console.log('Stopping recording...');
133 | if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
134 | mediaRecorderRef.current.stop();
135 | // Don't set state here - let the onstop handler do it
136 | } else {
137 | // If recorder isn't in recording state, force cleanup
138 | // console.log('Recorder not in recording state, forcing cleanup');
139 | if (streamRef.current) {
140 | streamRef.current.getTracks().forEach(track => track.stop());
141 | streamRef.current = null;
142 | }
143 | setState('idle');
144 | }
145 | };
146 |
147 | // Handle button click
148 | const handleClick = (e) => {
149 | // Prevent double firing on mobile
150 | if (e) {
151 | e.preventDefault();
152 | e.stopPropagation();
153 | }
154 |
155 | // Don't proceed if microphone is not supported
156 | if (!isSupported) {
157 | return;
158 | }
159 |
160 | // Debounce for mobile double-tap issue
161 | const now = Date.now();
162 | if (now - lastTapRef.current < 300) {
163 | // console.log('Ignoring rapid tap');
164 | return;
165 | }
166 | lastTapRef.current = now;
167 |
168 | // console.log('Button clicked, current state:', state);
169 |
170 | if (state === 'idle') {
171 | startRecording();
172 | } else if (state === 'recording') {
173 | stopRecording();
174 | }
175 | // Do nothing if transcribing or processing
176 | };
177 |
178 | // Clean up on unmount
179 | useEffect(() => {
180 | return () => {
181 | if (streamRef.current) {
182 | streamRef.current.getTracks().forEach(track => track.stop());
183 | }
184 | };
185 | }, []);
186 |
187 | // Button appearance based on state
188 | const getButtonAppearance = () => {
189 | if (!isSupported) {
190 | return {
191 | icon: ,
192 | className: 'bg-gray-400 cursor-not-allowed',
193 | disabled: true
194 | };
195 | }
196 |
197 | switch (state) {
198 | case 'recording':
199 | return {
200 | icon: ,
201 | className: 'bg-red-500 hover:bg-red-600 animate-pulse',
202 | disabled: false
203 | };
204 | case 'transcribing':
205 | return {
206 | icon: ,
207 | className: 'bg-blue-500 hover:bg-blue-600',
208 | disabled: true
209 | };
210 | case 'processing':
211 | return {
212 | icon: ,
213 | className: 'bg-purple-500 hover:bg-purple-600',
214 | disabled: true
215 | };
216 | default: // idle
217 | return {
218 | icon: ,
219 | className: 'bg-gray-700 hover:bg-gray-600',
220 | disabled: false
221 | };
222 | }
223 | };
224 |
225 | const { icon, className: buttonClass, disabled } = getButtonAppearance();
226 |
227 | return (
228 |
229 |
252 | {icon}
253 |
254 |
255 | {error && (
256 |
259 | {error}
260 |
261 | )}
262 |
263 | {state === 'recording' && (
264 |
265 | )}
266 |
267 | {state === 'processing' && (
268 |
269 | )}
270 |
271 | );
272 | }
--------------------------------------------------------------------------------
/server/routes/mcp.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import { promises as fs } from 'fs';
3 | import path from 'path';
4 | import os from 'os';
5 | import { fileURLToPath } from 'url';
6 | import { dirname } from 'path';
7 | import { spawn } from 'child_process';
8 |
9 | const router = express.Router();
10 | const __filename = fileURLToPath(import.meta.url);
11 | const __dirname = dirname(__filename);
12 |
13 | // Claude CLI command routes
14 |
15 | // GET /api/mcp/cli/list - List MCP servers using Claude CLI
16 | router.get('/cli/list', async (req, res) => {
17 | try {
18 | console.log('📋 Listing MCP servers using Claude CLI');
19 |
20 | const { spawn } = await import('child_process');
21 | const { promisify } = await import('util');
22 | const exec = promisify(spawn);
23 |
24 | const process = spawn('claude', ['mcp', 'list', '-s', 'user'], {
25 | stdio: ['pipe', 'pipe', 'pipe']
26 | });
27 |
28 | let stdout = '';
29 | let stderr = '';
30 |
31 | process.stdout.on('data', (data) => {
32 | stdout += data.toString();
33 | });
34 |
35 | process.stderr.on('data', (data) => {
36 | stderr += data.toString();
37 | });
38 |
39 | process.on('close', (code) => {
40 | if (code === 0) {
41 | res.json({ success: true, output: stdout, servers: parseClaudeListOutput(stdout) });
42 | } else {
43 | console.error('Claude CLI error:', stderr);
44 | res.status(500).json({ error: 'Claude CLI command failed', details: stderr });
45 | }
46 | });
47 |
48 | process.on('error', (error) => {
49 | console.error('Error running Claude CLI:', error);
50 | res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
51 | });
52 | } catch (error) {
53 | console.error('Error listing MCP servers via CLI:', error);
54 | res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
55 | }
56 | });
57 |
58 | // POST /api/mcp/cli/add - Add MCP server using Claude CLI
59 | router.post('/cli/add', async (req, res) => {
60 | try {
61 | const { name, type = 'stdio', command, args = [], url, headers = {}, env = {} } = req.body;
62 |
63 | console.log('➕ Adding MCP server using Claude CLI:', name);
64 |
65 | const { spawn } = await import('child_process');
66 |
67 | let cliArgs = ['mcp', 'add'];
68 |
69 | if (type === 'http') {
70 | cliArgs.push('--transport', 'http', name, '-s', 'user', url);
71 | // Add headers if provided
72 | Object.entries(headers).forEach(([key, value]) => {
73 | cliArgs.push('--header', `${key}: ${value}`);
74 | });
75 | } else if (type === 'sse') {
76 | cliArgs.push('--transport', 'sse', name, '-s', 'user', url);
77 | // Add headers if provided
78 | Object.entries(headers).forEach(([key, value]) => {
79 | cliArgs.push('--header', `${key}: ${value}`);
80 | });
81 | } else {
82 | // stdio (default): claude mcp add -s user [args...]
83 | cliArgs.push(name, '-s', 'user');
84 | // Add environment variables
85 | Object.entries(env).forEach(([key, value]) => {
86 | cliArgs.push('-e', `${key}=${value}`);
87 | });
88 | cliArgs.push(command);
89 | if (args && args.length > 0) {
90 | cliArgs.push(...args);
91 | }
92 | }
93 |
94 | console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
95 |
96 | const process = spawn('claude', cliArgs, {
97 | stdio: ['pipe', 'pipe', 'pipe']
98 | });
99 |
100 | let stdout = '';
101 | let stderr = '';
102 |
103 | process.stdout.on('data', (data) => {
104 | stdout += data.toString();
105 | });
106 |
107 | process.stderr.on('data', (data) => {
108 | stderr += data.toString();
109 | });
110 |
111 | process.on('close', (code) => {
112 | if (code === 0) {
113 | res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
114 | } else {
115 | console.error('Claude CLI error:', stderr);
116 | res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
117 | }
118 | });
119 |
120 | process.on('error', (error) => {
121 | console.error('Error running Claude CLI:', error);
122 | res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
123 | });
124 | } catch (error) {
125 | console.error('Error adding MCP server via CLI:', error);
126 | res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
127 | }
128 | });
129 |
130 | // DELETE /api/mcp/cli/remove/:name - Remove MCP server using Claude CLI
131 | router.delete('/cli/remove/:name', async (req, res) => {
132 | try {
133 | const { name } = req.params;
134 |
135 | console.log('🗑️ Removing MCP server using Claude CLI:', name);
136 |
137 | const { spawn } = await import('child_process');
138 |
139 | const process = spawn('claude', ['mcp', 'remove', '-s', 'user', name], {
140 | stdio: ['pipe', 'pipe', 'pipe']
141 | });
142 |
143 | let stdout = '';
144 | let stderr = '';
145 |
146 | process.stdout.on('data', (data) => {
147 | stdout += data.toString();
148 | });
149 |
150 | process.stderr.on('data', (data) => {
151 | stderr += data.toString();
152 | });
153 |
154 | process.on('close', (code) => {
155 | if (code === 0) {
156 | res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
157 | } else {
158 | console.error('Claude CLI error:', stderr);
159 | res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
160 | }
161 | });
162 |
163 | process.on('error', (error) => {
164 | console.error('Error running Claude CLI:', error);
165 | res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
166 | });
167 | } catch (error) {
168 | console.error('Error removing MCP server via CLI:', error);
169 | res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
170 | }
171 | });
172 |
173 | // GET /api/mcp/cli/get/:name - Get MCP server details using Claude CLI
174 | router.get('/cli/get/:name', async (req, res) => {
175 | try {
176 | const { name } = req.params;
177 |
178 | console.log('📄 Getting MCP server details using Claude CLI:', name);
179 |
180 | const { spawn } = await import('child_process');
181 |
182 | const process = spawn('claude', ['mcp', 'get', '-s', 'user', name], {
183 | stdio: ['pipe', 'pipe', 'pipe']
184 | });
185 |
186 | let stdout = '';
187 | let stderr = '';
188 |
189 | process.stdout.on('data', (data) => {
190 | stdout += data.toString();
191 | });
192 |
193 | process.stderr.on('data', (data) => {
194 | stderr += data.toString();
195 | });
196 |
197 | process.on('close', (code) => {
198 | if (code === 0) {
199 | res.json({ success: true, output: stdout, server: parseClaudeGetOutput(stdout) });
200 | } else {
201 | console.error('Claude CLI error:', stderr);
202 | res.status(404).json({ error: 'Claude CLI command failed', details: stderr });
203 | }
204 | });
205 |
206 | process.on('error', (error) => {
207 | console.error('Error running Claude CLI:', error);
208 | res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
209 | });
210 | } catch (error) {
211 | console.error('Error getting MCP server details via CLI:', error);
212 | res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
213 | }
214 | });
215 |
216 | // Helper functions to parse Claude CLI output
217 | function parseClaudeListOutput(output) {
218 | // Parse the output from 'claude mcp list' command
219 | // Format: "name: command/url" or "name: url (TYPE)"
220 | const servers = [];
221 | const lines = output.split('\n').filter(line => line.trim());
222 |
223 | for (const line of lines) {
224 | if (line.includes(':')) {
225 | const colonIndex = line.indexOf(':');
226 | const name = line.substring(0, colonIndex).trim();
227 | const rest = line.substring(colonIndex + 1).trim();
228 |
229 | let type = 'stdio'; // default type
230 |
231 | // Check if it has transport type in parentheses like "(SSE)" or "(HTTP)"
232 | const typeMatch = rest.match(/\((\w+)\)\s*$/);
233 | if (typeMatch) {
234 | type = typeMatch[1].toLowerCase();
235 | } else if (rest.startsWith('http://') || rest.startsWith('https://')) {
236 | // If it's a URL but no explicit type, assume HTTP
237 | type = 'http';
238 | }
239 |
240 | if (name) {
241 | servers.push({
242 | name,
243 | type,
244 | status: 'active'
245 | });
246 | }
247 | }
248 | }
249 |
250 | console.log('🔍 Parsed Claude CLI servers:', servers);
251 | return servers;
252 | }
253 |
254 | function parseClaudeGetOutput(output) {
255 | // Parse the output from 'claude mcp get ' command
256 | // This is a simple parser - might need adjustment based on actual output format
257 | try {
258 | // Try to extract JSON if present
259 | const jsonMatch = output.match(/\{[\s\S]*\}/);
260 | if (jsonMatch) {
261 | return JSON.parse(jsonMatch[0]);
262 | }
263 |
264 | // Otherwise, parse as text
265 | const server = { raw_output: output };
266 | const lines = output.split('\n');
267 |
268 | for (const line of lines) {
269 | if (line.includes('Name:')) {
270 | server.name = line.split(':')[1]?.trim();
271 | } else if (line.includes('Type:')) {
272 | server.type = line.split(':')[1]?.trim();
273 | } else if (line.includes('Command:')) {
274 | server.command = line.split(':')[1]?.trim();
275 | } else if (line.includes('URL:')) {
276 | server.url = line.split(':')[1]?.trim();
277 | }
278 | }
279 |
280 | return server;
281 | } catch (error) {
282 | return { raw_output: output, parse_error: error.message };
283 | }
284 | }
285 |
286 | export default router;
--------------------------------------------------------------------------------
/src/components/MessageRenderer.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import ReactMarkdown from 'react-markdown';
3 | import remarkGfm from 'remark-gfm';
4 | import remarkBreaks from 'remark-breaks';
5 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
6 | import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
7 | import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
8 |
9 | const CodeBlock = ({ language, value, inline, isDarkMode, className }) => {
10 | const [copied, setCopied] = useState(false);
11 |
12 | const handleCopy = () => {
13 | navigator.clipboard.writeText(value).then(() => {
14 | setCopied(true);
15 | setTimeout(() => setCopied(false), 2000);
16 | }).catch(() => {
17 | console.error('Failed to copy code');
18 | });
19 | };
20 |
21 | if (inline) {
22 | return (
23 |
24 | {value}
25 |
26 | );
27 | }
28 |
29 | return (
30 |
31 | {language && (
32 |
33 |
34 | {language}
35 |
36 |
40 | {copied ? (
41 | <>
42 |
43 |
44 |
45 | Copied!
46 | >
47 | ) : (
48 | <>
49 |
50 |
51 |
52 | Copy
53 | >
54 | )}
55 |
56 |
57 | )}
58 |
59 | {!language && (
60 |
64 | {copied ? (
65 | <>
66 |
67 |
68 |
69 | Copied!
70 | >
71 | ) : (
72 | <>
73 |
74 |
75 |
76 | Copy
77 | >
78 | )}
79 |
80 | )}
81 |
5}
92 | wrapLines={true}
93 | wrapLongLines={true}
94 | >
95 | {value}
96 |
97 |
98 |
99 | );
100 | };
101 |
102 | export const MessageRenderer = ({ content, isDarkMode = true }) => {
103 | // Filter out "Error: Loaded cached credentials" messages
104 | let filteredContent = content?.replace(/^Error:\s*Loaded cached credentials\.?\s*\n?/gim, '');
105 |
106 | // Process content to ensure code blocks stay together
107 | // This helps prevent markdown parser from splitting code blocks
108 | if (filteredContent) {
109 | // Fix code blocks that might be split
110 | filteredContent = filteredContent
111 | // Ensure triple backticks are properly formatted
112 | .replace(/```([\s\S]*?)```/g, (match, content) => {
113 | // Remove excessive line breaks within code blocks
114 | const cleanedContent = content.replace(/\n{3,}/g, '\n\n');
115 | return '```' + cleanedContent + '```';
116 | })
117 | // Normalize line breaks outside code blocks
118 | .replace(/\n{3,}/g, '\n\n') // Replace 3+ newlines with 2
119 | .replace(/\r\n/g, '\n') // Normalize Windows line endings
120 | .trim();
121 | }
122 |
123 | return (
124 |
125 |
{
129 | // For inline code, just render simple styled code
130 | if (inline) {
131 | return (
132 |
133 | {children}
134 |
135 | );
136 | }
137 | // Block code is handled by the pre component
138 | return <>{children}>;
139 | },
140 | pre: ({ node, children, ...props }) => {
141 | // Extract the code content from pre > code structure
142 | if (children && children.props) {
143 | const { className, children: codeContent } = children.props;
144 | const match = /language-(\w+)/.exec(className || '');
145 | const language = match ? match[1] : '';
146 | const value = String(codeContent).replace(/\n$/, '');
147 |
148 | return (
149 |
155 | );
156 | }
157 | return {children}
;
158 | },
159 | h1: ({ children }) => (
160 |
161 | {children}
162 |
163 | ),
164 | h2: ({ children }) => (
165 |
166 | {children}
167 |
168 | ),
169 | h3: ({ children }) => (
170 |
171 | {children}
172 |
173 | ),
174 | p: ({ children }) => {
175 | // Check if paragraph only contains whitespace or is empty
176 | const text = children?.toString().trim();
177 | if (!text || text === '') return null;
178 |
179 | return (
180 |
181 | {children}
182 |
183 | );
184 | },
185 | ul: ({ children }) => (
186 |
189 | ),
190 | ol: ({ children }) => (
191 |
192 | {children}
193 |
194 | ),
195 | li: ({ children }) => (
196 |
197 | {children}
198 |
199 | ),
200 | blockquote: ({ children }) => (
201 |
202 | {children}
203 |
204 | ),
205 | a: ({ href, children }) => (
206 |
212 | {children}
213 |
214 | ),
215 | table: ({ children }) => (
216 |
221 | ),
222 | thead: ({ children }) => (
223 |
224 | {children}
225 |
226 | ),
227 | tbody: ({ children }) => (
228 |
229 | {children}
230 |
231 | ),
232 | th: ({ children }) => (
233 |
234 | {children}
235 |
236 | ),
237 | td: ({ children }) => (
238 |
239 | {children}
240 |
241 | ),
242 | hr: () => (
243 |
244 | ),
245 | strong: ({ children }) => (
246 |
247 | {children}
248 |
249 | ),
250 | em: ({ children }) => (
251 |
252 | {children}
253 |
254 | ),
255 | }}
256 | >
257 | {filteredContent || ''}
258 |
259 |
260 | );
261 | };
262 |
263 | export default MessageRenderer;
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Gemini CLI UI
4 |
5 |
6 | A desktop and mobile UI for [Gemini CLI](https://github.com/google-gemini/gemini-cli), Google's official CLI for AI-assisted coding. You can use it locally or remotely to view your active projects and sessions in Gemini CLI and make changes to them the same way you would do it in Gemini CLI. This gives you a proper interface that works everywhere.
7 |
8 |
9 | ## Screenshots
10 |
11 |
12 |
13 |
14 |
15 | Chat View
16 |
17 |
18 | Main interface showing project overview and chat
19 |
20 |
21 | Setting
22 |
23 |
24 | Setting
25 |
26 |
27 |
28 |
29 |
30 |
31 | Chat View
32 |
33 |
34 | Gemini CLI UI Diagram
35 |
36 |
37 |
38 |
39 | ## Features
40 |
41 | - **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Gemini CLI from mobile
42 | - **Interactive Chat Interface** - Built-in chat interface for seamless communication with Gemini CLI
43 | - **Integrated Shell Terminal** - Direct access to Gemini CLI through built-in shell functionality
44 | - **File Explorer** - Interactive file tree with syntax highlighting and live editing
45 | - **Git Explorer** - View, stage and commit your changes. You can also switch branches
46 | - **Session Management** - Resume conversations, manage multiple sessions, and track history
47 | - **Model Selection** - Choose from multiple Gemini models including Gemini 2.5 Pro
48 | - **YOLO Mode** - Skip confirmation prompts for faster operations (use with caution)
49 |
50 | ## Quick Start
51 |
52 | ### Prerequisites
53 |
54 | - [Node.js](https://nodejs.org/) v20 or higher
55 | - [Gemini CLI](https://github.com/google-gemini/gemini-cli) installed and configured
56 |
57 | ### Installation
58 |
59 | 1. **Clone the repository:**
60 | ```bash
61 | git clone https://github.com/cruzyjapan/Gemini-CLI-UI.git
62 | cd Gemini-CLI-UI
63 | ```
64 |
65 | 2. **Install dependencies:**
66 | ```bash
67 | npm install
68 | ```
69 |
70 | 3. **Configure environment:**
71 | ```bash
72 | cp .env.example .env
73 | # Edit .env with your preferred settings
74 | ```
75 |
76 | **Note**: The `.env` file has been removed for security. Always copy `.env.example` to `.env` when using and modify settings as needed.
77 |
78 | 4. **Start the application:**
79 | ```bash
80 | # Development mode (with hot reload)
81 | npm run dev
82 | ```
83 | The application will start at the port you specified in your .env
84 |
85 | 5. **Open your browser:**
86 | - Development: `http://localhost:4009`
87 |
88 | ## Security & Tools Configuration
89 |
90 | **🔒 Important Notice**: All Gemini CLI tools are **disabled by default**. This prevents potentially harmful operations from running automatically.
91 |
92 | ### Enabling Tools
93 |
94 | To use Gemini CLI's full functionality, you'll need to manually enable tools:
95 |
96 | 1. **Open Tools Settings** - Click the gear icon in the sidebar
97 | 2. **Enable Selectively** - Turn on only the tools you need
98 | 3. **Apply Settings** - Your preferences are saved locally
99 |
100 | ### About YOLO Mode
101 |
102 | YOLO mode ("You Only Live Once") is equivalent to Gemini CLI's `--yolo` flag, skipping all confirmation prompts. This mode speeds up your work but should be used with caution.
103 |
104 | **Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later.
105 |
106 | ## Usage Guide
107 |
108 | ### Core Features
109 |
110 | #### Project Management
111 | The UI automatically discovers Gemini CLI projects from `~/.gemini/projects/` and provides:
112 | - **Visual Project Browser** - All available projects with metadata and session counts
113 | - **Project Actions** - Rename, delete, and organize projects
114 | - **Smart Navigation** - Quick access to recent projects and sessions
115 |
116 | #### Chat Interface
117 | - **Use responsive chat or Gemini CLI** - You can either use the adapted chat interface or use the shell button to connect to Gemini CLI
118 | - **Real-time Communication** - Stream responses from Gemini with WebSocket connection
119 | - **Session Management** - Resume previous conversations or start fresh sessions
120 | - **Message History** - Complete conversation history with timestamps and metadata
121 | - **Multi-format Support** - Text, code blocks, and file references
122 | - **Image Upload** - Upload and ask questions about images in chat
123 |
124 | #### File Explorer & Editor
125 | - **Interactive File Tree** - Browse project structure with expand/collapse navigation
126 | - **Live File Editing** - Read, modify, and save files directly in the interface
127 | - **Syntax Highlighting** - Support for multiple programming languages
128 | - **File Operations** - Create, rename, delete files and directories
129 |
130 | #### Git Explorer
131 | - **Visualize Changes** - See current changes in real-time
132 | - **Stage and Commit** - Create Git commits directly from the UI
133 | - **Branch Management** - Switch and manage branches
134 |
135 | #### Session Management
136 | - **Session Persistence** - All conversations automatically saved
137 | - **Session Organization** - Group sessions by project and timestamp
138 | - **Session Actions** - Rename, delete, and export conversation history
139 | - **Cross-device Sync** - Access sessions from any device
140 |
141 | ### Mobile App
142 | - **Responsive Design** - Optimized for all screen sizes
143 | - **Touch-friendly Interface** - Swipe gestures and touch navigation
144 | - **Mobile Navigation** - Bottom tab bar for easy thumb navigation
145 | - **Adaptive Layout** - Collapsible sidebar and smart content prioritization
146 | - **Add to Home Screen** - Add a shortcut to your home screen and the app will behave like a PWA
147 |
148 | ## Architecture
149 |
150 | ### System Overview
151 |
152 | ```
153 | ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
154 | │ Frontend │ │ Backend │ │ Gemini CLI │
155 | │ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
156 | └─────────────────┘ └─────────────────┘ └─────────────────┘
157 | ```
158 |
159 | ### Backend (Node.js + Express)
160 | - **Express Server** - RESTful API with static file serving (Port: 4008)
161 | - **WebSocket Server** - Communication for chats and project refresh
162 | - **Gemini CLI Integration** - Process spawning and management
163 | - **Session Management** - JSONL parsing and conversation persistence
164 | - **File System API** - Exposing file browser for projects
165 | - **Authentication System** - Secure login and session management (SQLite database: geminicliui_auth.db)
166 |
167 | ### Frontend (React + Vite)
168 | - **React 18** - Modern component architecture with hooks
169 | - **CodeMirror** - Advanced code editor with syntax highlighting
170 | - **Tailwind CSS** - Utility-first CSS framework
171 | - **Responsive Design** - Mobile-first approach
172 |
173 | ## Configuration Details
174 |
175 | ### Port Settings
176 | - **API Server**: Port 4008 (default)
177 | - **Frontend Dev Server**: Port 4009 (default)
178 | - These ports can be changed in the `.env` file
179 |
180 | ### Database Configuration
181 |
182 | #### Initial Setup and Table Structure
183 | - **Database File**: `server/database/geminicliui_auth.db`
184 | - **Database Type**: SQLite 3
185 | - **Initialization**: Automatically created and initialized on server startup
186 |
187 | #### User Table Details
188 |
189 | **Table Name**: `geminicliui_users`
190 |
191 | | Column | Data Type | Constraints | Description |
192 | |--------|-----------|-------------|-------------|
193 | | `id` | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique user identifier |
194 | | `username` | TEXT | UNIQUE NOT NULL | Login username (email recommended) |
195 | | `password_hash` | TEXT | NOT NULL | bcrypt hashed password |
196 | | `created_at` | DATETIME | DEFAULT CURRENT_TIMESTAMP | Account creation timestamp |
197 | | `last_login` | DATETIME | NULL | Last login timestamp |
198 | | `is_active` | BOOLEAN | DEFAULT 1 | Account active/inactive status |
199 |
200 | **Indexes**:
201 | - `idx_geminicliui_users_username`: For fast username lookups
202 | - `idx_geminicliui_users_active`: For filtering active users
203 |
204 | #### First Run Setup
205 | 1. On first server startup, database file is automatically created if it doesn't exist
206 | 2. Table structure is loaded from `server/database/init.sql`
207 | 3. First access displays user registration screen
208 | 4. First user is registered as administrator
209 |
210 | #### Security Features
211 | - Passwords are hashed with bcrypt before storage
212 | - JWT token-based authentication system
213 | - Session management with timeout functionality
214 | - SQL injection protection (prepared statements used)
215 |
216 | ## Troubleshooting
217 |
218 | ### Common Issues & Solutions
219 |
220 | #### "No Gemini projects found"
221 | **Problem**: The UI shows no projects or empty project list
222 | **Solutions**:
223 | - Ensure Gemini CLI is properly installed
224 | - Run `gemini` command in at least one project directory to initialize
225 | - Verify `~/.gemini/projects/` directory exists and has proper permissions
226 |
227 | #### File Explorer Issues
228 | **Problem**: Files not loading, permission errors, empty directories
229 | **Solutions**:
230 | - Check project directory permissions (`ls -la` in terminal)
231 | - Verify the project path exists and is accessible
232 | - Review server console logs for detailed error messages
233 | - Ensure you're not trying to access system directories outside project scope
234 |
235 | #### Model Selection Not Working
236 | **Problem**: Selected model is not being used
237 | **Solutions**:
238 | - After selecting a model in settings, make sure to click "Save Settings"
239 | - Clear browser local storage and reconfigure
240 | - Verify the model name is displayed correctly in the chat interface
241 |
242 | ## License
243 |
244 | GNU General Public License v3.0 - see [LICENSE](LICENSE) file for details.
245 |
246 | This project is open source and free to use, modify, and distribute under the GPL v3 license.
247 |
248 | ### Original Project
249 |
250 | This project is based on [Claude Code UI](https://github.com/siteboon/claudecodeui) (GPL v3.0) with customizations.
251 |
252 | **Major Changes:**
253 | - Adapted from Claude CLI to Gemini CLI
254 | - Added authentication system (SQLite-based)
255 | - Gemini-specific model selection feature
256 | - Enhanced Japanese language support
257 | - UI adjustments and Gemini branding
258 |
259 | Thanks to the original Claude Code UI project.
260 |
261 | ## Acknowledgments
262 |
263 | ### Built With
264 | - **[Gemini CLI](https://github.com/google-gemini/gemini-cli)** - Google's official CLI
265 | - **[React](https://react.dev/)** - User interface library
266 | - **[Vite](https://vitejs.dev/)** - Fast build tool and dev server
267 | - **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework
268 | - **[CodeMirror](https://codemirror.net/)** - Advanced code editor
269 |
270 | ## Support & Community
271 |
272 | ### Stay Updated
273 | - **Star** this repository to show support
274 | - **Watch** for updates and new releases
275 | - **Follow** the project for announcements
276 |
277 | ---
--------------------------------------------------------------------------------
/src/components/EnhancedMessageRenderer.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import ReactMarkdown from 'react-markdown';
3 | import remarkGfm from 'remark-gfm';
4 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
5 | import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
6 | import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
7 |
8 | const CodeBlock = ({ language, value, isDarkMode }) => {
9 | const [copied, setCopied] = useState(false);
10 |
11 | const handleCopy = () => {
12 | navigator.clipboard.writeText(value).then(() => {
13 | setCopied(true);
14 | setTimeout(() => setCopied(false), 2000);
15 | }).catch(() => {
16 | console.error('Failed to copy code');
17 | });
18 | };
19 |
20 | // Detect language from content if not specified
21 | let detectedLanguage = language;
22 | if (!detectedLanguage) {
23 | // More comprehensive detection based on content patterns
24 | const lowerValue = value.toLowerCase();
25 | if (lowerValue.includes(''))) {
27 | detectedLanguage = 'html';
28 | } else if (lowerValue.includes('