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:]
|
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 += (
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
@ -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}
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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("")
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user