Comments im ganzen Projekt

This commit is contained in:
Livio Meuli 2026-05-07 15:42:29 +02:00
parent c00a2454fc
commit eacf8c5137
11 changed files with 300 additions and 70 deletions

View File

@ -279,9 +279,12 @@ def trim_messages(messages: list) -> list:
tail = messages[2:]
original_task = messages[1]["content"] if len(messages) > 1 else ""
# Drop the oldest messages first (index 2 onwards) until we are under the limit.
# The system prompt (0) and original task (1) are never dropped.
while tail and sum(len(m["content"]) for m in head + tail) > MAX_HISTORY_CHARS:
tail.pop(0)
# Inject a reminder so the agent doesn't lose track of its goal after trimming.
reminder = {
"role": "user",
"content": (
@ -344,7 +347,7 @@ class CodingAgent:
payload = {
"model": self.model,
"messages": messages,
"temperature": 0.2,
"temperature": 0.2, # low temperature → deterministic, more reliable tool calls
"max_tokens": 4096,
"stream": False,
}
@ -445,7 +448,9 @@ class CodingAgent:
result = dispatch_tool(tool_name, arguments)
result = truncate_result(result)
# Build feedback – nudge agent to replan on errors
# Wrap the tool output in an XML tag so the LLM can easily find it.
# Append a <replan> tag on errors to force the agent to reconsider
# its plan rather than blindly retrying the same failing action.
feedback = f'<tool_result tool="{tool_name}">\n{result}\n</tool_result>'
if result.startswith("ERROR") or result.startswith("SYNTAX ERROR"):
feedback += (

View File

@ -1,4 +1,4 @@
"""Chat Manager - Handles chat history and AI communication"""
"""Manages the chat history and communication with the AI model API."""
import os
from dotenv import load_dotenv
@ -9,6 +9,13 @@ load_dotenv()
class ChatManager:
"""Handles sending messages and maintaining conversation history.
Connects to an OpenAI-compatible REST endpoint configured via environment
variables. All messages (user, assistant, system) are kept in memory so
the full conversation is sent with every request.
"""
def __init__(self):
self.api_host = os.getenv("HOST")
self.api_port = os.getenv("PORT")
@ -22,15 +29,23 @@ class ChatManager:
self.chat_history = []
def add_message(self, role: str, content: str) -> None:
"""Append a single message to the conversation history."""
self.chat_history.append({"role": role, "content": content})
def get_history(self) -> list:
"""Return the full conversation history."""
return self.chat_history
def clear_history(self) -> None:
"""Wipe the conversation history (starts a fresh chat)."""
self.chat_history = []
def send_message(self, user_message: str) -> str:
"""Send a user message to the AI and return its reply.
Adds the user message to history, calls the API with the full history
as context, and appends the AI reply to history before returning it.
"""
# Add user message to history
self.add_message("user", user_message)
@ -44,6 +59,7 @@ class ChatManager:
if self.api_key and self.api_key != "EMPTY":
headers["Authorization"] = f"Bearer {self.api_key}"
# Full history is sent so the model has multi-turn conversation context
payload = {
"model": self.model,
"messages": self.chat_history,
@ -91,6 +107,7 @@ class ChatManager:
raise Exception(error_msg)
def get_chat_display(self) -> list:
"""Return a copy of the history suitable for display in the UI."""
return [
{"role": msg["role"], "content": msg["content"]}
for msg in self.chat_history

View File

@ -1,9 +1,14 @@
# Simple in-memory logger used during code execution.
# Messages are collected in a list and can be retrieved for display or debugging.
class DebugLogger:
def __init__(self):
self.logs = []
def log(self, message):
"""Append a log message to the in-memory list."""
self.logs.append(message)
def get_logs(self):
return self.logs
"""Return all collected log messages."""
return self.logs

View File

@ -1,19 +1,38 @@
import subprocess
from pathlib import Path
RUN_TIMEOUT = 30 # seconds
# Maximum time (seconds) a subprocess is allowed to run before being killed.
RUN_TIMEOUT = 30
class ExecutionEngine:
"""Runs files from the editor in a subprocess and returns the output.
Currently supports Python (.py) and LaTeX (.tex) files.
Returns a dict with keys: stdout, stderr, rc (return code).
"""
def __init__(self):
pass
def run_code(self, active_file: Path) -> dict:
"""Execute the given file and return its output.
Args:
active_file: Absolute path to the file that should be run.
Returns:
{"stdout": str, "stderr": str, "rc": int}
rc == 0 means success, anything else is an error.
"""
suffix = active_file.suffix
current_dir = active_file.parent.resolve()
# Build the shell command depending on file type
if suffix == ".py":
cmd = ["py", active_file.name]
elif suffix == ".tex":
# pdflatex in non-interactive mode so it never waits for input
cmd = [
"pdflatex",
"-interaction=nonstopmode",
@ -22,20 +41,21 @@ class ExecutionEngine:
]
else:
return {"stdout": "", "stderr": f"Unsupported file type: {suffix}", "rc": 1}
try:
proc = subprocess.run(
cmd,
cwd=current_dir,
cwd=current_dir, # run inside the file's own directory
capture_output=True,
text=True,
timeout=RUN_TIMEOUT,
)
return {"stdout": proc.stdout, "stderr": proc.stderr, "rc": proc.returncode}
except subprocess.TimeoutExpired:
return {"stdout": "", "stderr": f"Timed out after {RUN_TIMEOUT}s", "rc": -1}
except FileNotFoundError as e:
# Raised when the interpreter/compiler binary is not found on PATH
return {"stdout": "", "stderr": str(e), "rc": -1}
except Exception as e:
return {"stdout": "", "stderr": str(e), "rc": -1}
return {"stdout": "", "stderr": str(e), "rc": -1}

View File

@ -1,6 +1,13 @@
"""Manages all file and folder operations inside the workspace directory.
Every method validates that the target path stays inside the workspace before
touching the filesystem, preventing path-traversal attacks.
"""
import streamlit as st
from pathlib import Path
# The workspace folder is created at module load so it always exists.
WORKSPACE = Path("workspace")
WORKSPACE.mkdir(exist_ok=True)
@ -10,14 +17,18 @@ class FileManager:
self.base_path.mkdir(exist_ok=True)
def create_folder(self, relative_path: str, name: str) -> bool:
"""Create a new sub-folder at relative_path/name inside the workspace.
Returns True on success, False on any error.
"""
if not name:
st.error(f"Invalid folder name: {name}")
return False
# Slashes in the name would silently create nested paths — reject them.
if "/" in name or "\\" in name:
st.error(f"Invalid folder name (no slashes allowed): {name}")
return False
name = Path(name)
if relative_path:
relative_path = Path(relative_path)
@ -26,10 +37,11 @@ class FileManager:
folder_path = (self.base_path / relative_path / name).resolve()
# Ensure the resolved path is still inside the workspace (prevents path traversal).
if not str(folder_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}")
return False
try:
folder_path.mkdir(exist_ok=False)
return True
@ -40,15 +52,19 @@ class FileManager:
st.error(f"Error creating folder {relative_path}: {str(e)}")
return False
def create_file(self, relative_path: str, name: str) -> bool:
def create_file(self, relative_path: str, name: str) -> bool:
"""Create a new empty file at relative_path/name inside the workspace.
If name has no extension, .txt is used as the default.
Returns True on success, False on any error.
"""
if not name or name.strip() == "" :
st.error(f"Invalid file name: {name}")
return False
name = Path(name)
if not name.suffix:
name = name.with_suffix(".txt") # Default to .txt if no extension provided
if relative_path:
relative_path = Path(relative_path)
else:
@ -56,10 +72,11 @@ class FileManager:
file_path = (self.base_path / relative_path / name).resolve()
# Ensure the resolved path is still inside the workspace (prevents path traversal).
if not str(file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}")
return False
try:
file_path.touch(exist_ok=False)
return True
@ -71,6 +88,9 @@ class FileManager:
return False
def read_file(self, relative_path: Path) -> str:
"""Read and return the text content of a file.
Returns an empty string and shows a Streamlit error on failure.
"""
file_path = (relative_path).resolve()
if not file_path.exists():
@ -79,10 +99,11 @@ class FileManager:
if not file_path.is_file():
st.error(f"Path is not a file: {relative_path}")
return ""
# Ensure the resolved path is still inside the workspace (prevents path traversal).
if not str(file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}")
return ""
try:
with open(file_path, "r") as f:
return f.read()
@ -94,12 +115,16 @@ class FileManager:
return ""
def save_file(self, relative_path: str, content: str):
"""Overwrite a file with new content.
Returns True on success, False on any error.
"""
file_path = (Path(relative_path)).resolve()
# Ensure the resolved path is still inside the workspace (prevents path traversal).
if not str(file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}")
return False
try:
with open(file_path, "w") as f:
f.write(content)
@ -107,35 +132,45 @@ class FileManager:
except Exception as e:
st.error(f"Error saving file {relative_path}: {str(e)}")
return False
def rename_file(self, old_relative_path: str, new_name: str) -> bool:
"""Rename a file while preserving its original extension.
If new_name carries a different extension it is ignored; the original
suffix is always kept. Returns True on success, False on any error.
"""
if not new_name or new_name.strip() == "":
st.error(f"Invalid file name: {new_name}")
return False
file_type = Path(old_relative_path).suffix
new_name = Path(new_name)
# Force the original extension so the file type cannot be changed by renaming.
if not Path(new_name).suffix == file_type:
new_name = Path(new_name).with_suffix(file_type) # Ensure the file extension remains the same
old_file_path = (Path(old_relative_path)).resolve()
new_file_path = old_file_path.parent / new_name
# Both old and new paths must stay inside the workspace.
if not str(old_file_path).startswith(str(self.base_path.resolve())) or not str(new_file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {old_relative_path}")
return False
try:
old_file_path.rename(new_file_path)
return True
except FileNotFoundError:
st.error(f"File not found: {old_relative_path}")
return False
return False
def delete_folder(self, relative_path) -> bool:
"""Delete a folder and all its contents recursively.
Returns True on success, False on any error.
"""
folder_path = (self.base_path / relative_path).resolve()
# Ensure the resolved path is still inside the workspace (prevents path traversal).
if not str(folder_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}")
return False
@ -153,12 +188,16 @@ class FileManager:
return False
def delete_file(self, relative_path):
"""Delete a single file from the workspace.
Returns True on success, False on any error.
"""
file_path = Path(relative_path).resolve()
# Ensure the resolved path is still inside the workspace (prevents path traversal).
if not str(file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}")
return False
try:
file_path.unlink()
return True
@ -170,17 +209,21 @@ class FileManager:
return False
def get_file_tree(self):
"""Return a nested dict representing the workspace directory tree.
Folders are dicts; files are None values.
Example: {"src": {"main.py": None}, "README.txt": None}
"""
def build_tree(path: Path):
tree = {}
for item in sorted(path.iterdir()):
if item.is_dir():
tree[item.name] = build_tree(item)
tree[item.name] = build_tree(item) # recurse into sub-folders
else:
tree[item.name] = None
tree[item.name] = None # leaf node for files
return tree
return build_tree(self.base_path)
if __name__ == "__main__":
FileManager()
FileManager()

View File

@ -1,18 +1,26 @@
"""System Prompter - Builds system prompts with optional file context"""
"""Builds the system prompt that is sent to the AI at the start of each chat session."""
MAX_FILE_CHARS = 4000 # Limit file context to avoid token overflow
# Prevents very large files from flooding the context window with tokens.
MAX_FILE_CHARS = 4000
class SystemPrompter:
"""Generates system prompts for the chat assistant.
When a file is open in the editor it can be embedded in the prompt so the
AI has direct context of the code the user is currently working on.
"""
@staticmethod
def generate_prompt(file_context: dict | None = None) -> str:
"""Build a system prompt, optionally embedding a file's content.
Args:
file_context: dict with keys 'name' and 'content', or None.
file_context: dict with keys 'name' (filename) and 'content' (raw text),
or None if no file should be included.
Returns:
A system prompt string.
A ready-to-use system prompt string.
"""
base = (
"You are an expert code assistant integrated into a lightweight code editor. "
@ -23,9 +31,11 @@ class SystemPrompter:
if file_context:
name = file_context.get("name", "unknown")
content = file_context.get("content", "")
# Truncate large files to avoid exceeding token limits
# Truncate large files to avoid exceeding the model's token limit
if len(content) > MAX_FILE_CHARS:
content = content[:MAX_FILE_CHARS] + "\n... [truncated]"
file_section = (
f"\n\nThe user currently has the following file open in the editor:\n"
f"<file name=\"{name}\">\n"

View File

@ -1,8 +1,20 @@
"""Entry point for the Streamlit app.
Runs with: streamlit run frontend/app.py
Responsibilities:
- Configure the page layout
- Inject global CSS tweaks
- Render the sidebar (navigation + file explorer)
- Delegate to the correct view (Chat or Code Editor) based on the radio selection
"""
import streamlit as st
import sys
from pathlib import Path
# Add project root to Python path for imports
# Add the project root to sys.path so backend imports work regardless of
# where streamlit is launched from.
sys.path.insert(0, str(Path(__file__).parent.parent))
from frontend.sidebar import render_sidebar
@ -10,12 +22,16 @@ from frontend.editor import render_editor
from frontend.chat import render_chat
from frontend.state import init_state
# Initialise all session-state keys before any widget is rendered
init_state()
def main():
st.set_page_config(page_title="Lightweight code editor", layout="wide")
# Small spacing corrections applied globally:
# - Reduce the default top padding of the main content area
# - Pull the sidebar content up so the logo sits at the very top
st.markdown(
"""
<style>
@ -28,14 +44,17 @@ def main():
st.title("Lightweight code editor")
# Re-run init_state to cover any keys that might have been missed on cold start
init_state()
render_sidebar()
# Switch between the two main views based on the sidebar radio button
if st.session_state.get("radio_interface_options") == "Code Editor":
render_editor()
elif st.session_state.get("radio_interface_options") == "Chat with AI Assistant":
render_chat()
if __name__ == "__main__":
main()

View File

@ -1,3 +1,5 @@
"""Chat view — renders both the normal chat interface and the Coding Agent mode."""
import streamlit as st
from backend.managers.chat_manager import ChatManager
from backend.managers.system_prompter import SystemPrompter
@ -6,12 +8,14 @@ from backend.managers.system_prompter import SystemPrompter
# ── Agent Mode helpers ────────────────────────────────────────────────────────
def _start_agent(task: str):
"""Initialise a fresh CodingAgent, start the task,
and propose the first action."""
"""Create a new CodingAgent, feed it the task, and propose the first action.
Stores the agent and its state in session_state so Streamlit can reference
them across reruns without losing progress.
"""
from backend.agent.coding_agent import CodingAgent
agent = CodingAgent()
agent.start_task(task)
action = agent.propose_next_action()
action = agent.propose_next_action() # ask LLM: what is the first step?
st.session_state.coding_agent = agent
st.session_state.agent_pending_action = action
st.session_state.agent_status = "waiting_approval"
@ -19,13 +23,13 @@ def _start_agent(task: str):
def _approve_action():
"""Execute the pending action,
append it to the log, then propose the next step."""
"""Execute the pending action, log it, then immediately propose the next step."""
agent = st.session_state.coding_agent
pending = st.session_state.agent_pending_action
result = agent.approve()
result = agent.approve() # actually runs the tool
# Append a record to the log so the user can review every completed step.
st.session_state.agent_log.append({
"thought": pending.get("thought", ""),
"tool": result["tool"],
@ -34,16 +38,21 @@ def _approve_action():
})
if result["is_done"]:
# Agent called the "done" tool — task is fully complete.
st.session_state.agent_status = "done"
st.session_state.agent_pending_action = None
else:
# More steps needed — get the next proposed action.
next_action = agent.propose_next_action()
st.session_state.agent_pending_action = next_action
st.session_state.agent_status = "waiting_approval"
def _reject_action(feedback: str):
"""Reject the pending action with optional feedback, then replan."""
"""Reject the pending action with feedback so the agent replans.
The pending action is discarded; the agent receives the user's feedback and
proposes a different approach on the next call to propose_next_action().
"""
agent = st.session_state.coding_agent
agent.reject(feedback or "Please try a different approach.")
next_action = agent.propose_next_action()
@ -52,7 +61,7 @@ def _reject_action(feedback: str):
def _followup_agent(question: str):
"""Inject a follow-up question into the finished agent and resume the loop."""
"""Continue a finished task by injecting a follow-up question and resuming the loop."""
agent = st.session_state.coding_agent
agent.follow_up(question)
action = agent.propose_next_action()
@ -61,7 +70,7 @@ def _followup_agent(question: str):
def _reset_agent():
"""Reset all agent state back to idle."""
"""Clear all agent state and return to the idle (task input) screen."""
st.session_state.coding_agent = None
st.session_state.agent_status = "idle"
st.session_state.agent_log = []
@ -71,13 +80,21 @@ def _reset_agent():
# ── Agent Mode UI ─────────────────────────────────────────────────────────────
def render_agent_mode():
# Toggle must always be rendered so Streamlit keeps agent_mode=True in session_state
"""Render the step-by-step agent UI.
Three distinct screens based on agent_status:
- "idle" → task description input + Start button
- "waiting_approval" → show proposed action, Approve / Reject / Abort
- "done" → success message, follow-up input, New Task button
"""
# The toggle must always render so Streamlit keeps agent_mode=True in session_state.
st.toggle("Agent Mode", key="agent_mode")
agent_status = st.session_state.get("agent_status", "idle")
agent_log = st.session_state.get("agent_log", [])
# ── Agent Log ────────────────────────────────────────────────────────────
# Collapsed by default so it doesn't clutter the UI during active tasks.
if agent_log:
with st.expander(f"Agent Log — {len(agent_log)} step(s) completed", expanded=False):
for i, step in enumerate(agent_log):
@ -87,6 +104,7 @@ def render_agent_mode():
if step.get("arguments"):
st.json(step["arguments"])
result_text = step.get("result", "")
# Colour the result based on whether the tool succeeded or failed.
if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"):
st.error(result_text)
elif result_text.startswith("OK") or result_text.startswith("DONE"):
@ -120,6 +138,8 @@ def render_agent_mode():
args = pending.get("arguments", {})
if args:
# Show file content separately as a code block for readability;
# other arguments are displayed as JSON.
if "content" in args:
display_args = {k: v for k, v in args.items() if k != "content"}
if display_args:
@ -182,7 +202,13 @@ def render_agent_mode():
# ── Normal Chat ───────────────────────────────────────────────────────────────
def render_normal_chat():
# Chat history as bubbles
"""Render the standard multi-turn chat interface.
On the first message the system prompt is injected into the history.
Each subsequent message appends to the same conversation so the AI retains
full context throughout the session.
"""
# Replay the conversation history as chat bubbles (skip system messages).
for message in st.session_state.chat_history:
role = message["role"]
if role == "system":
@ -195,16 +221,17 @@ def render_normal_chat():
if user_input:
chat_manager = st.session_state.chat_manager
# Inject system prompt on the first message
# On the very first user message, prepend the system prompt so the AI
# knows it is a code assistant embedded in an editor.
if not chat_manager.get_history():
system_prompt = SystemPrompter.generate_prompt()
chat_manager.add_message("system", system_prompt)
# Show user message immediately without waiting for response
# Show user message immediately without waiting for response.
with st.chat_message("user"):
st.markdown(user_input)
# Show response with spinner while API is called
# Call the AI and show its response with a spinner while waiting.
with st.chat_message("assistant"):
with st.spinner("Thinking..."):
try:
@ -227,6 +254,7 @@ def render_normal_chat():
# ── Entry point ───────────────────────────────────────────────────────────────
def render_chat():
"""Top-level chat view — switches between Agent Mode and normal chat."""
if st.session_state.get("agent_mode", False):
st.subheader("Coding Agent")
render_agent_mode()

View File

@ -1,3 +1,5 @@
"""Code Editor view — renders the Ace editor, file tabs, and execution output."""
import streamlit as st
import streamlit_ace as st_ace
from pathlib import Path
@ -6,6 +8,7 @@ from backend.managers.file_manager import FileManager
from backend.managers.execution_engine import ExecutionEngine
from backend.managers.debug_logger import DebugLogger
# Maps file extensions to Ace editor language modes for syntax highlighting.
LANG_MAP = {
".py": "python", ".tex": "latex", ".js": "javascript",
".html": "html", ".css": "css", ".sh": "bash",
@ -17,6 +20,10 @@ LANG_MAP = {
@st.dialog("Rename File")
def _rename_dialog(file_path: str):
"""Dialog for renaming the given file.
Updates open_files, files_content and active_file in session_state so
all tabs and the editor reference the new path immediately.
"""
fm = FileManager()
st.write(f"Current name: **{Path(file_path).name}**")
new_name = st.text_input("New name:", value=Path(file_path).stem)
@ -32,10 +39,13 @@ def _rename_dialog(file_path: str):
if fm.rename_file(file_path, new_name.strip()):
ext = Path(file_path).suffix
new_file_path = str(Path(file_path).parent / (Path(new_name.strip()).stem + ext))
# Update the open-files list in place so the tab order is preserved.
i = st.session_state.open_files.index(file_path)
st.session_state.open_files[i] = new_file_path
# Transfer cached editor content to the new path key.
st.session_state.files_content[new_file_path] = \
st.session_state.files_content.pop(file_path)
# Update active_file if the renamed file was the active one.
if st.session_state.active_file == file_path:
st.session_state.active_file = new_file_path
st.rerun()
@ -48,6 +58,10 @@ def _rename_dialog(file_path: str):
@st.dialog("Delete File")
def _delete_dialog(file_path: str):
"""Confirmation dialog before permanently deleting a file.
Removes the file from the filesystem and cleans up all session-state
references (open_files, files_content, active_file).
"""
fm = FileManager()
st.warning(f"Delete **{Path(file_path).name}**? This cannot be undone.")
@ -57,6 +71,7 @@ def _delete_dialog(file_path: str):
if fm.delete_file(file_path):
st.session_state.open_files.remove(file_path)
st.session_state.files_content.pop(file_path, None)
# Fall back to the first remaining open file, or None if none are left.
if st.session_state.active_file == file_path:
st.session_state.active_file = (
st.session_state.open_files[0]
@ -71,12 +86,16 @@ def _delete_dialog(file_path: str):
def run_active_file():
"""Execute the currently active file and store the result in session_state.
Returns the execution result dict {stdout, stderr, return_code}, or None
if no active file is set.
"""
active_file = st.session_state.active_file
if not active_file:
st.warning("No active file to run.")
return
execution_engine = ExecutionEngine()
debug_logger = DebugLogger()
@ -86,7 +105,7 @@ def run_active_file():
output = execution_engine.run_code(Path(active_file))
debug_logger.log("Execution completed.")
st.session_state.code_execution_output = {
"stdout": output["stdout"],
"stderr": output["stderr"],
@ -96,6 +115,7 @@ def run_active_file():
return result
def render_editor():
"""Render the full Code Editor view with tabs, Ace editor, and run output."""
st.subheader("Code Editor")
if not st.session_state.open_files:
@ -105,16 +125,19 @@ def render_editor():
fm = FileManager()
# ── Tab bar via st.tabs() ─────────────────────────────────────────────────
# Build one tab per open file, named by the file's basename.
tab_names = [Path(f).name for f in st.session_state.open_files]
tabs = st.tabs(tab_names)
for idx, file_path in enumerate(st.session_state.open_files):
with tabs[idx]:
# Load file content from disk on first open; afterwards use the cached version.
if file_path not in st.session_state.files_content:
st.session_state.files_content[file_path] = fm.read_file(Path(file_path))
file_language = LANG_MAP.get(Path(file_path).suffix, "text")
# Ace editor widget — auto_update sends content to Python on each keystroke.
code = st_ace.st_ace(
value=st.session_state.files_content[file_path],
language=file_language,
@ -124,6 +147,7 @@ def render_editor():
height=400,
)
# Keep the in-memory cache in sync with what the editor currently shows.
if code != st.session_state.files_content[file_path]:
st.session_state.files_content[file_path] = code
@ -137,6 +161,7 @@ def render_editor():
if st.button("Close File", key=f"close_{file_path}"):
st.session_state.open_files.remove(file_path)
st.session_state.files_content.pop(file_path, None)
# Switch active_file to the next available tab.
st.session_state.active_file = (
st.session_state.open_files[0]
if st.session_state.open_files else None
@ -155,33 +180,34 @@ def render_editor():
result = run_active_file()
if not result:
st.stop()
st.subheader("Execution Output")
# Green on exit code 0 (success), red on anything else (error/crash).
if result["return_code"] == 0:
st.success(f"Exit code: {result['return_code']}")
else:
st.error(f"Exit code: {result['return_code']}")
if result["stdout"]:
st.text_area(
"Standard Output",
value=result["stdout"],
"Standard Output",
value=result["stdout"],
height=200,
disabled=True,
disabled=True,
key="run_stdout")
if result["stderr"]:
st.text_area(
"Standard Error",
value=result["stderr"],
"Standard Error",
value=result["stderr"],
height=200,
disabled=True,
disabled=True,
key="run_stderr")
if not result["stdout"] and not result["stderr"]:
st.info("No output produced by the code execution.")
if __name__ == "__main__":
render_editor()
render_editor()

View File

@ -1,10 +1,15 @@
"""Sidebar — navigation radio, logo, and the workspace file explorer."""
import streamlit as st
from streamlit_arborist import tree_view
from pathlib import Path
from backend.managers.file_manager import FileManager
# Shared FileManager instance for all sidebar operations.
fm = FileManager()
# Maps file extensions (and special keys "folder"/"default") to display emojis
# shown next to each entry in the file tree.
SUFFIX_MAP = {
".py": "🐍", # Python
".js": "🟨", # JavaScript (Gelbes Quadrat/Logo)
@ -29,11 +34,13 @@ SUFFIX_MAP = {
@st.dialog("Delete Folder")
def _delete_folder_dialog(folder_rel: str, folder_name: str):
"""Confirmation dialog before permanently deleting a folder and its contents."""
st.warning(f"Delete **{folder_name}** and all its contents? This cannot be undone.")
col1, col2 = st.columns(2)
with col1:
if st.button("Delete", type="primary", use_container_width=True):
if fm.delete_folder(folder_rel):
# Clear the selected-folder state so the action bar disappears.
st.session_state.selected_folder = None
st.session_state.selected_folder_rel = None
st.rerun()
@ -46,6 +53,7 @@ def _delete_folder_dialog(folder_rel: str, folder_name: str):
@st.dialog("Add File")
def _add_file_dialog(parent_path: str = ""):
"""Dialog for creating a new file inside parent_path (or workspace root if empty)."""
name = st.text_input("File name:", placeholder="e.g. script.py")
col1, col2 = st.columns(2)
with col1:
@ -64,6 +72,7 @@ def _add_file_dialog(parent_path: str = ""):
@st.dialog("Add Folder")
def _add_folder_dialog(parent_path: str = ""):
"""Dialog for creating a new sub-folder inside parent_path (or workspace root if empty)."""
name = st.text_input("Folder name:", placeholder="e.g. utils")
col1, col2 = st.columns(2)
with col1:
@ -83,19 +92,33 @@ def _add_folder_dialog(parent_path: str = ""):
# ── File tree ─────────────────────────────────────────────────────────────────
def build_arborist_tree(tree, parent_path=Path()):
"""Convert the FileManager dict tree into the node format expected by streamlit-arborist.
Folders become nodes with a "children" list; files become leaf nodes
with an emoji prefix derived from their extension.
Args:
tree: Nested dict from FileManager.get_file_tree()
parent_path: Accumulates the relative path while recursing.
Returns:
List of node dicts accepted by tree_view().
"""
nodes = []
for name, content in sorted(tree.items()):
full_path = parent_path / name
node_id = str(full_path.as_posix())
node_id = str(full_path.as_posix()) # forward-slash IDs work cross-platform
if isinstance(content, dict):
# Directory — recurse to build child nodes.
nodes.append({
"id": node_id,
"name": f"{name}",
"children": build_arborist_tree(content, full_path)
})
else:
# File — pick an emoji based on extension, fall back to default.
suffix = Path(name).suffix
icon = SUFFIX_MAP.get(suffix, SUFFIX_MAP["default"])
@ -109,6 +132,7 @@ def build_arborist_tree(tree, parent_path=Path()):
def render_filetree_arborist(tree):
"""Render the interactive file tree and return the currently selected node dict."""
data = build_arborist_tree(tree)
selected = tree_view(
@ -116,7 +140,7 @@ def render_filetree_arborist(tree):
icons={"open": "📂", "closed": "📁"},
height=200,
selection=None,
select_internal_nodes=True,
select_internal_nodes=True, # allow clicking folder names, not just files
open_by_default=False
)
@ -126,6 +150,12 @@ def render_filetree_arborist(tree):
# ── Sidebar ───────────────────────────────────────────────────────────────────
def render_sidebar():
"""Render the full sidebar: navigation radio and workspace file explorer.
Tree click handling:
- Clicking a file → appended to open_files, set as active_file
- Clicking a folder → stored in selected_folder so the action bar appears
"""
st.sidebar.title("Navigation")
navigation_section = st.sidebar.container()
@ -155,11 +185,14 @@ def render_sidebar():
if selected:
selected_path = selected.get("id")
# Only react when the user clicks a *different* node to
# avoid re-running on every Streamlit rerender.
if st.session_state.last_selected != selected_path:
st.session_state.last_selected = selected_path
abs_path = fm.base_path / selected_path
if abs_path.is_file():
# Open the file in the editor.
st.session_state.selected_folder = None
st.session_state.selected_folder_rel = None
file_str = str(abs_path)
@ -169,11 +202,13 @@ def render_sidebar():
st.rerun()
elif abs_path.is_dir():
# Select the folder so its action bar appears below.
st.session_state.selected_folder = str(abs_path)
st.session_state.selected_folder_rel = selected_path
st.rerun()
# Folder actions — rendered outside the selection block so they persist across reruns
# Folder action bar — rendered unconditionally outside the selection
# block so it persists across reruns even when no new click happens.
if st.session_state.get("selected_folder"):
folder_name = Path(st.session_state.selected_folder).name
folder_rel = st.session_state.selected_folder_rel
@ -188,6 +223,7 @@ def render_sidebar():
_delete_folder_dialog(folder_rel, folder_name)
with add_more:
# Popover for workspace-root actions (not tied to any selected folder).
with st.popover("⚙️ Explorer Options", key="popover_options", use_container_width=True):
if st.button("Add File", key="btn_add_file", use_container_width=True):
_add_file_dialog("")

View File

@ -1,12 +1,21 @@
"""Centralised session-state initialisation for the Streamlit app.
All keys used throughout the app are declared here with their default values.
Calling init_state() at the top of app.py ensures every key exists before any
page tries to read it, preventing KeyError on the first load.
"""
import streamlit as st
from backend.managers.chat_manager import ChatManager
def init_state():
# Sidebar state initialization
# last_selected tracks the previously clicked tree node to detect new clicks
if "last_selected" not in st.session_state:
st.session_state.last_selected = None
# Absolute path and workspace-relative path of the currently highlighted folder
if "selected_folder" not in st.session_state:
st.session_state.selected_folder = None
@ -14,19 +23,24 @@ def init_state():
st.session_state.selected_folder_rel = None
# Chat manager (persists across reruns)
# ChatManager keeps the full conversation history in memory across reruns
if "chat_manager" not in st.session_state:
st.session_state.chat_manager = ChatManager()
# Editor state initialization
# List of absolute file paths that are currently open as tabs
if "open_files" not in st.session_state:
st.session_state.open_files = []
# Dict mapping file path → current editor content (may be unsaved)
if "files_content" not in st.session_state:
st.session_state.files_content = {}
# Absolute path of the file whose tab is currently active
if "active_file" not in st.session_state:
st.session_state.active_file = None
# Index of the active tab (used by st.tabs)
if "active_tab" not in st.session_state:
st.session_state.active_tab = 0
@ -36,26 +50,33 @@ def init_state():
if "code_suggestions" not in st.session_state:
st.session_state.code_suggestions = []
# Output dict from the last code run: {stdout, stderr, return_code}
if "code_execution_output" not in st.session_state:
st.session_state.code_execution_output = ""
# Chat state initialization
# Flat list of {"role": ..., "content": ...} dicts shown as chat bubbles
if "chat_history" not in st.session_state:
st.session_state.chat_history = []
# Agent Mode state
# Whether the UI is currently in Agent Mode (vs normal chat)
if "agent_mode" not in st.session_state:
st.session_state.agent_mode = False
# The live CodingAgent instance while a task is running
if "coding_agent" not in st.session_state:
st.session_state.coding_agent = None
# Current status of the agent: "idle" | "waiting_approval" | "done"
if "agent_status" not in st.session_state:
st.session_state.agent_status = "idle"
# List of completed steps shown in the collapsible Agent Log
if "agent_log" not in st.session_state:
st.session_state.agent_log = []
# The action the agent proposed but has not yet been approved or rejected
if "agent_pending_action" not in st.session_state:
st.session_state.agent_pending_action = None