Comments im ganzen Projekt
This commit is contained in:
parent
c00a2454fc
commit
eacf8c5137
@ -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 += (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 all collected log messages."""
|
||||
return self.logs
|
||||
@ -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",
|
||||
@ -26,7 +45,7 @@ class ExecutionEngine:
|
||||
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,
|
||||
@ -36,6 +55,7 @@ class ExecutionEngine:
|
||||
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}
|
||||
@ -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,10 +17,14 @@ 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
|
||||
@ -26,6 +37,7 @@ 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
|
||||
@ -41,6 +53,10 @@ class FileManager:
|
||||
return False
|
||||
|
||||
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
|
||||
@ -56,6 +72,7 @@ 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
|
||||
@ -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,6 +99,7 @@ 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 ""
|
||||
@ -94,8 +115,12 @@ 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
|
||||
@ -109,6 +134,10 @@ class FileManager:
|
||||
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
|
||||
@ -116,12 +145,14 @@ class FileManager:
|
||||
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
|
||||
@ -134,8 +165,12 @@ class FileManager:
|
||||
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,8 +188,12 @@ 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
|
||||
@ -170,15 +209,19 @@ 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)
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,6 +86,10 @@ 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:
|
||||
@ -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
|
||||
@ -158,6 +183,7 @@ def render_editor():
|
||||
|
||||
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:
|
||||
|
||||
@ -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("")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user