Source code for gwebui.app

"""
Gemini Web UI Application Logic
===============================

This module contains the core logic for the Gemini Web UI Streamlit application.
It provides a web-based interface for interacting with the `gemini-cli` tool.

Features
--------
- **Chat Interface**: A chat-like interface for sending prompts to Gemini.
- **Session Management**: Create, switch, and delete conversation sessions.
- **Context Management**: Upload files and paste images to be used as context.
- **History Tracking**: Maintains a local history of conversations within the session state.

Authors
-------
- Riccardo Finotello <riccardo.finotello@gmail.com>
"""

import io
import os
from datetime import datetime
from pathlib import Path
from typing import Any

import streamlit as st
from streamlit.delta_generator import DeltaGenerator
from streamlit_paste_button import PasteResult
from streamlit_paste_button import paste_image_button as pbutton

from gwebui import tools
from gwebui.config import ALLOWED_TOOLS
from gwebui.schemas import ChatMessage, SessionInfo


[docs] def init_session_state() -> None: """Initialize Streamlit session state variables.""" if "bg_color" not in st.session_state: st.session_state.bg_color = "#252525" if "font_size" not in st.session_state: st.session_state.font_size = 16 if "sidebar_font_size" not in st.session_state: st.session_state.sidebar_font_size = 14 if "messages" not in st.session_state: st.session_state.messages = [] if "session_id" not in st.session_state: st.session_state.session_id = None
[docs] def inject_custom_css( bg_color: str, sidebar_color: str, text_color: str, sidebar_text_color: str, font_size: int, sidebar_font_size: int, ) -> None: """Inject custom CSS for theme and typography.""" st.markdown( f""" <style> :root {{ --bg-color: {bg_color}; --text-color: {text_color}; --sidebar-bg: {sidebar_color}; --sidebar-text: {sidebar_text_color}; --font-size: {font_size}px; --sidebar-font-size: {sidebar_font_size}px; }} .stApp {{ background-color: var(--bg-color); color: var(--text-color); font-size: var(--font-size); }} [data-testid="stSidebar"] {{ background-color: var(--sidebar-bg); color: var(--sidebar-text); font-size: var(--sidebar-font-size); }} [data-testid="stHeader"] {{ background-color: var(--bg-color); }} [data-testid="stSidebar"] .stButton button {{ font-size: var(--sidebar-font-size); }} [data-testid="stSidebar"] h1 {{ font-size: calc(var(--sidebar-font-size) + 4px); color: var(--sidebar-text); }} [data-testid="stSidebar"] h2 {{ font-size: calc(var(--sidebar-font-size) + 2px); color: var(--sidebar-text); }} [data-testid="stSidebar"] h3 {{ font-size: var(--sidebar-font-size); color: var(--sidebar-text); }} /* Esthetically pleasing buttons and popovers */ [data-testid="stSidebar"] .stButton button, [data-testid="stPopover"] button {{ border-radius: 10px; border: 1px solid var(--sidebar-text)33; transition: all 0.3s ease; }} [data-testid="stSidebar"] .stButton button:hover, [data-testid="stPopover"] button:hover {{ border-color: var(--sidebar-text); box-shadow: 0 2px 4px var(--sidebar-text)22; }} /* Ensure markdown text follows the theme */ .stMarkdown, [data-testid="stMarkdownContainer"] p {{ color: var(--text-color); font-size: var(--font-size); }} [data-testid="stSidebar"] .stMarkdown, [data-testid="stSidebar"] [data-testid="stMarkdownContainer"] p {{ color: var(--sidebar-text); font-size: var(--sidebar-font-size); }} /* Chat input font size */ .stChatInput textarea {{ font-size: var(--font-size) !important; }} /* Compact list styling for buttons in sidebar */ [data-testid="stSidebar"] .stButton button {{ padding: 0px !important; height: auto !important; min-height: 0px !important; border: none !important; background: transparent !important; color: var(--sidebar-text) !important; text-align: left !important; justify-content: flex-start !important; font-size: var(--sidebar-font-size) !important; line-height: 1.2 !important; margin: 0px !important; width: 100% !important; display: flex !important; align-items: center !important; box-shadow: none !important; }} [data-testid="stSidebar"] button[aria-label="📝 New Chat"] {{ border: 1px solid var(--sidebar-text)55 !important; border-radius: 10px !important; padding: 6px 10px !important; background: var(--sidebar-bg) !important; box-shadow: 0 2px 6px var(--sidebar-text)22 !important; margin-bottom: 8px !important; }} [data-testid="stSidebar"] button[aria-label="📝 New Chat"]:hover {{ border-color: var(--sidebar-text) !important; box-shadow: 0 3px 8px var(--sidebar-text)33 !important; }} [data-testid="stSidebar"] .stButton button:hover {{ background: transparent !important; color: var(--sidebar-text) !important; text-decoration: underline !important; }} /* Target the internal label container of the button */ [data-testid="stSidebar"] .stButton button div, [data-testid="stSidebar"] .stButton button p {{ overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; width: 100% !important; text-align: left !important; margin: 0px !important; padding: 0px !important; display: block !important; justify-content: flex-start !important; }} /* Reduce gap and ensure left alignment for columns in the file list */ [data-testid="stSidebar"] [data-testid="column"] {{ padding: 0px !important; display: flex !important; justify-content: flex-start !important; align-items: center !important; overflow: hidden !important; }} /* Override paste button container background to match sidebar */ [data-testid="stSidebar"] div[data-testid="element-container"]:has(iframe[title="streamlit_paste_button.streamlit_paste_button"]), iframe[title="streamlit_paste_button.streamlit_paste_button"] {{ background-color: transparent !important; }} /* Fix Chat Input and Attachment Button Background */ /* We want to create a unified "one long bar" look */ /* 1. Make the outer wrapper transparent */ .stChatInput, [data-testid="stChatInput"] {{ background-color: transparent !important; }} /* 2. Style the internal flex container */ /* This is the container that holds the button and the input */ .stChatInput > div, [data-testid="stChatInput"] > div {{ background-color: var(--sidebar-bg) !important; border-radius: 20px !important; border: 1px solid var(--sidebar-text)33; }} /* 3. Make all children transparent */ .stChatInput button, [data-testid="stChatInput"] button, .stChatInput textarea, [data-testid="stChatInput"] textarea, .stChatInput > div > div, [data-testid="stChatInput"] > div > div {{ background-color: transparent !important; border: none !important; }} /* 4. Fix specific text color for the button */ .stChatInput button {{ color: var(--sidebar-text) !important; }} /* Hide the weird separator if it exists as a border/background */ [data-testid="stChatInput"] > div > div > div {{ background-color: transparent !important; }} </style> """, unsafe_allow_html=True, ) st.components.v1.html( # type: ignore """ <script> function fixIframeBackground() { const iframes = window.parent.document.querySelectorAll('iframe[title="streamlit_paste_button.streamlit_paste_button"]'); iframes.forEach(iframe => { try { const doc = iframe.contentDocument || iframe.contentWindow.document; if (doc) { doc.body.style.backgroundColor = 'transparent'; } } catch (e) { console.log("Cannot access iframe", e); } }); } setInterval(fixIframeBackground, 1000); fixIframeBackground(); </script> """, height=0, width=0, )
[docs] def render_sidebar( upload_dir: Path, available_sessions: list[SessionInfo], sidebar_color: str, ) -> None: """Render the sidebar with session management, model selection, and context.""" with st.sidebar: st.header("Chat Management") if st.button("📝 New Chat", use_container_width=True): st.session_state.session_id = None st.session_state.messages = [] if "history_select_key" not in st.session_state: st.session_state.history_select_key = 0 st.session_state.history_select_key += 1 st.rerun() with st.popover("📜 Past Conversations", use_container_width=True): if not available_sessions: st.info("No past conversations yet.") else: history_titles: list[str] = ["Select a conversation..."] + [ s.title for s in available_sessions ] current_index: int = 0 if st.session_state.session_id is not None: for i, s in enumerate(available_sessions): if s.session_id == st.session_state.session_id: current_index = i + 1 break if "history_select_key" not in st.session_state: st.session_state.history_select_key = 0 hist_cols: list[DeltaGenerator] = st.columns( [0.85, 0.15], gap="small", vertical_alignment="center" ) with hist_cols[0]: selected_title: str = st.selectbox( "Select a conversation", options=history_titles, index=current_index, key=f"history_selector_{st.session_state.history_select_key}", label_visibility="collapsed", ) with hist_cols[1]: if ( selected_title != "Select a conversation..." and st.button( "🗑️", key="del_session", help="Delete session" ) ): selected_session: SessionInfo | None = next( ( s for s in available_sessions if s.title == selected_title ), None, ) if selected_session: if tools.delete_session( selected_session.session_id ): st.toast("Session deleted.") st.session_state.session_id = None st.session_state.messages = [] st.session_state.history_select_key += 1 st.rerun() if selected_title != "Select a conversation...": selected_session = next( ( s for s in available_sessions if s.title == selected_title ), None, ) if ( selected_session and selected_session.session_id != st.session_state.session_id ): st.session_state.session_id = ( selected_session.session_id ) st.session_state.messages = ( tools.load_session_from_disk( selected_session.session_id ) ) st.rerun() model_options: list[str] = [ "gemini-3-pro-preview", "gemini-3-flash-preview", "Copilot", "RAG", "Researcher", "SysAdmin", "Agentic workflow", "Discovery", ] st.selectbox( "Select Model", options=model_options, index=0, key="selected_model" ) st.divider() st.subheader("Active Context") paste_result: PasteResult = pbutton( label="📋 Paste Image", key="context_paste", background_color=sidebar_color, ) if paste_result.image_data is not None: timestamp: str = datetime.now().strftime("%Y%m%d_%H%M%S") pasted_name: str = f"pasted_image_{timestamp}.png" pasted_path: Path = upload_dir / pasted_name img_bytes = io.BytesIO() img: Any = paste_result.image_data img.save(img_bytes, format="PNG") pasted_bytes: bytes = img_bytes.getvalue() pasted_bytes = tools.resize_image_if_needed( pasted_bytes, pasted_name ) with open(pasted_path, "wb") as f: f.write(pasted_bytes) st.toast(f"Image pasted and saved: {pasted_name}") st.rerun() if any(upload_dir.iterdir()): ctx_container: DeltaGenerator = st.container() tools.render_file_tree( upload_dir, container=ctx_container, allow_delete=True, include_hidden=False, key_prefix="ctx", level=0, ) else: st.info("No files in context.") st.subheader("Current Directory") if any(Path.cwd().iterdir()): cwd_container: DeltaGenerator = st.container() tools.render_file_tree( Path.cwd(), container=cwd_container, allow_delete=False, include_hidden=False, key_prefix="cwd", level=0, ) else: st.info("No files in current directory.") st.divider() with st.popover("🎨 Appearance", use_container_width=True): st.markdown("### Theme Settings") theme_cols: list[DeltaGenerator] = st.columns( [0.6, 0.4], vertical_alignment="center" ) with theme_cols[0]: st.markdown("Background Colour") with theme_cols[1]: st.color_picker( "Background Colour", key="bg_color", label_visibility="collapsed", ) st.slider( "Main Font Size (px)", min_value=10, max_value=24, key="font_size", ) st.slider( "Sidebar Font Size (px)", min_value=10, max_value=20, key="sidebar_font_size", )
[docs] def render_chat_history() -> None: """Render the conversation history in the main chat area.""" for message in st.session_state.messages: with st.chat_message(message.role): st.markdown(message.content) if message.tools: tool_badges = [] for tool in message.tools: icon = ( "✅" if tool.color == "green" else ("❌" if tool.color == "red" else "⚠️") ) count_str = f" ({tool.count})" if tool.count > 1 else "" badge = ( f"<span style='background-color: rgba(128, 128, 128, 0.2); " f"border: 1px solid {tool.color}; border-radius: 12px; " f"padding: 2px 8px; font-size: 0.8em; margin-right: 5px; " f"white-space: nowrap; display: inline-block; margin-bottom: 5px;'>" f"{icon} {tool.name}{count_str}</span>" ) tool_badges.append(badge) st.markdown( f"<div style='margin-top: 10px; margin-bottom: 5px; line-height: 1.5;'>" f"{''.join(tool_badges)}</div>", unsafe_allow_html=True, ) if message.role == "assistant" and message.model: st.markdown( f"<div style='text-align: right; color: #888; font-size: 0.8em;'>" f"{message.model}</div>", unsafe_allow_html=True, )
[docs] def handle_chat_input(upload_dir: Path) -> None: """Process user input, run Gemini CLI, and stream the response.""" env_prompt: str | None = os.environ.pop("GEMINI_TEST_PROMPT", None) chat_submission: Any = env_prompt or st.chat_input( "Ask Gemini... ", accept_file="multiple", key="chat_prompt" ) if chat_submission is None: pending_prompt: str | None = st.session_state.get("chat_prompt") last_processed: str | None = st.session_state.get( "_chat_prompt_last_processed" ) if pending_prompt and pending_prompt != last_processed: chat_submission = pending_prompt else: for key, value in st.session_state.items(): if not isinstance(value, str): continue key_str: str = str(key) if ( value and key_str not in ("_chat_prompt_last_processed", "chat_prompt") and ( "chat_input" in key_str or "chat_prompt" in key_str or "Ask Gemini" in key_str ) ): if value != last_processed: chat_submission = value st.session_state["_chat_prompt_source_key"] = key_str break if chat_submission is not None: prompt, attached_files = tools.parse_chat_submission(chat_submission) if not prompt: fallback_prompt: str | None = st.session_state.get("chat_prompt") if isinstance(fallback_prompt, str) and fallback_prompt.strip(): prompt = fallback_prompt.strip() elif isinstance(chat_submission, str) and chat_submission.strip(): prompt = chat_submission.strip() saved_files = tools.save_uploaded_files(attached_files, upload_dir) if saved_files: st.toast(f"File(s) uploaded: {', '.join(saved_files)}") if saved_files: attachment_info = f"[Attached: {', '.join(saved_files)}]" if prompt: prompt = f"{prompt}\n\n{attachment_info}" else: prompt = attachment_info if not prompt: st.rerun() st.session_state.messages.append( ChatMessage(role="user", content=prompt) ) with st.chat_message("user"): st.markdown(prompt) cmd = tools.build_gemini_command( prompt, upload_dir, st.session_state.session_id, ALLOWED_TOOLS, model=st.session_state.get("selected_model"), stream=True, ) with st.chat_message("assistant"): status_placeholder = st.empty() def event_generator(): status_container = None for event in tools.run_gemini_cli_stream(cmd): if event.type == "init": if event.session_id: st.session_state.session_id = event.session_id elif event.type == "tool_use": if status_container is None: status_container = status_placeholder.status( "Thinking...", expanded=True ) tool_name = event.tool_name or "Unknown Tool" status_container.markdown( f"**Using tool:** `{tool_name}`" ) elif event.type in ("message", "content"): if status_container: status_container.update( label="Finished thinking", state="complete", expanded=False, ) status_container = None if event.role == "assistant" or event.role is None: yield event.content elif event.type == "error": st.error(f"Error from Gemini: {event.content}") if status_container: status_container.update( label="Finished thinking", state="complete", expanded=False, ) # st.write_stream natively handles generator yielding and cursor simulation st.write_stream(event_generator) if st.session_state.session_id: messages = tools.load_session_from_disk( st.session_state.session_id ) st.session_state.messages = messages st.session_state["_chat_prompt_last_processed"] = prompt st.rerun()
[docs] def main() -> None: """ Run the Streamlit web interface for the Gemini CLI. """ upload_dir: Path = tools.get_upload_dir() st.set_page_config( page_title="Gemini CLI Web", page_icon="💎", layout="wide", initial_sidebar_state="expanded", menu_items={ "Get Help": "https://github.com/thesfinox/gemini-cli-webui", "Report a bug": "https://github.com/thesfinox/gemini-cli-webui/issues", "About": "https://github.com/thesfinox/gemini-cli-webui", }, ) init_session_state() bg_color: str = st.session_state.bg_color sidebar_color: str = tools.adjust_color_nuance(bg_color) text_color: str = tools.get_text_color(bg_color) sidebar_text_color: str = tools.get_text_color(sidebar_color) font_size: int = st.session_state.font_size sidebar_font_size: int = st.session_state.sidebar_font_size inject_custom_css( bg_color, sidebar_color, text_color, sidebar_text_color, font_size, sidebar_font_size, ) available_sessions = tools.list_available_sessions() render_sidebar(upload_dir, available_sessions, sidebar_color) st.title("Gemini CLI Web Interface") st.markdown( "Web interface for `gemini-cli`. Supports multi-modal chat " "(files/images) and session management." ) render_chat_history() handle_chat_input(upload_dir)
if __name__ == "__main__": main()