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:] tail = messages[2:]
original_task = messages[1]["content"] if len(messages) > 1 else "" 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: while tail and sum(len(m["content"]) for m in head + tail) > MAX_HISTORY_CHARS:
tail.pop(0) tail.pop(0)
# Inject a reminder so the agent doesn't lose track of its goal after trimming.
reminder = { reminder = {
"role": "user", "role": "user",
"content": ( "content": (
@ -344,7 +347,7 @@ class CodingAgent:
payload = { payload = {
"model": self.model, "model": self.model,
"messages": messages, "messages": messages,
"temperature": 0.2, "temperature": 0.2, # low temperature → deterministic, more reliable tool calls
"max_tokens": 4096, "max_tokens": 4096,
"stream": False, "stream": False,
} }
@ -445,7 +448,9 @@ class CodingAgent:
result = dispatch_tool(tool_name, arguments) result = dispatch_tool(tool_name, arguments)
result = truncate_result(result) 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>' feedback = f'<tool_result tool="{tool_name}">\n{result}\n</tool_result>'
if result.startswith("ERROR") or result.startswith("SYNTAX ERROR"): if result.startswith("ERROR") or result.startswith("SYNTAX ERROR"):
feedback += ( 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 import os
from dotenv import load_dotenv from dotenv import load_dotenv
@ -9,6 +9,13 @@ load_dotenv()
class ChatManager: 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): def __init__(self):
self.api_host = os.getenv("HOST") self.api_host = os.getenv("HOST")
self.api_port = os.getenv("PORT") self.api_port = os.getenv("PORT")
@ -22,15 +29,23 @@ class ChatManager:
self.chat_history = [] self.chat_history = []
def add_message(self, role: str, content: str) -> None: 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}) self.chat_history.append({"role": role, "content": content})
def get_history(self) -> list: def get_history(self) -> list:
"""Return the full conversation history."""
return self.chat_history return self.chat_history
def clear_history(self) -> None: def clear_history(self) -> None:
"""Wipe the conversation history (starts a fresh chat)."""
self.chat_history = [] self.chat_history = []
def send_message(self, user_message: str) -> str: 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 # Add user message to history
self.add_message("user", user_message) self.add_message("user", user_message)
@ -44,6 +59,7 @@ class ChatManager:
if self.api_key and self.api_key != "EMPTY": if self.api_key and self.api_key != "EMPTY":
headers["Authorization"] = f"Bearer {self.api_key}" headers["Authorization"] = f"Bearer {self.api_key}"
# Full history is sent so the model has multi-turn conversation context
payload = { payload = {
"model": self.model, "model": self.model,
"messages": self.chat_history, "messages": self.chat_history,
@ -91,6 +107,7 @@ class ChatManager:
raise Exception(error_msg) raise Exception(error_msg)
def get_chat_display(self) -> list: def get_chat_display(self) -> list:
"""Return a copy of the history suitable for display in the UI."""
return [ return [
{"role": msg["role"], "content": msg["content"]} {"role": msg["role"], "content": msg["content"]}
for msg in self.chat_history 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: class DebugLogger:
def __init__(self): def __init__(self):
self.logs = [] self.logs = []
def log(self, message): def log(self, message):
"""Append a log message to the in-memory list."""
self.logs.append(message) self.logs.append(message)
def get_logs(self): def get_logs(self):
"""Return all collected log messages."""
return self.logs return self.logs

View File

@ -1,19 +1,38 @@
import subprocess import subprocess
from pathlib import Path 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: 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): def __init__(self):
pass pass
def run_code(self, active_file: Path) -> dict: 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 suffix = active_file.suffix
current_dir = active_file.parent.resolve() current_dir = active_file.parent.resolve()
# Build the shell command depending on file type
if suffix == ".py": if suffix == ".py":
cmd = ["py", active_file.name] cmd = ["py", active_file.name]
elif suffix == ".tex": elif suffix == ".tex":
# pdflatex in non-interactive mode so it never waits for input
cmd = [ cmd = [
"pdflatex", "pdflatex",
"-interaction=nonstopmode", "-interaction=nonstopmode",
@ -26,7 +45,7 @@ class ExecutionEngine:
try: try:
proc = subprocess.run( proc = subprocess.run(
cmd, cmd,
cwd=current_dir, cwd=current_dir, # run inside the file's own directory
capture_output=True, capture_output=True,
text=True, text=True,
timeout=RUN_TIMEOUT, timeout=RUN_TIMEOUT,
@ -36,6 +55,7 @@ class ExecutionEngine:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return {"stdout": "", "stderr": f"Timed out after {RUN_TIMEOUT}s", "rc": -1} return {"stdout": "", "stderr": f"Timed out after {RUN_TIMEOUT}s", "rc": -1}
except FileNotFoundError as e: except FileNotFoundError as e:
# Raised when the interpreter/compiler binary is not found on PATH
return {"stdout": "", "stderr": str(e), "rc": -1} return {"stdout": "", "stderr": str(e), "rc": -1}
except Exception as e: 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 import streamlit as st
from pathlib import Path from pathlib import Path
# The workspace folder is created at module load so it always exists.
WORKSPACE = Path("workspace") WORKSPACE = Path("workspace")
WORKSPACE.mkdir(exist_ok=True) WORKSPACE.mkdir(exist_ok=True)
@ -10,10 +17,14 @@ class FileManager:
self.base_path.mkdir(exist_ok=True) self.base_path.mkdir(exist_ok=True)
def create_folder(self, relative_path: str, name: str) -> bool: 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: if not name:
st.error(f"Invalid folder name: {name}") st.error(f"Invalid folder name: {name}")
return False return False
# Slashes in the name would silently create nested paths — reject them.
if "/" in name or "\\" in name: if "/" in name or "\\" in name:
st.error(f"Invalid folder name (no slashes allowed): {name}") st.error(f"Invalid folder name (no slashes allowed): {name}")
return False return False
@ -26,6 +37,7 @@ class FileManager:
folder_path = (self.base_path / relative_path / name).resolve() 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())): if not str(folder_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}") st.error(f"Access denied: {relative_path}")
return False return False
@ -41,6 +53,10 @@ class FileManager:
return False 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() == "" : if not name or name.strip() == "" :
st.error(f"Invalid file name: {name}") st.error(f"Invalid file name: {name}")
return False return False
@ -56,6 +72,7 @@ class FileManager:
file_path = (self.base_path / relative_path / name).resolve() 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())): if not str(file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}") st.error(f"Access denied: {relative_path}")
return False return False
@ -71,6 +88,9 @@ class FileManager:
return False return False
def read_file(self, relative_path: Path) -> str: 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() file_path = (relative_path).resolve()
if not file_path.exists(): if not file_path.exists():
@ -79,6 +99,7 @@ class FileManager:
if not file_path.is_file(): if not file_path.is_file():
st.error(f"Path is not a file: {relative_path}") st.error(f"Path is not a file: {relative_path}")
return "" return ""
# Ensure the resolved path is still inside the workspace (prevents path traversal).
if not str(file_path).startswith(str(self.base_path.resolve())): if not str(file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}") st.error(f"Access denied: {relative_path}")
return "" return ""
@ -94,8 +115,12 @@ class FileManager:
return "" return ""
def save_file(self, relative_path: str, content: str): 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() 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())): if not str(file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}") st.error(f"Access denied: {relative_path}")
return False return False
@ -109,6 +134,10 @@ class FileManager:
return False return False
def rename_file(self, old_relative_path: str, new_name: str) -> bool: 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() == "": if not new_name or new_name.strip() == "":
st.error(f"Invalid file name: {new_name}") st.error(f"Invalid file name: {new_name}")
return False return False
@ -116,12 +145,14 @@ class FileManager:
file_type = Path(old_relative_path).suffix file_type = Path(old_relative_path).suffix
new_name = Path(new_name) 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: if not Path(new_name).suffix == file_type:
new_name = Path(new_name).with_suffix(file_type) # Ensure the file extension remains the same new_name = Path(new_name).with_suffix(file_type) # Ensure the file extension remains the same
old_file_path = (Path(old_relative_path)).resolve() old_file_path = (Path(old_relative_path)).resolve()
new_file_path = old_file_path.parent / new_name 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())): 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}") st.error(f"Access denied: {old_relative_path}")
return False return False
@ -134,8 +165,12 @@ class FileManager:
return False return False
def delete_folder(self, relative_path) -> bool: 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() 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())): if not str(folder_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}") st.error(f"Access denied: {relative_path}")
return False return False
@ -153,8 +188,12 @@ class FileManager:
return False return False
def delete_file(self, relative_path): 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() 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())): if not str(file_path).startswith(str(self.base_path.resolve())):
st.error(f"Access denied: {relative_path}") st.error(f"Access denied: {relative_path}")
return False return False
@ -170,15 +209,19 @@ class FileManager:
return False return False
def get_file_tree(self): 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): def build_tree(path: Path):
tree = {} tree = {}
for item in sorted(path.iterdir()): for item in sorted(path.iterdir()):
if item.is_dir(): if item.is_dir():
tree[item.name] = build_tree(item) tree[item.name] = build_tree(item) # recurse into sub-folders
else: else:
tree[item.name] = None tree[item.name] = None # leaf node for files
return tree return tree
return build_tree(self.base_path) return build_tree(self.base_path)

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: 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 @staticmethod
def generate_prompt(file_context: dict | None = None) -> str: def generate_prompt(file_context: dict | None = None) -> str:
"""Build a system prompt, optionally embedding a file's content. """Build a system prompt, optionally embedding a file's content.
Args: 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: Returns:
A system prompt string. A ready-to-use system prompt string.
""" """
base = ( base = (
"You are an expert code assistant integrated into a lightweight code editor. " "You are an expert code assistant integrated into a lightweight code editor. "
@ -23,9 +31,11 @@ class SystemPrompter:
if file_context: if file_context:
name = file_context.get("name", "unknown") name = file_context.get("name", "unknown")
content = file_context.get("content", "") 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: if len(content) > MAX_FILE_CHARS:
content = content[:MAX_FILE_CHARS] + "\n... [truncated]" content = content[:MAX_FILE_CHARS] + "\n... [truncated]"
file_section = ( file_section = (
f"\n\nThe user currently has the following file open in the editor:\n" f"\n\nThe user currently has the following file open in the editor:\n"
f"<file name=\"{name}\">\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 streamlit as st
import sys import sys
from pathlib import Path 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)) sys.path.insert(0, str(Path(__file__).parent.parent))
from frontend.sidebar import render_sidebar from frontend.sidebar import render_sidebar
@ -10,12 +22,16 @@ from frontend.editor import render_editor
from frontend.chat import render_chat from frontend.chat import render_chat
from frontend.state import init_state from frontend.state import init_state
# Initialise all session-state keys before any widget is rendered
init_state() init_state()
def main(): def main():
st.set_page_config(page_title="Lightweight code editor", layout="wide") 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( st.markdown(
""" """
<style> <style>
@ -28,14 +44,17 @@ def main():
st.title("Lightweight code editor") st.title("Lightweight code editor")
# Re-run init_state to cover any keys that might have been missed on cold start
init_state() init_state()
render_sidebar() render_sidebar()
# Switch between the two main views based on the sidebar radio button
if st.session_state.get("radio_interface_options") == "Code Editor": if st.session_state.get("radio_interface_options") == "Code Editor":
render_editor() render_editor()
elif st.session_state.get("radio_interface_options") == "Chat with AI Assistant": elif st.session_state.get("radio_interface_options") == "Chat with AI Assistant":
render_chat() render_chat()
if __name__ == "__main__": if __name__ == "__main__":
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 import streamlit as st
from backend.managers.chat_manager import ChatManager from backend.managers.chat_manager import ChatManager
from backend.managers.system_prompter import SystemPrompter from backend.managers.system_prompter import SystemPrompter
@ -6,12 +8,14 @@ from backend.managers.system_prompter import SystemPrompter
# ── Agent Mode helpers ──────────────────────────────────────────────────────── # ── Agent Mode helpers ────────────────────────────────────────────────────────
def _start_agent(task: str): def _start_agent(task: str):
"""Initialise a fresh CodingAgent, start the task, """Create a new CodingAgent, feed it the task, and propose the first action.
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 from backend.agent.coding_agent import CodingAgent
agent = CodingAgent() agent = CodingAgent()
agent.start_task(task) 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.coding_agent = agent
st.session_state.agent_pending_action = action st.session_state.agent_pending_action = action
st.session_state.agent_status = "waiting_approval" st.session_state.agent_status = "waiting_approval"
@ -19,13 +23,13 @@ def _start_agent(task: str):
def _approve_action(): def _approve_action():
"""Execute the pending action, """Execute the pending action, log it, then immediately propose the next step."""
append it to the log, then propose the next step."""
agent = st.session_state.coding_agent agent = st.session_state.coding_agent
pending = st.session_state.agent_pending_action 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({ st.session_state.agent_log.append({
"thought": pending.get("thought", ""), "thought": pending.get("thought", ""),
"tool": result["tool"], "tool": result["tool"],
@ -34,16 +38,21 @@ def _approve_action():
}) })
if result["is_done"]: if result["is_done"]:
# Agent called the "done" tool — task is fully complete.
st.session_state.agent_status = "done" st.session_state.agent_status = "done"
st.session_state.agent_pending_action = None st.session_state.agent_pending_action = None
else: else:
# More steps needed — get the next proposed action.
next_action = agent.propose_next_action() next_action = agent.propose_next_action()
st.session_state.agent_pending_action = next_action st.session_state.agent_pending_action = next_action
st.session_state.agent_status = "waiting_approval" st.session_state.agent_status = "waiting_approval"
def _reject_action(feedback: str): 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 = st.session_state.coding_agent
agent.reject(feedback or "Please try a different approach.") agent.reject(feedback or "Please try a different approach.")
next_action = agent.propose_next_action() next_action = agent.propose_next_action()
@ -52,7 +61,7 @@ def _reject_action(feedback: str):
def _followup_agent(question: 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 = st.session_state.coding_agent
agent.follow_up(question) agent.follow_up(question)
action = agent.propose_next_action() action = agent.propose_next_action()
@ -61,7 +70,7 @@ def _followup_agent(question: str):
def _reset_agent(): 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.coding_agent = None
st.session_state.agent_status = "idle" st.session_state.agent_status = "idle"
st.session_state.agent_log = [] st.session_state.agent_log = []
@ -71,13 +80,21 @@ def _reset_agent():
# ── Agent Mode UI ───────────────────────────────────────────────────────────── # ── Agent Mode UI ─────────────────────────────────────────────────────────────
def render_agent_mode(): 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") st.toggle("Agent Mode", key="agent_mode")
agent_status = st.session_state.get("agent_status", "idle") agent_status = st.session_state.get("agent_status", "idle")
agent_log = st.session_state.get("agent_log", []) agent_log = st.session_state.get("agent_log", [])
# ── Agent Log ──────────────────────────────────────────────────────────── # ── Agent Log ────────────────────────────────────────────────────────────
# Collapsed by default so it doesn't clutter the UI during active tasks.
if agent_log: if agent_log:
with st.expander(f"Agent Log — {len(agent_log)} step(s) completed", expanded=False): with st.expander(f"Agent Log — {len(agent_log)} step(s) completed", expanded=False):
for i, step in enumerate(agent_log): for i, step in enumerate(agent_log):
@ -87,6 +104,7 @@ def render_agent_mode():
if step.get("arguments"): if step.get("arguments"):
st.json(step["arguments"]) st.json(step["arguments"])
result_text = step.get("result", "") 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"): if result_text.startswith("ERROR") or result_text.startswith("SYNTAX ERROR"):
st.error(result_text) st.error(result_text)
elif result_text.startswith("OK") or result_text.startswith("DONE"): elif result_text.startswith("OK") or result_text.startswith("DONE"):
@ -120,6 +138,8 @@ def render_agent_mode():
args = pending.get("arguments", {}) args = pending.get("arguments", {})
if args: if args:
# Show file content separately as a code block for readability;
# other arguments are displayed as JSON.
if "content" in args: if "content" in args:
display_args = {k: v for k, v in args.items() if k != "content"} display_args = {k: v for k, v in args.items() if k != "content"}
if display_args: if display_args:
@ -182,7 +202,13 @@ def render_agent_mode():
# ── Normal Chat ─────────────────────────────────────────────────────────────── # ── Normal Chat ───────────────────────────────────────────────────────────────
def render_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: for message in st.session_state.chat_history:
role = message["role"] role = message["role"]
if role == "system": if role == "system":
@ -195,16 +221,17 @@ def render_normal_chat():
if user_input: if user_input:
chat_manager = st.session_state.chat_manager 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(): if not chat_manager.get_history():
system_prompt = SystemPrompter.generate_prompt() system_prompt = SystemPrompter.generate_prompt()
chat_manager.add_message("system", system_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"): with st.chat_message("user"):
st.markdown(user_input) 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.chat_message("assistant"):
with st.spinner("Thinking..."): with st.spinner("Thinking..."):
try: try:
@ -227,6 +254,7 @@ def render_normal_chat():
# ── Entry point ─────────────────────────────────────────────────────────────── # ── Entry point ───────────────────────────────────────────────────────────────
def render_chat(): def render_chat():
"""Top-level chat view — switches between Agent Mode and normal chat."""
if st.session_state.get("agent_mode", False): if st.session_state.get("agent_mode", False):
st.subheader("Coding Agent") st.subheader("Coding Agent")
render_agent_mode() 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 as st
import streamlit_ace as st_ace import streamlit_ace as st_ace
from pathlib import Path 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.execution_engine import ExecutionEngine
from backend.managers.debug_logger import DebugLogger from backend.managers.debug_logger import DebugLogger
# Maps file extensions to Ace editor language modes for syntax highlighting.
LANG_MAP = { LANG_MAP = {
".py": "python", ".tex": "latex", ".js": "javascript", ".py": "python", ".tex": "latex", ".js": "javascript",
".html": "html", ".css": "css", ".sh": "bash", ".html": "html", ".css": "css", ".sh": "bash",
@ -17,6 +20,10 @@ LANG_MAP = {
@st.dialog("Rename File") @st.dialog("Rename File")
def _rename_dialog(file_path: str): 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() fm = FileManager()
st.write(f"Current name: **{Path(file_path).name}**") st.write(f"Current name: **{Path(file_path).name}**")
new_name = st.text_input("New name:", value=Path(file_path).stem) 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()): if fm.rename_file(file_path, new_name.strip()):
ext = Path(file_path).suffix ext = Path(file_path).suffix
new_file_path = str(Path(file_path).parent / (Path(new_name.strip()).stem + ext)) 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) i = st.session_state.open_files.index(file_path)
st.session_state.open_files[i] = new_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[new_file_path] = \
st.session_state.files_content.pop(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: if st.session_state.active_file == file_path:
st.session_state.active_file = new_file_path st.session_state.active_file = new_file_path
st.rerun() st.rerun()
@ -48,6 +58,10 @@ def _rename_dialog(file_path: str):
@st.dialog("Delete File") @st.dialog("Delete File")
def _delete_dialog(file_path: str): 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() fm = FileManager()
st.warning(f"Delete **{Path(file_path).name}**? This cannot be undone.") 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): if fm.delete_file(file_path):
st.session_state.open_files.remove(file_path) st.session_state.open_files.remove(file_path)
st.session_state.files_content.pop(file_path, None) 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: if st.session_state.active_file == file_path:
st.session_state.active_file = ( st.session_state.active_file = (
st.session_state.open_files[0] st.session_state.open_files[0]
@ -71,6 +86,10 @@ def _delete_dialog(file_path: str):
def run_active_file(): 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 active_file = st.session_state.active_file
if not active_file: if not active_file:
@ -96,6 +115,7 @@ def run_active_file():
return result return result
def render_editor(): def render_editor():
"""Render the full Code Editor view with tabs, Ace editor, and run output."""
st.subheader("Code Editor") st.subheader("Code Editor")
if not st.session_state.open_files: if not st.session_state.open_files:
@ -105,16 +125,19 @@ def render_editor():
fm = FileManager() fm = FileManager()
# ── Tab bar via st.tabs() ───────────────────────────────────────────────── # ── 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] tab_names = [Path(f).name for f in st.session_state.open_files]
tabs = st.tabs(tab_names) tabs = st.tabs(tab_names)
for idx, file_path in enumerate(st.session_state.open_files): for idx, file_path in enumerate(st.session_state.open_files):
with tabs[idx]: 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: if file_path not in st.session_state.files_content:
st.session_state.files_content[file_path] = fm.read_file(Path(file_path)) st.session_state.files_content[file_path] = fm.read_file(Path(file_path))
file_language = LANG_MAP.get(Path(file_path).suffix, "text") 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( code = st_ace.st_ace(
value=st.session_state.files_content[file_path], value=st.session_state.files_content[file_path],
language=file_language, language=file_language,
@ -124,6 +147,7 @@ def render_editor():
height=400, height=400,
) )
# Keep the in-memory cache in sync with what the editor currently shows.
if code != st.session_state.files_content[file_path]: if code != st.session_state.files_content[file_path]:
st.session_state.files_content[file_path] = code 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}"): if st.button("Close File", key=f"close_{file_path}"):
st.session_state.open_files.remove(file_path) st.session_state.open_files.remove(file_path)
st.session_state.files_content.pop(file_path, None) 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.active_file = (
st.session_state.open_files[0] st.session_state.open_files[0]
if st.session_state.open_files else None if st.session_state.open_files else None
@ -158,6 +183,7 @@ def render_editor():
st.subheader("Execution Output") st.subheader("Execution Output")
# Green on exit code 0 (success), red on anything else (error/crash).
if result["return_code"] == 0: if result["return_code"] == 0:
st.success(f"Exit code: {result['return_code']}") st.success(f"Exit code: {result['return_code']}")
else: else:

View File

@ -1,10 +1,15 @@
"""Sidebar — navigation radio, logo, and the workspace file explorer."""
import streamlit as st import streamlit as st
from streamlit_arborist import tree_view from streamlit_arborist import tree_view
from pathlib import Path from pathlib import Path
from backend.managers.file_manager import FileManager from backend.managers.file_manager import FileManager
# Shared FileManager instance for all sidebar operations.
fm = FileManager() fm = FileManager()
# Maps file extensions (and special keys "folder"/"default") to display emojis
# shown next to each entry in the file tree.
SUFFIX_MAP = { SUFFIX_MAP = {
".py": "🐍", # Python ".py": "🐍", # Python
".js": "🟨", # JavaScript (Gelbes Quadrat/Logo) ".js": "🟨", # JavaScript (Gelbes Quadrat/Logo)
@ -29,11 +34,13 @@ SUFFIX_MAP = {
@st.dialog("Delete Folder") @st.dialog("Delete Folder")
def _delete_folder_dialog(folder_rel: str, folder_name: str): 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.") st.warning(f"Delete **{folder_name}** and all its contents? This cannot be undone.")
col1, col2 = st.columns(2) col1, col2 = st.columns(2)
with col1: with col1:
if st.button("Delete", type="primary", use_container_width=True): if st.button("Delete", type="primary", use_container_width=True):
if fm.delete_folder(folder_rel): 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 = None
st.session_state.selected_folder_rel = None st.session_state.selected_folder_rel = None
st.rerun() st.rerun()
@ -46,6 +53,7 @@ def _delete_folder_dialog(folder_rel: str, folder_name: str):
@st.dialog("Add File") @st.dialog("Add File")
def _add_file_dialog(parent_path: str = ""): 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") name = st.text_input("File name:", placeholder="e.g. script.py")
col1, col2 = st.columns(2) col1, col2 = st.columns(2)
with col1: with col1:
@ -64,6 +72,7 @@ def _add_file_dialog(parent_path: str = ""):
@st.dialog("Add Folder") @st.dialog("Add Folder")
def _add_folder_dialog(parent_path: str = ""): 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") name = st.text_input("Folder name:", placeholder="e.g. utils")
col1, col2 = st.columns(2) col1, col2 = st.columns(2)
with col1: with col1:
@ -83,19 +92,33 @@ def _add_folder_dialog(parent_path: str = ""):
# ── File tree ───────────────────────────────────────────────────────────────── # ── File tree ─────────────────────────────────────────────────────────────────
def build_arborist_tree(tree, parent_path=Path()): 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 = [] nodes = []
for name, content in sorted(tree.items()): for name, content in sorted(tree.items()):
full_path = parent_path / name 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): if isinstance(content, dict):
# Directory — recurse to build child nodes.
nodes.append({ nodes.append({
"id": node_id, "id": node_id,
"name": f"{name}", "name": f"{name}",
"children": build_arborist_tree(content, full_path) "children": build_arborist_tree(content, full_path)
}) })
else: else:
# File — pick an emoji based on extension, fall back to default.
suffix = Path(name).suffix suffix = Path(name).suffix
icon = SUFFIX_MAP.get(suffix, SUFFIX_MAP["default"]) 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): def render_filetree_arborist(tree):
"""Render the interactive file tree and return the currently selected node dict."""
data = build_arborist_tree(tree) data = build_arborist_tree(tree)
selected = tree_view( selected = tree_view(
@ -116,7 +140,7 @@ def render_filetree_arborist(tree):
icons={"open": "📂", "closed": "📁"}, icons={"open": "📂", "closed": "📁"},
height=200, height=200,
selection=None, selection=None,
select_internal_nodes=True, select_internal_nodes=True, # allow clicking folder names, not just files
open_by_default=False open_by_default=False
) )
@ -126,6 +150,12 @@ def render_filetree_arborist(tree):
# ── Sidebar ─────────────────────────────────────────────────────────────────── # ── Sidebar ───────────────────────────────────────────────────────────────────
def render_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") st.sidebar.title("Navigation")
navigation_section = st.sidebar.container() navigation_section = st.sidebar.container()
@ -155,11 +185,14 @@ def render_sidebar():
if selected: if selected:
selected_path = selected.get("id") 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: if st.session_state.last_selected != selected_path:
st.session_state.last_selected = selected_path st.session_state.last_selected = selected_path
abs_path = fm.base_path / selected_path abs_path = fm.base_path / selected_path
if abs_path.is_file(): if abs_path.is_file():
# Open the file in the editor.
st.session_state.selected_folder = None st.session_state.selected_folder = None
st.session_state.selected_folder_rel = None st.session_state.selected_folder_rel = None
file_str = str(abs_path) file_str = str(abs_path)
@ -169,11 +202,13 @@ def render_sidebar():
st.rerun() st.rerun()
elif abs_path.is_dir(): 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 = str(abs_path)
st.session_state.selected_folder_rel = selected_path st.session_state.selected_folder_rel = selected_path
st.rerun() 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"): if st.session_state.get("selected_folder"):
folder_name = Path(st.session_state.selected_folder).name folder_name = Path(st.session_state.selected_folder).name
folder_rel = st.session_state.selected_folder_rel folder_rel = st.session_state.selected_folder_rel
@ -188,6 +223,7 @@ def render_sidebar():
_delete_folder_dialog(folder_rel, folder_name) _delete_folder_dialog(folder_rel, folder_name)
with add_more: 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): 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): if st.button("Add File", key="btn_add_file", use_container_width=True):
_add_file_dialog("") _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 import streamlit as st
from backend.managers.chat_manager import ChatManager from backend.managers.chat_manager import ChatManager
def init_state(): def init_state():
# Sidebar state initialization # Sidebar state initialization
# last_selected tracks the previously clicked tree node to detect new clicks
if "last_selected" not in st.session_state: if "last_selected" not in st.session_state:
st.session_state.last_selected = None 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: if "selected_folder" not in st.session_state:
st.session_state.selected_folder = None st.session_state.selected_folder = None
@ -14,19 +23,24 @@ def init_state():
st.session_state.selected_folder_rel = None st.session_state.selected_folder_rel = None
# Chat manager (persists across reruns) # Chat manager (persists across reruns)
# ChatManager keeps the full conversation history in memory across reruns
if "chat_manager" not in st.session_state: if "chat_manager" not in st.session_state:
st.session_state.chat_manager = ChatManager() st.session_state.chat_manager = ChatManager()
# Editor state initialization # Editor state initialization
# List of absolute file paths that are currently open as tabs
if "open_files" not in st.session_state: if "open_files" not in st.session_state:
st.session_state.open_files = [] st.session_state.open_files = []
# Dict mapping file path → current editor content (may be unsaved)
if "files_content" not in st.session_state: if "files_content" not in st.session_state:
st.session_state.files_content = {} st.session_state.files_content = {}
# Absolute path of the file whose tab is currently active
if "active_file" not in st.session_state: if "active_file" not in st.session_state:
st.session_state.active_file = None st.session_state.active_file = None
# Index of the active tab (used by st.tabs)
if "active_tab" not in st.session_state: if "active_tab" not in st.session_state:
st.session_state.active_tab = 0 st.session_state.active_tab = 0
@ -36,26 +50,33 @@ def init_state():
if "code_suggestions" not in st.session_state: if "code_suggestions" not in st.session_state:
st.session_state.code_suggestions = [] 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: if "code_execution_output" not in st.session_state:
st.session_state.code_execution_output = "" st.session_state.code_execution_output = ""
# Chat state initialization # Chat state initialization
# Flat list of {"role": ..., "content": ...} dicts shown as chat bubbles
if "chat_history" not in st.session_state: if "chat_history" not in st.session_state:
st.session_state.chat_history = [] st.session_state.chat_history = []
# Agent Mode state # Agent Mode state
# Whether the UI is currently in Agent Mode (vs normal chat)
if "agent_mode" not in st.session_state: if "agent_mode" not in st.session_state:
st.session_state.agent_mode = False st.session_state.agent_mode = False
# The live CodingAgent instance while a task is running
if "coding_agent" not in st.session_state: if "coding_agent" not in st.session_state:
st.session_state.coding_agent = None st.session_state.coding_agent = None
# Current status of the agent: "idle" | "waiting_approval" | "done"
if "agent_status" not in st.session_state: if "agent_status" not in st.session_state:
st.session_state.agent_status = "idle" st.session_state.agent_status = "idle"
# List of completed steps shown in the collapsible Agent Log
if "agent_log" not in st.session_state: if "agent_log" not in st.session_state:
st.session_state.agent_log = [] 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: if "agent_pending_action" not in st.session_state:
st.session_state.agent_pending_action = None st.session_state.agent_pending_action = None