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()