├── .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 | Gemini 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 |
11 |
12 | 13 |
14 |
15 |

Gemini Code UI

16 |
17 |
18 |
19 |
20 |
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 | 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 | 23 |
24 | 25 |
26 | {file.name} { 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 | 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 | 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 | 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 |
40 |
41 | 42 |
43 |
44 |

Welcome Back

45 |

46 | Sign in to your Gemini CLI UI account 47 |

48 |
49 | 50 | {/* Login Form */} 51 |
52 |
53 | 56 | setUsername(e.target.value)} 61 | className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 62 | placeholder="Enter your username" 63 | required 64 | disabled={isLoading} 65 | /> 66 |
67 | 68 |
69 | 72 | setPassword(e.target.value)} 77 | className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 78 | placeholder="Enter your password" 79 | required 80 | disabled={isLoading} 81 | /> 82 |
83 | 84 | {error && ( 85 |
86 |

{error}

87 |
88 | )} 89 | 90 | 97 |
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 | 9 | 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 |
61 |
62 | 65 | setUsername(e.target.value)} 70 | className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 71 | placeholder="Enter your username" 72 | required 73 | disabled={isLoading} 74 | /> 75 |
76 | 77 |
78 | 81 | setPassword(e.target.value)} 86 | className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 87 | placeholder="Enter your password" 88 | required 89 | disabled={isLoading} 90 | /> 91 |
92 | 93 |
94 | 97 | setConfirmPassword(e.target.value)} 102 | className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" 103 | placeholder="Confirm your password" 104 | required 105 | disabled={isLoading} 106 | /> 107 |
108 | 109 | {error && ( 110 |
111 |

{error}

112 |
113 | )} 114 | 115 | 122 |
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 | Gemini CLI UI 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 | 20 | 26 | 27 |
15 |

Chat View

16 | Desktop Interface 17 |
18 | Main interface showing project overview and chat 19 |
21 |

Setting

22 | Mobile Interface 23 |
24 | Setting 25 |
28 | 29 | 30 | 36 |
31 |

Chat View

32 | Desktop Interface 33 |
34 | Gemini CLI UI Diagram 35 |
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 | 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 | 56 |
57 | )} 58 |
59 | {!language && ( 60 | 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 |
    187 | {children} 188 |
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 |
    217 | 218 | {children} 219 |
    220 |
    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 | Gemini CLI UI 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 | 20 | 26 | 27 |
    15 |

    Chat View

    16 | Desktop Interface 17 |
    18 | Main interface showing project overview and chat 19 |
    21 |

    Setting

    22 | Mobile Interface 23 |
    24 | Setting 25 |
    28 | 29 | 30 | 36 |
    31 |

    Chat View

    32 | Desktop Interface 33 |
    34 | Gemini CLI UI Diagram 35 |
    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('') || value.includes('console.')) { 37 | detectedLanguage = 'javascript'; 38 | } else if (value.includes('def ') || value.includes('import ') || value.includes('from ') || 39 | value.includes('class ') || value.includes('print(')) { 40 | detectedLanguage = 'python'; 41 | } else if (value.includes(' 60 |
    61 | 62 | {detectedLanguage || 'plaintext'} 63 | 64 | 84 |
    85 |
    86 | 5} 98 | wrapLines={true} 99 | wrapLongLines={true} 100 | lineNumberStyle={{ 101 | minWidth: '2.5em', 102 | paddingRight: '1em', 103 | color: isDarkMode ? '#4a5568' : '#a0aec0', 104 | fontSize: '0.75rem' 105 | }} 106 | > 107 | {value} 108 | 109 |
    110 |
    111 | ); 112 | }; 113 | 114 | export const EnhancedMessageRenderer = ({ content, isDarkMode = true }) => { 115 | // Filter out error messages 116 | let processedContent = content?.replace(/^Error:\s*Loaded cached credentials\.?\s*\n?/gim, ''); 117 | 118 | // Pre-process to ensure code blocks are properly formatted 119 | if (processedContent) { 120 | // Handle fenced code blocks to ensure they stay as single blocks 121 | const codeBlockRegex = /```(\w*)\n?([\s\S]*?)```/g; 122 | const codeBlocks = []; 123 | let tempContent = processedContent; 124 | 125 | // Extract code blocks and replace with placeholders 126 | tempContent = tempContent.replace(codeBlockRegex, (match, lang, code) => { 127 | const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`; 128 | codeBlocks.push({ lang: lang || '', code: code.trim() }); 129 | return placeholder; 130 | }); 131 | 132 | // Clean up spacing around placeholders 133 | tempContent = tempContent 134 | .replace(/\n{3,}/g, '\n\n') 135 | .replace(/(__CODE_BLOCK_\d+__)\n{2,}/g, '$1\n') 136 | .replace(/\n{2,}(__CODE_BLOCK_\d+__)/g, '\n$1') 137 | .trim(); 138 | 139 | // Restore code blocks with proper formatting 140 | codeBlocks.forEach((block, index) => { 141 | const placeholder = `__CODE_BLOCK_${index}__`; 142 | tempContent = tempContent.replace( 143 | placeholder, 144 | `\n\n\`\`\`${block.lang}\n${block.code}\n\`\`\`\n\n` 145 | ); 146 | }); 147 | 148 | processedContent = tempContent.trim(); 149 | } 150 | 151 | return ( 152 |
    153 | { 157 | if (inline) { 158 | return ( 159 | 160 | {children} 161 | 162 | ); 163 | } 164 | 165 | const match = /language-(\w+)/.exec(className || ''); 166 | const language = match ? match[1] : ''; 167 | const value = String(children).replace(/\n$/, ''); 168 | 169 | return ( 170 | 175 | ); 176 | }, 177 | pre: ({ children }) => { 178 | // Pass through - code blocks are handled by code component 179 | return <>{children}; 180 | }, 181 | h1: ({ children }) => ( 182 |

    183 | {children} 184 |

    185 | ), 186 | h2: ({ children }) => ( 187 |

    188 | {children} 189 |

    190 | ), 191 | h3: ({ children }) => ( 192 |

    193 | {children} 194 |

    195 | ), 196 | p: ({ children }) => { 197 | const text = children?.toString().trim(); 198 | if (!text || text === '') return null; 199 | 200 | return ( 201 |

    202 | {children} 203 |

    204 | ); 205 | }, 206 | ul: ({ children }) => ( 207 |
      208 | {children} 209 |
    210 | ), 211 | ol: ({ children }) => ( 212 |
      213 | {children} 214 |
    215 | ), 216 | li: ({ children }) => ( 217 |
  • 218 | {children} 219 |
  • 220 | ), 221 | blockquote: ({ children }) => ( 222 |
    223 | {children} 224 |
    225 | ), 226 | a: ({ href, children }) => ( 227 | 233 | {children} 234 | 235 | ), 236 | table: ({ children }) => ( 237 |
    238 | 239 | {children} 240 |
    241 |
    242 | ), 243 | thead: ({ children }) => ( 244 | 245 | {children} 246 | 247 | ), 248 | tbody: ({ children }) => ( 249 | 250 | {children} 251 | 252 | ), 253 | th: ({ children }) => ( 254 | 255 | {children} 256 | 257 | ), 258 | td: ({ children }) => ( 259 | 260 | {children} 261 | 262 | ), 263 | hr: () => ( 264 |
    265 | ), 266 | strong: ({ children }) => ( 267 | 268 | {children} 269 | 270 | ), 271 | em: ({ children }) => ( 272 | 273 | {children} 274 | 275 | ), 276 | }} 277 | > 278 | {processedContent || ''} 279 |
    280 |
    281 | ); 282 | }; 283 | 284 | export default EnhancedMessageRenderer; -------------------------------------------------------------------------------- /src/components/QuickSettingsPanel.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | ChevronLeft, 4 | ChevronRight, 5 | Maximize2, 6 | Eye, 7 | Settings2, 8 | Moon, 9 | Sun, 10 | ArrowDown, 11 | Mic, 12 | Brain, 13 | Sparkles, 14 | FileText 15 | } from 'lucide-react'; 16 | import DarkModeToggle from './DarkModeToggle'; 17 | import { useTheme } from '../contexts/ThemeContext'; 18 | 19 | const QuickSettingsPanel = ({ 20 | isOpen, 21 | onToggle, 22 | autoExpandTools, 23 | onAutoExpandChange, 24 | showRawParameters, 25 | onShowRawParametersChange, 26 | autoScrollToBottom, 27 | onAutoScrollChange, 28 | isMobile 29 | }) => { 30 | const [localIsOpen, setLocalIsOpen] = useState(isOpen); 31 | const [whisperMode, setWhisperMode] = useState(() => { 32 | return localStorage.getItem('whisperMode') || 'default'; 33 | }); 34 | const { isDarkMode } = useTheme(); 35 | 36 | useEffect(() => { 37 | setLocalIsOpen(isOpen); 38 | }, [isOpen]); 39 | 40 | const handleToggle = () => { 41 | const newState = !localIsOpen; 42 | setLocalIsOpen(newState); 43 | onToggle(newState); 44 | }; 45 | 46 | return ( 47 | <> 48 | {/* Pull Tab */} 49 |
    54 | 65 |
    66 | 67 | {/* Panel */} 68 |
    73 |
    74 | {/* Header */} 75 |
    76 |

    77 | 78 | Quick Settings 79 |

    80 |
    81 | 82 | {/* Settings Content */} 83 |
    84 | {/* Appearance Settings */} 85 |
    86 |

    Appearance

    87 | 88 |
    89 | 90 | {isDarkMode ? : } 91 | Dark Mode 92 | 93 | 94 |
    95 |
    96 | 97 | {/* Tool Display Settings */} 98 |
    99 |

    Tool Display

    100 | 101 | 113 | 114 | 126 |
    127 | {/* View Options */} 128 |
    129 |

    View Options

    130 | 131 | 143 |
    144 | 145 | {/* Whisper Dictation Settings - HIDDEN */} 146 |
    147 |

    Whisper Dictation

    148 | 149 |
    150 | 173 | 174 | 197 | 198 | 221 |
    222 |
    223 |
    224 |
    225 |
    226 | 227 | {/* Backdrop */} 228 | {localIsOpen && ( 229 |
    233 | )} 234 | 235 | ); 236 | }; 237 | 238 | export default QuickSettingsPanel; -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* Global spinner animation - defined early to ensure it loads */ 6 | @keyframes spin { 7 | 0% { 8 | transform: rotate(0deg); 9 | } 10 | 100% { 11 | transform: rotate(360deg); 12 | } 13 | } 14 | 15 | @-webkit-keyframes spin { 16 | 0% { 17 | -webkit-transform: rotate(0deg); 18 | } 19 | 100% { 20 | -webkit-transform: rotate(360deg); 21 | } 22 | } 23 | 24 | /* Gemini pulse animation */ 25 | @keyframes gemini-pulse { 26 | 0%, 100% { 27 | opacity: 1; 28 | } 29 | 50% { 30 | opacity: .5; 31 | } 32 | } 33 | 34 | /* Gemini glow animation */ 35 | @keyframes gemini-glow { 36 | 0%, 100% { 37 | box-shadow: 0 0 20px rgba(6, 182, 212, 0.5), 0 0 40px rgba(6, 182, 212, 0.3); 38 | } 39 | 50% { 40 | box-shadow: 0 0 30px rgba(6, 182, 212, 0.8), 0 0 60px rgba(6, 182, 212, 0.4); 41 | } 42 | } 43 | 44 | /* Chat input alignment fix */ 45 | .chat-input-expanded { 46 | position: relative; 47 | } 48 | 49 | .chat-input-expanded textarea { 50 | resize: none; 51 | overflow-y: auto; 52 | } 53 | 54 | /* Prevent layout shift when textarea expands */ 55 | .chat-input-container { 56 | position: relative; 57 | display: flex; 58 | align-items: flex-end; 59 | } 60 | 61 | /* Fix send button position when textarea expands */ 62 | .chat-input-container button[type="submit"] { 63 | position: absolute !important; 64 | right: 0.5rem !important; 65 | top: 50% !important; 66 | transform: translateY(-50%) !important; 67 | z-index: 10; 68 | } 69 | 70 | /* Ensure proper padding for textarea to not overlap with button */ 71 | .chat-input-container textarea { 72 | padding-right: 4rem !important; /* Make room for send button */ 73 | } 74 | 75 | @layer base { 76 | :root { 77 | --background: 0 0% 100%; 78 | --foreground: 222.2 84% 4.9%; 79 | --card: 0 0% 100%; 80 | --card-foreground: 222.2 84% 4.9%; 81 | --popover: 0 0% 100%; 82 | --popover-foreground: 222.2 84% 4.9%; 83 | --primary: 192 91% 36%; 84 | --primary-foreground: 210 40% 98%; 85 | --secondary: 210 40% 96.1%; 86 | --secondary-foreground: 222.2 47.4% 11.2%; 87 | --muted: 210 40% 96.1%; 88 | --muted-foreground: 215.4 16.3% 46.9%; 89 | --accent: 210 40% 96.1%; 90 | --accent-foreground: 222.2 47.4% 11.2%; 91 | --destructive: 0 84.2% 60.2%; 92 | --destructive-foreground: 210 40% 98%; 93 | --border: 214.3 31.8% 91.4%; 94 | --input: 214.3 31.8% 91.4%; 95 | --ring: 192 91% 36%; 96 | --radius: 0.5rem; 97 | } 98 | 99 | .dark { 100 | --background: 222.2 84% 4.9%; 101 | --foreground: 210 40% 98%; 102 | --card: 217.2 91.2% 8%; 103 | --card-foreground: 210 40% 98%; 104 | --popover: 217.2 91.2% 8%; 105 | --popover-foreground: 210 40% 98%; 106 | --primary: 192 91% 46%; 107 | --primary-foreground: 222.2 47.4% 11.2%; 108 | --secondary: 217.2 32.6% 17.5%; 109 | --secondary-foreground: 210 40% 98%; 110 | --muted: 217.2 32.6% 17.5%; 111 | --muted-foreground: 215 20.2% 65.1%; 112 | --accent: 217.2 32.6% 17.5%; 113 | --accent-foreground: 210 40% 98%; 114 | --destructive: 0 62.8% 30.6%; 115 | --destructive-foreground: 210 40% 98%; 116 | --border: 217.2 32.6% 17.5%; 117 | --input: 217.2 32.6% 17.5%; 118 | --ring: 192 91% 46%; 119 | } 120 | } 121 | 122 | @layer base { 123 | body { 124 | @apply bg-background text-foreground; 125 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 126 | margin: 0; 127 | padding: 0; 128 | /* Prevent layout shift during font loading */ 129 | font-display: swap; 130 | /* Ensure consistent rendering */ 131 | -webkit-font-smoothing: antialiased; 132 | -moz-osx-font-smoothing: grayscale; 133 | } 134 | 135 | html, body, #root { 136 | height: 100%; 137 | margin: 0; 138 | padding: 0; 139 | /* Prevent layout jumps */ 140 | overflow-x: hidden; 141 | position: relative; 142 | } 143 | 144 | /* Global transition defaults */ 145 | button, 146 | a, 147 | input, 148 | textarea, 149 | select, 150 | [role="button"], 151 | .transition-all { 152 | transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); 153 | } 154 | 155 | /* Color transitions for theme switching - exclude interactive elements */ 156 | body, div, section, article, aside, header, footer, nav, main, 157 | h1, h2, h3, h4, h5, h6, p, span, blockquote, 158 | ul, ol, li, dl, dt, dd, 159 | table, thead, tbody, tfoot, tr, td, th, 160 | form, fieldset, legend, label { 161 | transition: background-color 200ms ease-in-out, 162 | border-color 200ms ease-in-out, 163 | color 200ms ease-in-out; 164 | } 165 | 166 | /* Transform transitions */ 167 | .transition-transform { 168 | transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); 169 | } 170 | 171 | /* Opacity transitions */ 172 | .transition-opacity { 173 | transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1); 174 | } 175 | 176 | /* Scale transitions */ 177 | .transition-scale { 178 | transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); 179 | } 180 | 181 | /* Base button styles with hover state */ 182 | button:not(:disabled):hover { 183 | transform: translateY(-1px); 184 | } 185 | 186 | button:not(:disabled):active { 187 | transform: translateY(0); 188 | } 189 | 190 | /* Smooth focus states */ 191 | *:focus { 192 | outline: none; 193 | } 194 | 195 | *:focus-visible { 196 | outline: 2px solid hsl(var(--ring)); 197 | outline-offset: 2px; 198 | } 199 | 200 | /* Remove spinner in webkit browsers */ 201 | input[type="number"]::-webkit-inner-spin-button, 202 | input[type="number"]::-webkit-outer-spin-button { 203 | -webkit-appearance: none; 204 | margin: 0; 205 | } 206 | 207 | /* Remove spinner in Firefox */ 208 | input[type="number"] { 209 | -moz-appearance: textfield; 210 | } 211 | 212 | /* Prevent overscroll on iOS */ 213 | body { 214 | overscroll-behavior-y: none; 215 | } 216 | 217 | /* xterm.js styles */ 218 | .xterm { 219 | height: 100%; 220 | padding: 8px; 221 | } 222 | 223 | .xterm-viewport { 224 | overflow-y: auto !important; 225 | } 226 | 227 | /* Custom animation classes */ 228 | @keyframes slideInRight { 229 | from { 230 | transform: translateX(100%); 231 | opacity: 0; 232 | } 233 | to { 234 | transform: translateX(0); 235 | opacity: 1; 236 | } 237 | } 238 | 239 | @keyframes slideOutRight { 240 | from { 241 | transform: translateX(0); 242 | opacity: 1; 243 | } 244 | to { 245 | transform: translateX(100%); 246 | opacity: 0; 247 | } 248 | } 249 | 250 | .animate-slideInRight { 251 | animation: slideInRight 0.3s ease-out; 252 | } 253 | 254 | .animate-slideOutRight { 255 | animation: slideOutRight 0.3s ease-out; 256 | } 257 | 258 | /* Custom scrollbar styles */ 259 | .scrollbar-thin { 260 | scrollbar-width: thin; 261 | scrollbar-color: hsl(var(--muted-foreground)) transparent; 262 | } 263 | 264 | .scrollbar-thin::-webkit-scrollbar { 265 | width: 6px; 266 | height: 6px; 267 | } 268 | 269 | .scrollbar-thin::-webkit-scrollbar-track { 270 | background: transparent; 271 | } 272 | 273 | .scrollbar-thin::-webkit-scrollbar-thumb { 274 | background-color: hsl(var(--muted-foreground)); 275 | border-radius: 3px; 276 | } 277 | 278 | .scrollbar-thin::-webkit-scrollbar-thumb:hover { 279 | background-color: hsl(var(--muted-foreground) / 0.8); 280 | } 281 | 282 | /* Dark mode scrollbar styles */ 283 | .dark .scrollbar-thin { 284 | scrollbar-color: rgba(156, 163, 175, 0.5) transparent; 285 | } 286 | 287 | .dark .scrollbar-thin::-webkit-scrollbar-track { 288 | background: rgba(31, 41, 55, 0.3); 289 | } 290 | 291 | .dark .scrollbar-thin::-webkit-scrollbar-thumb { 292 | background-color: rgba(156, 163, 175, 0.5); 293 | border-radius: 3px; 294 | } 295 | 296 | .dark .scrollbar-thin::-webkit-scrollbar-thumb:hover { 297 | background-color: rgba(156, 163, 175, 0.7); 298 | } 299 | 300 | /* Global scrollbar styles for main content areas */ 301 | .dark::-webkit-scrollbar { 302 | width: 8px; 303 | height: 8px; 304 | } 305 | 306 | .dark::-webkit-scrollbar-track { 307 | background: rgba(31, 41, 55, 0.5); 308 | } 309 | 310 | .dark::-webkit-scrollbar-thumb { 311 | background-color: rgba(107, 114, 128, 0.5); 312 | border-radius: 4px; 313 | } 314 | 315 | .dark::-webkit-scrollbar-thumb:hover { 316 | background-color: rgba(107, 114, 128, 0.7); 317 | } 318 | 319 | /* Prevent text selection during drag operations */ 320 | .dragging * { 321 | user-select: none !important; 322 | } 323 | 324 | /* Prevent layout shift on content changes */ 325 | * { 326 | box-sizing: border-box; 327 | } 328 | 329 | /* Stabilize flex containers */ 330 | .flex { 331 | flex-shrink: 0; 332 | } 333 | 334 | /* Prevent collapsing margins */ 335 | .chat-message { 336 | display: block; 337 | overflow: hidden; 338 | } 339 | 340 | /* Ensure consistent line heights */ 341 | p, div, span { 342 | line-height: 1.5; 343 | } 344 | 345 | /* Prevent layout shift from images */ 346 | img { 347 | max-width: 100%; 348 | height: auto; 349 | display: block; 350 | } 351 | 352 | /* Mobile-specific styles */ 353 | @media (max-width: 768px) { 354 | /* Prevent pull-to-refresh on mobile */ 355 | body { 356 | overscroll-behavior-y: contain; 357 | } 358 | 359 | /* Ensure proper viewport height on mobile browsers */ 360 | #root { 361 | height: 100vh; 362 | height: 100dvh; /* Dynamic viewport height */ 363 | } 364 | } 365 | 366 | /* Advanced selectors for preventing transitions on specific elements */ 367 | /* Prevent transitions on code blocks during syntax highlighting updates */ 368 | pre code, 369 | pre code *, 370 | .cm-editor, 371 | .cm-editor * { 372 | transition: none !important; 373 | } 374 | 375 | /* File explorer hover states should not transition colors */ 376 | .file-tree-item:hover { 377 | transition: background-color 100ms ease-in-out !important; 378 | } 379 | 380 | /* Gemini-specific styles */ 381 | .gemini-gradient { 382 | background: linear-gradient(135deg, #06b6d4 0%, #0891b2 50%, #0e7490 100%); 383 | } 384 | 385 | .gemini-gradient-text { 386 | background: linear-gradient(135deg, #06b6d4 0%, #0891b2 50%, #0e7490 100%); 387 | -webkit-background-clip: text; 388 | -webkit-text-fill-color: transparent; 389 | background-clip: text; 390 | } 391 | 392 | .gemini-shadow { 393 | box-shadow: 0 4px 6px -1px rgba(6, 182, 212, 0.1), 0 2px 4px -1px rgba(6, 182, 212, 0.06); 394 | } 395 | 396 | .gemini-shadow-lg { 397 | box-shadow: 0 10px 15px -3px rgba(6, 182, 212, 0.1), 0 4px 6px -2px rgba(6, 182, 212, 0.05); 398 | } 399 | 400 | .gemini-border { 401 | border: 1px solid rgba(6, 182, 212, 0.2); 402 | } 403 | 404 | .gemini-hover:hover { 405 | background-color: rgba(6, 182, 212, 0.05); 406 | border-color: rgba(6, 182, 212, 0.3); 407 | } 408 | 409 | .dark .gemini-hover:hover { 410 | background-color: rgba(6, 182, 212, 0.1); 411 | border-color: rgba(6, 182, 212, 0.4); 412 | } 413 | 414 | /* Enhanced button styles */ 415 | .gemini-button { 416 | @apply relative overflow-hidden; 417 | background: linear-gradient(135deg, #0891b2 0%, #0e7490 100%); 418 | transition: all 0.3s ease; 419 | } 420 | 421 | .gemini-button:hover { 422 | transform: translateY(-2px); 423 | box-shadow: 0 5px 15px rgba(6, 182, 212, 0.3); 424 | } 425 | 426 | .gemini-button:active { 427 | transform: translateY(0); 428 | } 429 | 430 | .gemini-button::before { 431 | content: ""; 432 | position: absolute; 433 | top: 50%; 434 | left: 50%; 435 | width: 0; 436 | height: 0; 437 | border-radius: 50%; 438 | background: rgba(255, 255, 255, 0.5); 439 | transform: translate(-50%, -50%); 440 | transition: width 0.6s, height 0.6s; 441 | } 442 | 443 | .gemini-button:active::before { 444 | width: 300px; 445 | height: 300px; 446 | } 447 | 448 | /* Glass morphism effect */ 449 | .gemini-glass { 450 | background: rgba(255, 255, 255, 0.05); 451 | backdrop-filter: blur(10px); 452 | -webkit-backdrop-filter: blur(10px); 453 | border: 1px solid rgba(255, 255, 255, 0.1); 454 | } 455 | 456 | .dark .gemini-glass { 457 | background: rgba(0, 0, 0, 0.05); 458 | border: 1px solid rgba(255, 255, 255, 0.05); 459 | } 460 | 461 | /* Animated background pattern */ 462 | .gemini-pattern { 463 | background-image: 464 | radial-gradient(circle at 20% 80%, rgba(6, 182, 212, 0.1) 0%, transparent 50%), 465 | radial-gradient(circle at 80% 20%, rgba(14, 116, 144, 0.1) 0%, transparent 50%), 466 | radial-gradient(circle at 40% 40%, rgba(8, 145, 178, 0.1) 0%, transparent 50%); 467 | background-size: 100% 100%; 468 | animation: gemini-float 20s ease-in-out infinite; 469 | } 470 | 471 | @keyframes gemini-float { 472 | 0%, 100% { 473 | background-position: 0% 0%; 474 | } 475 | 50% { 476 | background-position: 100% 100%; 477 | } 478 | } 479 | 480 | /* Loading animation */ 481 | .gemini-loader { 482 | display: inline-block; 483 | width: 20px; 484 | height: 20px; 485 | border: 2px solid rgba(6, 182, 212, 0.3); 486 | border-radius: 50%; 487 | border-top-color: #06b6d4; 488 | animation: spin 1s ease-in-out infinite; 489 | } 490 | 491 | /* Enhanced focus styles */ 492 | .gemini-focus:focus { 493 | outline: none; 494 | box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.2); 495 | } 496 | 497 | .gemini-focus:focus-visible { 498 | outline: none; 499 | box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.3); 500 | } 501 | 502 | /* Message layout stability */ 503 | .chat-message { 504 | contain: layout style; 505 | will-change: auto; 506 | } 507 | 508 | /* Prevent layout shift on initial render */ 509 | .chat-message .prose { 510 | min-height: 1.5rem; 511 | } 512 | 513 | /* Stable rendering for markdown content */ 514 | .chat-message .prose p { 515 | margin-top: 0; 516 | margin-bottom: 0.5rem; 517 | } 518 | 519 | .chat-message .prose p:last-child { 520 | margin-bottom: 0; 521 | } 522 | 523 | /* Prevent code block layout shift */ 524 | .chat-message .prose pre { 525 | margin-top: 0.5rem; 526 | margin-bottom: 0.5rem; 527 | } 528 | 529 | /* Disable transitions on initial load */ 530 | .chat-message:first-child, 531 | .chat-message:first-child * { 532 | animation-duration: 0s !important; 533 | transition-duration: 0s !important; 534 | } 535 | 536 | /* Smooth scroll behavior */ 537 | .scroll-smooth { 538 | scroll-behavior: smooth; 539 | } 540 | 541 | /* But instant for programmatic scrolls */ 542 | .scroll-instant { 543 | scroll-behavior: auto !important; 544 | } 545 | } --------------------------------------------------------------------------------