This change adds a label to the Provider panel to show the count and total size of active Gemini caches when the Gemini provider is selected. This information is hidden for other providers.
2273 lines
90 KiB
Python
2273 lines
90 KiB
Python
# gui.py
|
|
"""
|
|
Note(Gemini):
|
|
The main DearPyGui interface orchestrator.
|
|
This is not a simple UI wrapper; it's a complex state machine that:
|
|
1. Manages background daemon threads for AI requests so the UI doesn't block.
|
|
2. Implements lock-protected comms queues for safe main-thread rendering.
|
|
3. Pauses AI execution to prompt the human for destructive PowerShell script approval.
|
|
"""
|
|
# gui.py
|
|
import dearpygui.dearpygui as dpg
|
|
import tomllib
|
|
import tomli_w
|
|
import threading
|
|
import time
|
|
import math
|
|
import sys
|
|
import os
|
|
from pathlib import Path
|
|
from tkinter import filedialog, Tk
|
|
import aggregate
|
|
import ai_client
|
|
from ai_client import ProviderError
|
|
import shell_runner
|
|
import session_logger
|
|
import project_manager
|
|
import api_hooks
|
|
import theme
|
|
|
|
CONFIG_PATH = Path("config.toml")
|
|
PROVIDERS = ["gemini", "anthropic"]
|
|
|
|
# Max chars shown inline for a heavy comms field before clamping to a scrollable box
|
|
COMMS_CLAMP_CHARS = 300
|
|
|
|
|
|
def load_config() -> dict:
|
|
with open(CONFIG_PATH, "rb") as f:
|
|
return tomllib.load(f)
|
|
|
|
|
|
def save_config(config: dict):
|
|
with open(CONFIG_PATH, "wb") as f:
|
|
tomli_w.dump(config, f)
|
|
|
|
|
|
def hide_tk_root() -> Tk:
|
|
root = Tk()
|
|
root.withdraw()
|
|
root.wm_attributes("-topmost", True)
|
|
return root
|
|
|
|
def get_total_token_usage() -> dict:
|
|
"""Returns aggregated token usage across the entire session from comms log."""
|
|
usage = {
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"cache_read_input_tokens": 0,
|
|
"cache_creation_input_tokens": 0
|
|
}
|
|
for entry in ai_client.get_comms_log():
|
|
if entry.get("kind") == "response" and "usage" in entry.get("payload", {}):
|
|
u = entry["payload"]["usage"]
|
|
for k in usage.keys():
|
|
usage[k] += u.get(k, 0) or 0
|
|
return usage
|
|
|
|
def truncate_entries(entries: list[dict], max_pairs: int) -> list[dict]:
|
|
"""Truncates history to the last N pairs of User/AI messages."""
|
|
if max_pairs <= 0:
|
|
return []
|
|
target_count = max_pairs * 2
|
|
if len(entries) <= target_count:
|
|
return entries
|
|
return entries[-target_count:]
|
|
|
|
|
|
# ------------------------------------------------------------------ comms rendering helpers
|
|
|
|
# Direction -> colour
|
|
_DIR_COLORS = {
|
|
"OUT": (100, 200, 255), # blue-ish
|
|
"IN": (140, 255, 160), # green-ish
|
|
}
|
|
|
|
# Kind -> colour
|
|
_KIND_COLORS = {
|
|
"request": (255, 220, 100),
|
|
"response": (180, 255, 180),
|
|
"tool_call": (255, 180, 80),
|
|
"tool_result": (180, 220, 255),
|
|
"tool_result_send": (200, 180, 255),
|
|
}
|
|
|
|
_HEAVY_KEYS = {"message", "text", "script", "output", "content"}
|
|
|
|
# Label colours used in rich rendering
|
|
_LABEL_COLOR = (180, 180, 180)
|
|
_VALUE_COLOR = (220, 220, 220)
|
|
_KEY_COLOR = (140, 200, 255) # dict key / call index
|
|
_NUM_COLOR = (180, 255, 180) # numbers / token counts
|
|
_SUBHDR_COLOR = (220, 200, 120) # sub-section header
|
|
|
|
|
|
|
|
def _show_text_viewer(title: str, text: str):
|
|
if dpg.does_item_exist("win_text_viewer"):
|
|
wrap = dpg.get_value("project_word_wrap") if dpg.does_item_exist("project_word_wrap") else False
|
|
dpg.configure_item("win_text_viewer", label=f"Text Viewer - {title}", show=True)
|
|
if dpg.does_item_exist("text_viewer_content"):
|
|
dpg.set_value("text_viewer_content", text if text is not None else "")
|
|
dpg.configure_item("text_viewer_content", show=not wrap)
|
|
if dpg.does_item_exist("text_viewer_wrap_container"):
|
|
dpg.set_value("text_viewer_wrap", text if text is not None else "")
|
|
dpg.configure_item("text_viewer_wrap_container", show=wrap)
|
|
dpg.focus_item("win_text_viewer")
|
|
|
|
|
|
def _add_text_field(parent: str, label: str, value: str):
|
|
"""Render a labelled text value; long values get a scrollable box."""
|
|
wrap = dpg.get_value("project_word_wrap") if dpg.does_item_exist("project_word_wrap") else False
|
|
with dpg.group(horizontal=False, parent=parent):
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_text(f"{label}:", color=_LABEL_COLOR)
|
|
dpg.add_button(label="[+]", callback=lambda s, a, u: _show_text_viewer(label, u), user_data=value)
|
|
if len(value) > COMMS_CLAMP_CHARS:
|
|
if wrap:
|
|
with dpg.child_window(height=80, border=True):
|
|
dpg.add_text(value, wrap=0, color=_VALUE_COLOR)
|
|
else:
|
|
dpg.add_input_text(
|
|
default_value=value,
|
|
multiline=True,
|
|
readonly=True,
|
|
width=-1,
|
|
height=80,
|
|
)
|
|
else:
|
|
dpg.add_text(value if value else "(empty)", wrap=0, color=_VALUE_COLOR)
|
|
|
|
|
|
def _add_kv_row(parent: str, key: str, val, val_color=None):
|
|
"""Single key: value row, horizontally laid out."""
|
|
vc = val_color or _VALUE_COLOR
|
|
with dpg.group(horizontal=True, parent=parent):
|
|
dpg.add_text(f"{key}:", color=_LABEL_COLOR)
|
|
dpg.add_text(str(val), color=vc)
|
|
|
|
|
|
def _render_usage(parent: str, usage: dict):
|
|
"""Render Anthropic usage dict as a compact token table."""
|
|
if not usage:
|
|
return
|
|
dpg.add_text("usage:", color=_SUBHDR_COLOR, parent=parent)
|
|
order = [
|
|
"input_tokens",
|
|
"cache_read_input_tokens",
|
|
"cache_creation_input_tokens",
|
|
"output_tokens",
|
|
]
|
|
shown: set = set()
|
|
for key in order:
|
|
if key in usage:
|
|
shown.add(key)
|
|
_add_kv_row(parent, f" {key.replace('_', ' ')}", usage[key], _NUM_COLOR)
|
|
for key, val in usage.items():
|
|
if key not in shown:
|
|
_add_kv_row(parent, f" {key}", val, _NUM_COLOR)
|
|
|
|
|
|
def _render_tool_calls_list(parent: str, tool_calls: list):
|
|
"""Render a list of tool_call dicts inline."""
|
|
if not tool_calls:
|
|
dpg.add_text(" (none)", color=_VALUE_COLOR, parent=parent)
|
|
return
|
|
for i, tc in enumerate(tool_calls):
|
|
dpg.add_text(f" call[{i}] {tc.get('name', '?')}", color=_KEY_COLOR, parent=parent)
|
|
if "id" in tc:
|
|
_add_kv_row(parent, " id", tc["id"])
|
|
args = tc.get("args") or tc.get("input") or {}
|
|
if isinstance(args, dict):
|
|
for ak, av in args.items():
|
|
_add_text_field(parent, f" {ak}", str(av))
|
|
elif args:
|
|
_add_text_field(parent, " args", str(args))
|
|
|
|
|
|
# ---- kind-specific renderers ------------------------------------------------
|
|
|
|
def _render_payload_request(parent: str, payload: dict):
|
|
_add_text_field(parent, "message", payload.get("message", ""))
|
|
|
|
|
|
def _render_payload_response(parent: str, payload: dict):
|
|
_add_kv_row(parent, "round", payload.get("round", ""))
|
|
_add_kv_row(parent, "stop_reason", payload.get("stop_reason", ""), (255, 200, 120))
|
|
text = payload.get("text", "")
|
|
if text:
|
|
_add_text_field(parent, "text", text)
|
|
dpg.add_text("tool_calls:", color=_LABEL_COLOR, parent=parent)
|
|
_render_tool_calls_list(parent, payload.get("tool_calls", []))
|
|
usage = payload.get("usage")
|
|
if usage:
|
|
_render_usage(parent, usage)
|
|
|
|
|
|
def _render_payload_tool_call(parent: str, payload: dict):
|
|
_add_kv_row(parent, "name", payload.get("name", ""))
|
|
if "id" in payload:
|
|
_add_kv_row(parent, "id", payload["id"])
|
|
# PowerShell tool uses 'script'; MCP file tools use 'args' dict
|
|
if "script" in payload:
|
|
_add_text_field(parent, "script", payload.get("script", ""))
|
|
elif "args" in payload:
|
|
args = payload["args"]
|
|
if isinstance(args, dict):
|
|
for ak, av in args.items():
|
|
_add_text_field(parent, ak, str(av))
|
|
else:
|
|
_add_text_field(parent, "args", str(args))
|
|
|
|
|
|
def _render_payload_tool_result(parent: str, payload: dict):
|
|
_add_kv_row(parent, "name", payload.get("name", ""))
|
|
if "id" in payload:
|
|
_add_kv_row(parent, "id", payload["id"])
|
|
_add_text_field(parent, "output", payload.get("output", ""))
|
|
|
|
|
|
def _render_payload_tool_result_send(parent: str, payload: dict):
|
|
for i, r in enumerate(payload.get("results", [])):
|
|
dpg.add_text(f"result[{i}]", color=_KEY_COLOR, parent=parent)
|
|
_add_kv_row(parent, " tool_use_id", r.get("tool_use_id", ""))
|
|
_add_text_field(parent, " content", str(r.get("content", "")))
|
|
|
|
|
|
def _render_payload_generic(parent: str, payload: dict):
|
|
"""Fallback: render any unknown payload kind as labelled fields."""
|
|
import json
|
|
for key, val in payload.items():
|
|
if isinstance(val, (dict, list)):
|
|
val_str = json.dumps(val, ensure_ascii=False, indent=2)
|
|
else:
|
|
val_str = str(val)
|
|
if key in _HEAVY_KEYS:
|
|
_add_text_field(parent, key, val_str)
|
|
else:
|
|
_add_kv_row(parent, key, val_str)
|
|
|
|
|
|
_KIND_RENDERERS = {
|
|
"request": _render_payload_request,
|
|
"response": _render_payload_response,
|
|
"tool_call": _render_payload_tool_call,
|
|
"tool_result": _render_payload_tool_result,
|
|
"tool_result_send": _render_payload_tool_result_send,
|
|
}
|
|
|
|
|
|
def _render_comms_entry(parent: str, entry: dict, idx: int):
|
|
direction = entry["direction"]
|
|
kind = entry["kind"]
|
|
ts = entry["ts"]
|
|
provider = entry["provider"]
|
|
model = entry["model"]
|
|
payload = entry["payload"]
|
|
|
|
dir_color = _DIR_COLORS.get(direction, (220, 220, 220))
|
|
kind_color = _KIND_COLORS.get(kind, (220, 220, 220))
|
|
|
|
with dpg.group(horizontal=False, parent=parent):
|
|
# Header row
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_text(f"#{idx}", color=(160, 160, 160))
|
|
dpg.add_text(ts, color=(160, 160, 160))
|
|
dpg.add_text(direction, color=dir_color)
|
|
dpg.add_text(kind, color=kind_color)
|
|
dpg.add_text(f"{provider}/{model}", color=(180, 180, 180))
|
|
|
|
# Payload - use rich renderer if available, else generic fallback
|
|
renderer = _KIND_RENDERERS.get(kind, _render_payload_generic)
|
|
renderer(parent, payload)
|
|
|
|
dpg.add_separator()
|
|
|
|
|
|
class ConfirmDialog:
|
|
"""
|
|
Modal confirmation window for a proposed PowerShell script.
|
|
Background thread calls wait(), which blocks on a threading.Event.
|
|
Main render loop detects _pending_dialog and calls show() on the next frame.
|
|
User clicks Approve or Reject, which sets the event and unblocks the thread.
|
|
"""
|
|
|
|
_next_id = 0
|
|
|
|
def __init__(self, script: str, base_dir: str):
|
|
ConfirmDialog._next_id += 1
|
|
self._uid = ConfirmDialog._next_id
|
|
self._tag = f"confirm_dlg_{self._uid}"
|
|
# Cast to str to ensure DPG doesn't crash on None or weird objects
|
|
self._script = str(script) if script is not None else ""
|
|
self._base_dir = str(base_dir) if base_dir is not None else ""
|
|
self._event = threading.Event()
|
|
self._approved = False
|
|
|
|
def show(self):
|
|
"""Called from main thread only. Wrapped in try/except to prevent thread lockups."""
|
|
try:
|
|
w, h = 700, 480
|
|
vp_w = dpg.get_viewport_width()
|
|
vp_h = dpg.get_viewport_height()
|
|
px = max(0, (vp_w - w) // 2)
|
|
py = max(0, (vp_h - h) // 2)
|
|
|
|
with dpg.window(
|
|
label=f"Approve PowerShell Command #{self._uid}",
|
|
tag=self._tag,
|
|
modal=True,
|
|
no_close=True,
|
|
pos=(px, py),
|
|
width=w,
|
|
height=h,
|
|
):
|
|
dpg.add_text("The AI wants to run the following PowerShell script:")
|
|
dpg.add_text(f"base_dir: {self._base_dir}", color=(200, 200, 100))
|
|
dpg.add_separator()
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_text("Script:")
|
|
dpg.add_button(
|
|
label="[+ Maximize]",
|
|
user_data=self._script,
|
|
callback=lambda s, a, u: _show_text_viewer("Confirm Script", u)
|
|
)
|
|
dpg.add_input_text(
|
|
tag=f"{self._tag}_script",
|
|
default_value=self._script,
|
|
multiline=True,
|
|
width=-1,
|
|
height=-72,
|
|
readonly=False,
|
|
)
|
|
dpg.add_separator()
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_button(label="Approve & Run", callback=self._cb_approve)
|
|
dpg.add_button(label="Reject", callback=self._cb_reject)
|
|
|
|
dpg.focus_item(self._tag)
|
|
except Exception as e:
|
|
print(f"ERROR rendering ConfirmDialog: {e}")
|
|
self._approved = False
|
|
self._event.set()
|
|
|
|
def _cb_approve(self):
|
|
try:
|
|
self._script = dpg.get_value(f"{self._tag}_script")
|
|
except Exception:
|
|
pass
|
|
self._approved = True
|
|
self._event.set()
|
|
try:
|
|
dpg.delete_item(self._tag)
|
|
except Exception:
|
|
pass
|
|
|
|
def _cb_reject(self):
|
|
self._approved = False
|
|
self._event.set()
|
|
try:
|
|
dpg.delete_item(self._tag)
|
|
except Exception:
|
|
pass
|
|
|
|
def wait(self) -> tuple[bool, str]:
|
|
"""Called from background thread. Blocks until user acts."""
|
|
self._event.wait()
|
|
return self._approved, self._script
|
|
|
|
|
|
DISC_ROLES = ["User", "AI", "Vendor API", "System"]
|
|
|
|
|
|
def _parse_history_entries(history: list[str], roles: list[str] | None = None) -> list[dict]:
|
|
"""
|
|
Convert the raw TOML string array into a flat list of {role, content, collapsed, ts} dicts.
|
|
Supports both legacy format (no timestamps) and new format (@timestamp prefix).
|
|
"""
|
|
known = roles if roles is not None else DISC_ROLES
|
|
entries: list[dict] = []
|
|
for raw in history:
|
|
entry = project_manager.str_to_entry(raw, known)
|
|
entries.append(entry)
|
|
return entries
|
|
|
|
|
|
class App:
|
|
def __init__(self):
|
|
self.config = load_config()
|
|
# Controls whether API hooks are enabled, based on CLI arg or env var
|
|
self.test_hooks_enabled: bool = (
|
|
'--enable-test-hooks' in sys.argv or
|
|
os.environ.get('SLOP_TEST_HOOKS') == '1')
|
|
# The API hook server instance
|
|
self.hook_server: api_hooks.HookServer = api_hooks.HookServer(self)
|
|
|
|
# ---- global settings from config.toml ----
|
|
ai_cfg = self.config.get("ai", {})
|
|
self.current_provider: str = ai_cfg.get("provider", "gemini")
|
|
self.current_model: str = ai_cfg.get("model", "gemini-2.5-flash")
|
|
self.temperature: float = ai_cfg.get("temperature", 0.0)
|
|
self.max_tokens: int = ai_cfg.get("max_tokens", 8192)
|
|
self.history_trunc_limit: int = ai_cfg.get("history_trunc_limit", 8000)
|
|
self.available_models: list[str] = []
|
|
|
|
# ---- project management ----
|
|
projects_cfg = self.config.get("projects", {})
|
|
self.project_paths: list[str] = list(projects_cfg.get("paths", []))
|
|
self.active_project_path: str = projects_cfg.get("active", "")
|
|
|
|
# The loaded project dict (from the active .toml file)
|
|
self.project: dict = {}
|
|
# The active discussion name within the project
|
|
self.active_discussion: str = "main"
|
|
|
|
# Load the active project, or migrate from legacy config
|
|
self._load_active_project()
|
|
|
|
# ---- project-derived state ----
|
|
self.files: list[str] = list(self.project.get("files", {}).get("paths", []))
|
|
self.screenshots: list[str] = list(self.project.get("screenshots", {}).get("paths", []))
|
|
|
|
disc_sec = self.project.get("discussion", {})
|
|
self.disc_roles: list[str] = list(disc_sec.get("roles", list(DISC_ROLES)))
|
|
self.active_discussion = disc_sec.get("active", "main")
|
|
|
|
disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {})
|
|
history_strings = disc_data.get("history", [])
|
|
self.disc_entries: list[dict] = _parse_history_entries(history_strings, self.disc_roles)
|
|
|
|
self.ai_status = "idle"
|
|
self.ai_response = ""
|
|
self.last_md = ""
|
|
self.last_md_path: Path | None = None
|
|
self.last_file_items: list = []
|
|
self.send_thread: threading.Thread | None = None
|
|
self.models_thread: threading.Thread | None = None
|
|
self.window_info = {
|
|
"Projects": "win_projects",
|
|
"Files": "win_files",
|
|
"Screenshots": "win_screenshots",
|
|
"Discussion History": "win_discussion",
|
|
"Provider": "win_provider",
|
|
"Message": "win_message",
|
|
"Response": "win_response",
|
|
"Tool Calls": "win_tool_log",
|
|
"Comms History": "win_comms",
|
|
"System Prompts": "win_system_prompts",
|
|
"Theme": "win_theme",
|
|
"Last Script Output": "win_script_output",
|
|
"Text Viewer": "win_text_viewer",
|
|
}
|
|
|
|
|
|
self._pending_dialog: ConfirmDialog | None = None
|
|
self._pending_dialog_lock = threading.Lock()
|
|
|
|
self._tool_log: list[tuple[str, str]] = []
|
|
self._last_script: str = ""
|
|
self._last_output: str = ""
|
|
|
|
# Comms log entries queued from background thread for main-thread rendering
|
|
self._pending_comms: list[dict] = []
|
|
self._pending_comms_lock = threading.Lock()
|
|
self._comms_entry_count = 0
|
|
|
|
# Auto-history queues
|
|
self._pending_history_adds: list[dict] = []
|
|
self._pending_history_adds_lock = threading.Lock()
|
|
|
|
# API GUI Hooks Queue
|
|
# Tasks (e.g., set_value, click) to be executed on the main DPG thread
|
|
self._pending_gui_tasks: list[dict] = []
|
|
# Lock for _pending_gui_tasks to ensure thread safety
|
|
self._pending_gui_tasks_lock = threading.Lock()
|
|
|
|
# Blink state
|
|
self._trigger_blink = False
|
|
self._is_blinking = False
|
|
self._blink_start_time = 0.0
|
|
|
|
# Script Blink State
|
|
self._trigger_script_blink = False
|
|
self._is_script_blinking = False
|
|
self._script_blink_start_time = 0.0
|
|
|
|
session_logger.open_session()
|
|
ai_client.set_provider(self.current_provider, self.current_model)
|
|
ai_client.confirm_and_run_callback = self._confirm_and_run
|
|
ai_client.comms_log_callback = self._on_comms_entry
|
|
ai_client.tool_log_callback = self._on_tool_log
|
|
|
|
# ---------------------------------------------------------------- project loading
|
|
|
|
def _load_active_project(self):
|
|
"""
|
|
Load the active project .toml. If no project paths configured or
|
|
active path is missing, attempt migration from legacy config.toml.
|
|
"""
|
|
# Try to load from the active path
|
|
if self.active_project_path and Path(self.active_project_path).exists():
|
|
try:
|
|
self.project = project_manager.load_project(self.active_project_path)
|
|
return
|
|
except Exception as e:
|
|
print(f"Failed to load project {self.active_project_path}: {e}")
|
|
|
|
# Try first available project path
|
|
for pp in self.project_paths:
|
|
if Path(pp).exists():
|
|
try:
|
|
self.project = project_manager.load_project(pp)
|
|
self.active_project_path = pp
|
|
return
|
|
except Exception:
|
|
continue
|
|
|
|
# No valid project file found - migrate from legacy config.toml
|
|
self.project = project_manager.migrate_from_legacy_config(self.config)
|
|
name = self.project.get("project", {}).get("name", "project")
|
|
fallback_path = f"{name}.toml"
|
|
project_manager.save_project(self.project, fallback_path)
|
|
self.active_project_path = fallback_path
|
|
if fallback_path not in self.project_paths:
|
|
self.project_paths.append(fallback_path)
|
|
|
|
def _switch_project(self, path: str):
|
|
"""Switch to a different project .toml file."""
|
|
if not Path(path).exists():
|
|
self._update_status(f"project file not found: {path}")
|
|
return
|
|
|
|
# Save current project first
|
|
self._flush_to_project()
|
|
self._save_active_project()
|
|
|
|
# Load the new one
|
|
try:
|
|
self.project = project_manager.load_project(path)
|
|
self.active_project_path = path
|
|
except Exception as e:
|
|
self._update_status(f"failed to load project: {e}")
|
|
return
|
|
|
|
# Refresh all project-derived state
|
|
self._refresh_from_project()
|
|
|
|
# Reset AI session since context changed
|
|
ai_client.reset_session()
|
|
self.cb_clear_tool_log()
|
|
self.cb_clear_comms()
|
|
self._update_response("")
|
|
self._update_status(f"switched to: {Path(path).stem}")
|
|
|
|
def _refresh_from_project(self):
|
|
"""Reload all GUI state from self.project after a project switch or discussion switch."""
|
|
self.files = list(self.project.get("files", {}).get("paths", []))
|
|
self.screenshots = list(self.project.get("screenshots", {}).get("paths", []))
|
|
|
|
disc_sec = self.project.get("discussion", {})
|
|
self.disc_roles = list(disc_sec.get("roles", list(DISC_ROLES)))
|
|
self.active_discussion = disc_sec.get("active", "main")
|
|
|
|
disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {})
|
|
history_strings = disc_data.get("history", [])
|
|
self.disc_entries = _parse_history_entries(history_strings, self.disc_roles)
|
|
|
|
# Update all GUI widgets
|
|
self._refresh_project_widgets()
|
|
self._rebuild_files_list()
|
|
self._rebuild_shots_list()
|
|
self._rebuild_disc_list()
|
|
self._rebuild_disc_roles_list()
|
|
self._rebuild_discussion_selector()
|
|
|
|
def _refresh_project_widgets(self):
|
|
"""Push project-level values into the GUI widgets."""
|
|
proj = self.project
|
|
if dpg.does_item_exist("output_dir"):
|
|
dpg.set_value("output_dir", proj.get("output", {}).get("output_dir", "./md_gen"))
|
|
if dpg.does_item_exist("files_base_dir"):
|
|
dpg.set_value("files_base_dir", proj.get("files", {}).get("base_dir", "."))
|
|
if dpg.does_item_exist("shots_base_dir"):
|
|
dpg.set_value("shots_base_dir", proj.get("screenshots", {}).get("base_dir", "."))
|
|
if dpg.does_item_exist("project_name_text"):
|
|
name = proj.get("project", {}).get("name", Path(self.active_project_path).stem)
|
|
dpg.set_value("project_name_text", f"Active: {name}")
|
|
if dpg.does_item_exist("project_git_dir"):
|
|
dpg.set_value("project_git_dir", proj.get("project", {}).get("git_dir", ""))
|
|
if dpg.does_item_exist("project_system_prompt"):
|
|
dpg.set_value("project_system_prompt", proj.get("project", {}).get("system_prompt", ""))
|
|
if dpg.does_item_exist("project_main_context"):
|
|
dpg.set_value("project_main_context", proj.get("project", {}).get("main_context", ""))
|
|
if dpg.does_item_exist("auto_add_history"):
|
|
dpg.set_value("auto_add_history", proj.get("discussion", {}).get("auto_add", False))
|
|
if dpg.does_item_exist("project_word_wrap"):
|
|
dpg.set_value("project_word_wrap", proj.get("project", {}).get("word_wrap", True))
|
|
|
|
agent_tools = proj.get("agent", {}).get("tools", {})
|
|
for t_name in ["run_powershell", "read_file", "list_directory", "search_files", "get_file_summary", "web_search", "fetch_url"]:
|
|
tag = f"tool_toggle_{t_name}"
|
|
if dpg.does_item_exist(tag):
|
|
dpg.set_value(tag, agent_tools.get(t_name, True))
|
|
|
|
self.cb_word_wrap_toggled(app_data=proj.get("project", {}).get("word_wrap", True))
|
|
|
|
def _save_active_project(self):
|
|
"""Write self.project to the active project .toml file."""
|
|
if self.active_project_path:
|
|
try:
|
|
project_manager.save_project(self.project, self.active_project_path)
|
|
except Exception as e:
|
|
self._update_status(f"save error: {e}")
|
|
|
|
# ---------------------------------------------------------------- discussion management
|
|
|
|
def _get_discussion_names(self) -> list[str]:
|
|
"""Return sorted list of discussion names in the active project."""
|
|
disc_sec = self.project.get("discussion", {})
|
|
discussions = disc_sec.get("discussions", {})
|
|
return sorted(discussions.keys())
|
|
|
|
def _switch_discussion(self, name: str):
|
|
"""Save current discussion entries, then switch to a different one."""
|
|
# Save current entries into project
|
|
self._flush_disc_entries_to_project()
|
|
|
|
disc_sec = self.project.get("discussion", {})
|
|
discussions = disc_sec.get("discussions", {})
|
|
if name not in discussions:
|
|
self._update_status(f"discussion not found: {name}")
|
|
return
|
|
|
|
self.active_discussion = name
|
|
disc_sec["active"] = name
|
|
|
|
disc_data = discussions[name]
|
|
history_strings = disc_data.get("history", [])
|
|
self.disc_entries = _parse_history_entries(history_strings, self.disc_roles)
|
|
|
|
self._rebuild_disc_list()
|
|
self._rebuild_discussion_selector()
|
|
self._update_status(f"discussion: {name}")
|
|
|
|
def _flush_disc_entries_to_project(self):
|
|
"""Serialize current disc_entries back into the active discussion in self.project."""
|
|
# Pull latest content from widgets
|
|
for i, entry in enumerate(self.disc_entries):
|
|
tag = f"disc_content_{i}"
|
|
if dpg.does_item_exist(tag):
|
|
entry["content"] = dpg.get_value(tag)
|
|
|
|
history_strings = [project_manager.entry_to_str(e) for e in self.disc_entries]
|
|
|
|
disc_sec = self.project.setdefault("discussion", {})
|
|
discussions = disc_sec.setdefault("discussions", {})
|
|
disc_data = discussions.setdefault(self.active_discussion, project_manager.default_discussion())
|
|
disc_data["history"] = history_strings
|
|
disc_data["last_updated"] = project_manager.now_ts()
|
|
|
|
def _create_discussion(self, name: str):
|
|
"""Create a new empty discussion in the active project."""
|
|
disc_sec = self.project.setdefault("discussion", {})
|
|
discussions = disc_sec.setdefault("discussions", {})
|
|
if name in discussions:
|
|
self._update_status(f"discussion '{name}' already exists")
|
|
return
|
|
discussions[name] = project_manager.default_discussion()
|
|
self._switch_discussion(name)
|
|
|
|
def _rename_discussion(self, old_name: str, new_name: str):
|
|
"""Rename a discussion."""
|
|
disc_sec = self.project.get("discussion", {})
|
|
discussions = disc_sec.get("discussions", {})
|
|
if old_name not in discussions:
|
|
return
|
|
if new_name in discussions:
|
|
self._update_status(f"discussion '{new_name}' already exists")
|
|
return
|
|
discussions[new_name] = discussions.pop(old_name)
|
|
if self.active_discussion == old_name:
|
|
self.active_discussion = new_name
|
|
disc_sec["active"] = new_name
|
|
self._rebuild_discussion_selector()
|
|
|
|
def _delete_discussion(self, name: str):
|
|
"""Delete a discussion. Cannot delete the last one."""
|
|
disc_sec = self.project.get("discussion", {})
|
|
discussions = disc_sec.get("discussions", {})
|
|
if len(discussions) <= 1:
|
|
self._update_status("cannot delete the last discussion")
|
|
return
|
|
if name not in discussions:
|
|
return
|
|
del discussions[name]
|
|
if self.active_discussion == name:
|
|
# Switch to the first remaining discussion
|
|
remaining = sorted(discussions.keys())
|
|
self._switch_discussion(remaining[0])
|
|
else:
|
|
self._rebuild_discussion_selector()
|
|
|
|
def _update_discussion_git_commit(self):
|
|
"""Update the git commit hash on the active discussion."""
|
|
git_dir = self.project.get("project", {}).get("git_dir", "")
|
|
if not git_dir:
|
|
git_dir = dpg.get_value("project_git_dir") if dpg.does_item_exist("project_git_dir") else ""
|
|
if not git_dir:
|
|
self._update_status("no git_dir configured")
|
|
return
|
|
commit = project_manager.get_git_commit(git_dir)
|
|
if not commit:
|
|
self._update_status("could not read git commit")
|
|
return
|
|
disc_sec = self.project.get("discussion", {})
|
|
discussions = disc_sec.get("discussions", {})
|
|
disc_data = discussions.get(self.active_discussion, {})
|
|
disc_data["git_commit"] = commit
|
|
disc_data["last_updated"] = project_manager.now_ts()
|
|
self._rebuild_discussion_selector()
|
|
self._update_status(f"commit: {commit[:12]}")
|
|
|
|
def _queue_history_add(self, role: str, content: str):
|
|
"""Safely queue a new history entry from a background thread."""
|
|
with self._pending_history_adds_lock:
|
|
self._pending_history_adds.append({
|
|
"role": role,
|
|
"content": content,
|
|
"collapsed": False,
|
|
"ts": project_manager.now_ts()
|
|
})
|
|
|
|
# ---------------------------------------------------------------- comms log
|
|
|
|
def _on_comms_entry(self, entry: dict):
|
|
"""Called from background thread; queue for main thread."""
|
|
session_logger.log_comms(entry)
|
|
with self._pending_comms_lock:
|
|
self._pending_comms.append(entry)
|
|
|
|
def _on_tool_log(self, script: str, result: str):
|
|
"""Called from background thread when a tool call completes."""
|
|
session_logger.log_tool_call(script, result, None)
|
|
|
|
def _flush_pending_comms(self):
|
|
"""Called every frame from the main render loop."""
|
|
with self._pending_comms_lock:
|
|
entries = self._pending_comms[:]
|
|
self._pending_comms.clear()
|
|
for entry in entries:
|
|
self._comms_entry_count += 1
|
|
self._append_comms_entry(entry, self._comms_entry_count)
|
|
if entries:
|
|
self._update_token_usage()
|
|
|
|
def _update_token_usage(self):
|
|
if not dpg.does_item_exist("ai_token_usage"):
|
|
return
|
|
usage = get_total_token_usage()
|
|
total = usage["input_tokens"] + usage["output_tokens"]
|
|
dpg.set_value("ai_token_usage", f"Tokens: {total} (In: {usage['input_tokens']} Out: {usage['output_tokens']})")
|
|
|
|
def _update_telemetry_panel(self):
|
|
"""Updates the token budget visualizer in the Provider panel."""
|
|
# Update history bleed stats for all providers
|
|
stats = ai_client.get_history_bleed_stats()
|
|
if dpg.does_item_exist("token_budget_bar"):
|
|
percentage = stats.get("percentage", 0.0)
|
|
dpg.set_value("token_budget_bar", percentage / 100.0 if percentage else 0.0)
|
|
if dpg.does_item_exist("token_budget_label"):
|
|
current = stats.get("current", 0)
|
|
limit = stats.get("limit", 0)
|
|
dpg.set_value("token_budget_label", f"{current:,} / {limit:,}")
|
|
|
|
# Update Gemini-specific cache stats
|
|
if dpg.does_item_exist("gemini_cache_label"):
|
|
if self.current_provider == "gemini":
|
|
try:
|
|
cache_stats = ai_client.get_gemini_cache_stats()
|
|
count = cache_stats.get("cache_count", 0)
|
|
size_bytes = cache_stats.get("total_size_bytes", 0)
|
|
size_kb = size_bytes / 1024.0
|
|
text = f"Gemini Caches: {count} ({size_kb:.1f} KB)"
|
|
dpg.set_value("gemini_cache_label", text)
|
|
dpg.configure_item("gemini_cache_label", show=True)
|
|
except Exception as e:
|
|
# If the API call fails, just hide the label
|
|
dpg.configure_item("gemini_cache_label", show=False)
|
|
else:
|
|
dpg.configure_item("gemini_cache_label", show=False)
|
|
|
|
def _append_comms_entry(self, entry: dict, idx: int):
|
|
if not dpg.does_item_exist("comms_scroll"):
|
|
return
|
|
_render_comms_entry("comms_scroll", entry, idx)
|
|
|
|
def _rebuild_comms_log(self):
|
|
"""Full redraw from ai_client.get_comms_log() - used after clear/reset."""
|
|
if not dpg.does_item_exist("comms_scroll"):
|
|
return
|
|
dpg.delete_item("comms_scroll", children_only=True)
|
|
self._comms_entry_count = 0
|
|
for entry in ai_client.get_comms_log():
|
|
self._comms_entry_count += 1
|
|
_render_comms_entry("comms_scroll", entry, self._comms_entry_count)
|
|
|
|
# ---------------------------------------------------------------- tool execution
|
|
|
|
def _confirm_and_run(self, script: str, base_dir: str) -> str | None:
|
|
dialog = ConfirmDialog(script, base_dir)
|
|
|
|
with self._pending_dialog_lock:
|
|
self._pending_dialog = dialog
|
|
|
|
approved, final_script = dialog.wait()
|
|
|
|
if not approved:
|
|
self._append_tool_log(final_script, "REJECTED by user")
|
|
return None
|
|
|
|
self._update_status("running powershell...")
|
|
output = shell_runner.run_powershell(final_script, base_dir)
|
|
self._append_tool_log(final_script, output)
|
|
self._update_status("powershell done, awaiting AI...")
|
|
return output
|
|
|
|
def _append_tool_log(self, script: str, result: str):
|
|
self._last_script = script
|
|
self._last_output = result
|
|
self._tool_log.append((script, result))
|
|
self._rebuild_tool_log()
|
|
|
|
if dpg.does_item_exist("last_script_text"):
|
|
dpg.set_value("last_script_text", script)
|
|
if dpg.does_item_exist("last_script_text_wrap"):
|
|
dpg.set_value("last_script_text_wrap", script)
|
|
|
|
if dpg.does_item_exist("last_script_output"):
|
|
dpg.set_value("last_script_output", result)
|
|
if dpg.does_item_exist("last_script_output_wrap"):
|
|
dpg.set_value("last_script_output_wrap", result)
|
|
|
|
self._trigger_script_blink = True
|
|
|
|
def _rebuild_tool_log(self):
|
|
if not dpg.does_item_exist("tool_log_scroll"):
|
|
return
|
|
wrap = dpg.get_value("project_word_wrap") if dpg.does_item_exist("project_word_wrap") else False
|
|
dpg.delete_item("tool_log_scroll", children_only=True)
|
|
for i, (script, result) in enumerate(self._tool_log, 1):
|
|
with dpg.group(parent="tool_log_scroll"):
|
|
first_line = script.strip().splitlines()[0][:80] if script.strip() else "(empty)"
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_text(f"Call #{i}: {first_line}", color=(140, 200, 255))
|
|
dpg.add_button(
|
|
label="[+ Script]",
|
|
user_data=script,
|
|
callback=lambda s, a, u: _show_text_viewer("Call Script", u)
|
|
)
|
|
dpg.add_button(
|
|
label="[+ Output]",
|
|
user_data=result,
|
|
callback=lambda s, a, u: _show_text_viewer("Call Output", u)
|
|
)
|
|
if wrap:
|
|
with dpg.child_window(height=72, border=True):
|
|
dpg.add_text(result, wrap=0)
|
|
else:
|
|
dpg.add_input_text(
|
|
default_value=result,
|
|
multiline=True,
|
|
readonly=True,
|
|
width=-1,
|
|
height=72,
|
|
)
|
|
dpg.add_separator()
|
|
|
|
# ---------------------------------------------------------------- helpers
|
|
|
|
def _flush_to_project(self):
|
|
"""Pull all widget values into self.project (the active project dict)."""
|
|
proj = self.project
|
|
|
|
# Output
|
|
proj.setdefault("output", {})
|
|
if dpg.does_item_exist("output_dir"):
|
|
proj["output"]["output_dir"] = dpg.get_value("output_dir")
|
|
|
|
# Files
|
|
proj.setdefault("files", {})
|
|
if dpg.does_item_exist("files_base_dir"):
|
|
proj["files"]["base_dir"] = dpg.get_value("files_base_dir")
|
|
proj["files"]["paths"] = self.files
|
|
|
|
# Screenshots
|
|
proj.setdefault("screenshots", {})
|
|
if dpg.does_item_exist("shots_base_dir"):
|
|
proj["screenshots"]["base_dir"] = dpg.get_value("shots_base_dir")
|
|
proj["screenshots"]["paths"] = self.screenshots
|
|
|
|
# Project metadata
|
|
proj.setdefault("project", {})
|
|
if dpg.does_item_exist("project_git_dir"):
|
|
proj["project"]["git_dir"] = dpg.get_value("project_git_dir")
|
|
if dpg.does_item_exist("project_system_prompt"):
|
|
proj["project"]["system_prompt"] = dpg.get_value("project_system_prompt")
|
|
if dpg.does_item_exist("project_main_context"):
|
|
proj["project"]["main_context"] = dpg.get_value("project_main_context")
|
|
if dpg.does_item_exist("project_word_wrap"):
|
|
proj["project"]["word_wrap"] = dpg.get_value("project_word_wrap")
|
|
|
|
# Agent tools
|
|
proj.setdefault("agent", {}).setdefault("tools", {})
|
|
for t_name in ["run_powershell", "read_file", "list_directory", "search_files", "get_file_summary", "web_search", "fetch_url"]:
|
|
tag = f"tool_toggle_{t_name}"
|
|
if dpg.does_item_exist(tag):
|
|
proj["agent"]["tools"][t_name] = dpg.get_value(tag)
|
|
|
|
# Discussion
|
|
self._flush_disc_entries_to_project()
|
|
disc_sec = proj.setdefault("discussion", {})
|
|
disc_sec["roles"] = self.disc_roles
|
|
disc_sec["active"] = self.active_discussion
|
|
if dpg.does_item_exist("auto_add_history"):
|
|
disc_sec["auto_add"] = dpg.get_value("auto_add_history")
|
|
|
|
def _flush_to_config(self):
|
|
"""Pull global settings into self.config (config.toml)."""
|
|
self.config["ai"] = {
|
|
"provider": self.current_provider,
|
|
"model": self.current_model,
|
|
"temperature": dpg.get_value("ai_temperature") if dpg.does_item_exist("ai_temperature") else self.temperature,
|
|
"max_tokens": dpg.get_value("ai_max_tokens") if dpg.does_item_exist("ai_max_tokens") else self.max_tokens,
|
|
"history_trunc_limit": dpg.get_value("ai_history_trunc") if dpg.does_item_exist("ai_history_trunc") else self.history_trunc_limit,
|
|
}
|
|
if dpg.does_item_exist("global_system_prompt"):
|
|
self.config["ai"]["system_prompt"] = dpg.get_value("global_system_prompt")
|
|
self.config["projects"] = {
|
|
"paths": self.project_paths,
|
|
"active": self.active_project_path,
|
|
}
|
|
theme.save_to_config(self.config)
|
|
|
|
def _do_generate(self) -> tuple[str, Path, list]:
|
|
self._flush_to_project()
|
|
self._save_active_project()
|
|
self._flush_to_config()
|
|
save_config(self.config)
|
|
flat = project_manager.flat_config(self.project, self.active_discussion)
|
|
return aggregate.run(flat)
|
|
|
|
def _update_status(self, status: str):
|
|
self.ai_status = status
|
|
if dpg.does_item_exist("ai_status"):
|
|
dpg.set_value("ai_status", f"Status: {status}")
|
|
|
|
def _update_response(self, text: str):
|
|
self.ai_response = text
|
|
if dpg.does_item_exist("ai_response"):
|
|
dpg.set_value("ai_response", text)
|
|
if dpg.does_item_exist("ai_response_wrap"):
|
|
dpg.set_value("ai_response_wrap", text)
|
|
|
|
def _rebuild_files_list(self):
|
|
if not dpg.does_item_exist("files_scroll"):
|
|
return
|
|
dpg.delete_item("files_scroll", children_only=True)
|
|
for i, f in enumerate(self.files):
|
|
with dpg.group(horizontal=True, parent="files_scroll"):
|
|
dpg.add_button(
|
|
label="x", width=24, callback=self._make_remove_file_cb(i)
|
|
)
|
|
dpg.add_text(f)
|
|
|
|
def _rebuild_shots_list(self):
|
|
if not dpg.does_item_exist("shots_scroll"):
|
|
return
|
|
dpg.delete_item("shots_scroll", children_only=True)
|
|
for i, s in enumerate(self.screenshots):
|
|
with dpg.group(horizontal=True, parent="shots_scroll"):
|
|
dpg.add_button(
|
|
label="x", width=24, callback=self._make_remove_shot_cb(i)
|
|
)
|
|
dpg.add_text(s)
|
|
|
|
def _rebuild_models_list(self):
|
|
if not dpg.does_item_exist("model_listbox"):
|
|
return
|
|
dpg.configure_item("model_listbox", items=self.available_models)
|
|
if self.current_model in self.available_models:
|
|
dpg.set_value("model_listbox", self.current_model)
|
|
elif self.available_models:
|
|
self.current_model = self.available_models[0]
|
|
dpg.set_value("model_listbox", self.current_model)
|
|
ai_client.set_provider(self.current_provider, self.current_model)
|
|
|
|
def _rebuild_projects_list(self):
|
|
if not dpg.does_item_exist("projects_scroll"):
|
|
return
|
|
dpg.delete_item("projects_scroll", children_only=True)
|
|
for i, pp in enumerate(self.project_paths):
|
|
is_active = (pp == self.active_project_path)
|
|
with dpg.group(horizontal=True, parent="projects_scroll"):
|
|
dpg.add_button(
|
|
label="x", width=24, callback=self._make_remove_project_cb(i)
|
|
)
|
|
name_color = (140, 255, 160) if is_active else (200, 200, 200)
|
|
marker = " *" if is_active else ""
|
|
dpg.add_button(
|
|
label=f"{Path(pp).stem}{marker}",
|
|
callback=self._make_switch_project_cb(pp),
|
|
)
|
|
dpg.add_text(pp, color=(140, 140, 140))
|
|
|
|
def _rebuild_discussion_selector(self):
|
|
"""Rebuild the discussion selector UI: listbox + metadata for active discussion."""
|
|
if not dpg.does_item_exist("disc_selector_group"):
|
|
return
|
|
dpg.delete_item("disc_selector_group", children_only=True)
|
|
|
|
names = self._get_discussion_names()
|
|
|
|
dpg.add_listbox(
|
|
tag="disc_listbox",
|
|
items=names,
|
|
default_value=self.active_discussion,
|
|
width=-1,
|
|
num_items=min(len(names), 5),
|
|
callback=self.cb_disc_switch,
|
|
parent="disc_selector_group",
|
|
)
|
|
|
|
# Show metadata for the active discussion
|
|
disc_sec = self.project.get("discussion", {})
|
|
disc_data = disc_sec.get("discussions", {}).get(self.active_discussion, {})
|
|
git_commit = disc_data.get("git_commit", "")
|
|
last_updated = disc_data.get("last_updated", "")
|
|
|
|
with dpg.group(horizontal=False, parent="disc_selector_group"):
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_text("commit:", color=(160, 160, 160))
|
|
dpg.add_text(
|
|
git_commit[:12] if git_commit else "(none)",
|
|
color=(180, 255, 180) if git_commit else (120, 120, 120),
|
|
)
|
|
dpg.add_button(label="Update Commit", callback=self.cb_update_git_commit)
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_text("updated:", color=(160, 160, 160))
|
|
dpg.add_text(
|
|
last_updated if last_updated else "(never)",
|
|
color=(200, 200, 160),
|
|
)
|
|
|
|
with dpg.group(horizontal=True, parent="disc_selector_group"):
|
|
dpg.add_input_text(
|
|
tag="disc_new_name_input",
|
|
hint="New discussion name",
|
|
width=-180,
|
|
)
|
|
dpg.add_button(label="Create", callback=self.cb_disc_create)
|
|
dpg.add_button(label="Rename", callback=self.cb_disc_rename)
|
|
dpg.add_button(label="Delete", callback=self.cb_disc_delete)
|
|
|
|
def _make_remove_file_cb(self, idx: int):
|
|
def cb():
|
|
if idx < len(self.files):
|
|
self.files.pop(idx)
|
|
self._rebuild_files_list()
|
|
return cb
|
|
|
|
def _make_remove_shot_cb(self, idx: int):
|
|
def cb():
|
|
if idx < len(self.screenshots):
|
|
self.screenshots.pop(idx)
|
|
self._rebuild_shots_list()
|
|
return cb
|
|
|
|
def _make_remove_project_cb(self, idx: int):
|
|
def cb():
|
|
if idx < len(self.project_paths):
|
|
removed = self.project_paths.pop(idx)
|
|
if removed == self.active_project_path and self.project_paths:
|
|
self._switch_project(self.project_paths[0])
|
|
self._rebuild_projects_list()
|
|
return cb
|
|
|
|
def _make_switch_project_cb(self, path: str):
|
|
def cb():
|
|
if path != self.active_project_path:
|
|
self._switch_project(path)
|
|
self._rebuild_projects_list()
|
|
return cb
|
|
|
|
def _fetch_models(self, provider: str):
|
|
self._update_status("fetching models...")
|
|
|
|
def do_fetch():
|
|
try:
|
|
models = ai_client.list_models(provider)
|
|
self.available_models = models
|
|
self._rebuild_models_list()
|
|
self._update_status(f"models loaded: {len(models)}")
|
|
except Exception as e:
|
|
self._update_status(f"model fetch error: {e}")
|
|
|
|
self.models_thread = threading.Thread(target=do_fetch, daemon=True)
|
|
self.models_thread.start()
|
|
|
|
# ---------------------------------------------------------------- callbacks
|
|
|
|
def cb_word_wrap_toggled(self, sender=None, app_data=None):
|
|
# This function is now also called by _refresh_project_widgets to set initial state
|
|
if app_data is None:
|
|
wrap = dpg.get_value("project_word_wrap") if dpg.does_item_exist("project_word_wrap") else False
|
|
else:
|
|
wrap = app_data
|
|
|
|
# Persist the setting
|
|
self.project.setdefault("project", {})["word_wrap"] = wrap
|
|
|
|
# Toggle visibility of persistent wrapped/unwrapped container pairs
|
|
persistent_panels = [
|
|
"ai_response", "last_script_text", "last_script_output", "text_viewer_content"
|
|
]
|
|
for name in persistent_panels:
|
|
no_wrap_widget = name
|
|
wrap_container = f"{name}_wrap_container"
|
|
if dpg.does_item_exist(no_wrap_widget):
|
|
dpg.configure_item(no_wrap_widget, show=not wrap)
|
|
if dpg.does_item_exist(wrap_container):
|
|
dpg.configure_item(wrap_container, show=wrap)
|
|
|
|
# Re-render UI components with dynamic content that needs to change widget type
|
|
self._rebuild_comms_log()
|
|
self._rebuild_tool_log()
|
|
|
|
def cb_browse_output(self):
|
|
root = hide_tk_root()
|
|
d = filedialog.askdirectory(title="Select Output Dir")
|
|
root.destroy()
|
|
if d:
|
|
dpg.set_value("output_dir", d)
|
|
|
|
def cb_save_config(self):
|
|
self._flush_to_project()
|
|
self._save_active_project()
|
|
self._flush_to_config()
|
|
save_config(self.config)
|
|
self._update_status("config saved")
|
|
|
|
def cb_browse_files_base(self):
|
|
root = hide_tk_root()
|
|
d = filedialog.askdirectory(title="Select Files Base Dir")
|
|
root.destroy()
|
|
if d:
|
|
dpg.set_value("files_base_dir", d)
|
|
|
|
def cb_add_files(self):
|
|
root = hide_tk_root()
|
|
paths = filedialog.askopenfilenames(title="Select Files")
|
|
root.destroy()
|
|
for p in paths:
|
|
if p not in self.files:
|
|
self.files.append(p)
|
|
self._rebuild_files_list()
|
|
|
|
def cb_add_wildcard(self):
|
|
root = hide_tk_root()
|
|
d = filedialog.askdirectory(title="Select Dir for Wildcard")
|
|
root.destroy()
|
|
if d:
|
|
self.files.append(str(Path(d) / "**" / "*"))
|
|
self._rebuild_files_list()
|
|
|
|
def cb_browse_shots_base(self):
|
|
root = hide_tk_root()
|
|
d = filedialog.askdirectory(title="Select Screenshots Base Dir")
|
|
root.destroy()
|
|
if d:
|
|
dpg.set_value("shots_base_dir", d)
|
|
|
|
def cb_add_shots(self):
|
|
root = hide_tk_root()
|
|
paths = filedialog.askopenfilenames(
|
|
title="Select Screenshots",
|
|
filetypes=[
|
|
("Images", "*.png *.jpg *.jpeg *.gif *.bmp *.webp"),
|
|
("All", "*.*"),
|
|
],
|
|
)
|
|
root.destroy()
|
|
for p in paths:
|
|
if p not in self.screenshots:
|
|
self.screenshots.append(p)
|
|
self._rebuild_shots_list()
|
|
|
|
def cb_md_only(self):
|
|
try:
|
|
md, path, _file_items = self._do_generate()
|
|
self.last_md = md
|
|
self.last_md_path = path
|
|
self._update_status(f"md written: {path.name}")
|
|
except Exception as e:
|
|
self._update_status(f"error: {e}")
|
|
|
|
def cb_reset_session(self):
|
|
ai_client.reset_session()
|
|
ai_client.clear_comms_log()
|
|
self._tool_log.clear()
|
|
self._rebuild_tool_log()
|
|
with self._pending_comms_lock:
|
|
self._pending_comms.clear()
|
|
self._comms_entry_count = 0
|
|
if dpg.does_item_exist("comms_scroll"):
|
|
dpg.delete_item("comms_scroll", children_only=True)
|
|
self._update_status("session reset")
|
|
self._update_response("")
|
|
|
|
def cb_generate_send(self):
|
|
if self.send_thread and self.send_thread.is_alive():
|
|
return
|
|
try:
|
|
md, path, file_items = self._do_generate()
|
|
self.last_md = md
|
|
self.last_md_path = path
|
|
self.last_file_items = file_items
|
|
except Exception as e:
|
|
self._update_status(f"generate error: {e}")
|
|
return
|
|
|
|
self._update_status("sending...")
|
|
user_msg = dpg.get_value("ai_input")
|
|
base_dir = dpg.get_value("files_base_dir")
|
|
|
|
global_sp = dpg.get_value("global_system_prompt") if dpg.does_item_exist("global_system_prompt") else ""
|
|
project_sp = dpg.get_value("project_system_prompt") if dpg.does_item_exist("project_system_prompt") else ""
|
|
combined_sp = []
|
|
if global_sp: combined_sp.append(global_sp.strip())
|
|
if project_sp: combined_sp.append(project_sp.strip())
|
|
ai_client.set_custom_system_prompt("\n\n".join(combined_sp))
|
|
ai_client.set_agent_tools(self.project.get("agent", {}).get("tools", {}))
|
|
temp = dpg.get_value("ai_temperature") if dpg.does_item_exist("ai_temperature") else 0.0
|
|
max_tok = dpg.get_value("ai_max_tokens") if dpg.does_item_exist("ai_max_tokens") else 8192
|
|
trunc = dpg.get_value("ai_history_trunc") if dpg.does_item_exist("ai_history_trunc") else 8000
|
|
ai_client.set_model_params(temp, max_tok, trunc)
|
|
|
|
def do_send():
|
|
auto_add = dpg.get_value("auto_add_history") if dpg.does_item_exist("auto_add_history") else False
|
|
if auto_add:
|
|
self._queue_history_add("User", user_msg)
|
|
try:
|
|
response = ai_client.send(self.last_md, user_msg, base_dir, self.last_file_items)
|
|
self._update_response(response)
|
|
self._update_status("done")
|
|
self._trigger_blink = True
|
|
if auto_add:
|
|
self._queue_history_add("AI", response)
|
|
except ProviderError as e:
|
|
resp = e.ui_message()
|
|
self._update_response(resp)
|
|
self._update_status("error")
|
|
self._trigger_blink = True
|
|
if auto_add:
|
|
self._queue_history_add("Vendor API", resp)
|
|
except Exception as e:
|
|
resp = f"ERROR: {e}"
|
|
self._update_response(resp)
|
|
self._update_status("error")
|
|
self._trigger_blink = True
|
|
if auto_add:
|
|
self._queue_history_add("System", resp)
|
|
|
|
self.send_thread = threading.Thread(target=do_send, daemon=True)
|
|
self.send_thread.start()
|
|
|
|
def cb_provider_changed(self, sender, app_data):
|
|
self.current_provider = app_data
|
|
ai_client.reset_session()
|
|
ai_client.set_provider(self.current_provider, self.current_model)
|
|
self.available_models = []
|
|
self._rebuild_models_list()
|
|
self._fetch_models(self.current_provider)
|
|
|
|
def cb_model_changed(self, sender, app_data):
|
|
if app_data:
|
|
self.current_model = app_data
|
|
ai_client.reset_session()
|
|
ai_client.set_provider(self.current_provider, self.current_model)
|
|
self._update_status(f"model set: {self.current_model}")
|
|
|
|
def cb_fetch_models(self):
|
|
self._fetch_models(self.current_provider)
|
|
|
|
def cb_clear_tool_log(self):
|
|
self._tool_log.clear()
|
|
self._rebuild_tool_log()
|
|
|
|
def cb_clear_comms(self):
|
|
ai_client.clear_comms_log()
|
|
with self._pending_comms_lock:
|
|
self._pending_comms.clear()
|
|
self._comms_entry_count = 0
|
|
self._update_token_usage()
|
|
if dpg.does_item_exist("comms_scroll"):
|
|
dpg.delete_item("comms_scroll", children_only=True)
|
|
|
|
# ---- project callbacks ----
|
|
|
|
def cb_add_project(self):
|
|
root = hide_tk_root()
|
|
p = filedialog.askopenfilename(
|
|
title="Select Project .toml",
|
|
filetypes=[("TOML", "*.toml"), ("All", "*.*")],
|
|
)
|
|
root.destroy()
|
|
if p and p not in self.project_paths:
|
|
self.project_paths.append(p)
|
|
self._rebuild_projects_list()
|
|
|
|
def cb_new_project(self):
|
|
root = hide_tk_root()
|
|
p = filedialog.asksaveasfilename(
|
|
title="Create New Project .toml",
|
|
defaultextension=".toml",
|
|
filetypes=[("TOML", "*.toml"), ("All", "*.*")],
|
|
)
|
|
root.destroy()
|
|
if not p:
|
|
return
|
|
name = Path(p).stem
|
|
proj = project_manager.default_project(name)
|
|
project_manager.save_project(proj, p)
|
|
if p not in self.project_paths:
|
|
self.project_paths.append(p)
|
|
self._switch_project(p)
|
|
self._rebuild_projects_list()
|
|
self._update_status(f"created project: {name}")
|
|
|
|
def cb_browse_git_dir(self):
|
|
root = hide_tk_root()
|
|
d = filedialog.askdirectory(title="Select Git Directory")
|
|
root.destroy()
|
|
if d and dpg.does_item_exist("project_git_dir"):
|
|
dpg.set_value("project_git_dir", d)
|
|
|
|
def cb_browse_main_context(self):
|
|
root = hide_tk_root()
|
|
p = filedialog.askopenfilename(title="Select Main Context File")
|
|
root.destroy()
|
|
if p and dpg.does_item_exist("project_main_context"):
|
|
dpg.set_value("project_main_context", p)
|
|
|
|
# ---- discussion callbacks ----
|
|
|
|
def cb_disc_switch(self, sender, app_data):
|
|
if app_data and app_data != self.active_discussion:
|
|
self._switch_discussion(app_data)
|
|
|
|
def cb_disc_create(self):
|
|
if not dpg.does_item_exist("disc_new_name_input"):
|
|
return
|
|
name = dpg.get_value("disc_new_name_input").strip()
|
|
if not name:
|
|
self._update_status("enter a discussion name")
|
|
return
|
|
self._create_discussion(name)
|
|
dpg.set_value("disc_new_name_input", "")
|
|
|
|
def cb_disc_rename(self):
|
|
if not dpg.does_item_exist("disc_new_name_input"):
|
|
return
|
|
new_name = dpg.get_value("disc_new_name_input").strip()
|
|
if not new_name:
|
|
self._update_status("enter a new name")
|
|
return
|
|
self._rename_discussion(self.active_discussion, new_name)
|
|
dpg.set_value("disc_new_name_input", "")
|
|
|
|
def cb_disc_delete(self):
|
|
self._delete_discussion(self.active_discussion)
|
|
|
|
def cb_update_git_commit(self):
|
|
self._update_discussion_git_commit()
|
|
|
|
def cb_disc_save(self):
|
|
self._flush_to_project()
|
|
self._save_active_project()
|
|
self._flush_to_config()
|
|
save_config(self.config)
|
|
self._update_status("discussion saved")
|
|
|
|
def cb_disc_append_entry(self):
|
|
default_role = self.disc_roles[0] if self.disc_roles else "User"
|
|
self.disc_entries.append({
|
|
"role": default_role,
|
|
"content": "",
|
|
"collapsed": False,
|
|
"ts": project_manager.now_ts(),
|
|
})
|
|
self._rebuild_disc_list()
|
|
|
|
def cb_disc_clear(self):
|
|
self.disc_entries.clear()
|
|
self._rebuild_disc_list()
|
|
|
|
def cb_disc_truncate(self):
|
|
pairs = dpg.get_value("disc_truncate_pairs") if dpg.does_item_exist("disc_truncate_pairs") else 2
|
|
self.disc_entries = truncate_entries(self.disc_entries, pairs)
|
|
self._rebuild_disc_list()
|
|
self._update_status(f"history truncated to {pairs} pairs")
|
|
|
|
def cb_disc_collapse_all(self):
|
|
for i, entry in enumerate(self.disc_entries):
|
|
tag = f"disc_content_{i}"
|
|
if dpg.does_item_exist(tag):
|
|
entry["content"] = dpg.get_value(tag)
|
|
entry["collapsed"] = True
|
|
self._rebuild_disc_list()
|
|
|
|
def cb_disc_expand_all(self):
|
|
for entry in self.disc_entries:
|
|
entry["collapsed"] = False
|
|
self._rebuild_disc_list()
|
|
|
|
def cb_append_message_to_history(self):
|
|
msg = dpg.get_value("ai_input")
|
|
if msg:
|
|
self.disc_entries.append({
|
|
"role": "User",
|
|
"content": msg,
|
|
"collapsed": False,
|
|
"ts": project_manager.now_ts(),
|
|
})
|
|
self._rebuild_disc_list()
|
|
|
|
def cb_append_response_to_history(self):
|
|
resp = self.ai_response
|
|
if resp:
|
|
self.disc_entries.append({
|
|
"role": "AI",
|
|
"content": resp,
|
|
"collapsed": False,
|
|
"ts": project_manager.now_ts(),
|
|
})
|
|
self._rebuild_disc_list()
|
|
|
|
# ---- disc roles ----
|
|
|
|
def _rebuild_disc_roles_list(self):
|
|
if not dpg.does_item_exist("disc_roles_scroll"):
|
|
return
|
|
dpg.delete_item("disc_roles_scroll", children_only=True)
|
|
for i, role in enumerate(self.disc_roles):
|
|
with dpg.group(horizontal=True, parent="disc_roles_scroll"):
|
|
dpg.add_button(
|
|
label="x", width=24,
|
|
callback=self._make_disc_remove_role_cb(i),
|
|
)
|
|
dpg.add_text(role)
|
|
|
|
def _make_disc_remove_role_cb(self, idx: int):
|
|
def cb():
|
|
if idx < len(self.disc_roles):
|
|
self.disc_roles.pop(idx)
|
|
self._rebuild_disc_roles_list()
|
|
self._rebuild_disc_list()
|
|
return cb
|
|
|
|
def cb_disc_add_role(self):
|
|
if not dpg.does_item_exist("disc_new_role_input"):
|
|
return
|
|
name = dpg.get_value("disc_new_role_input").strip()
|
|
if name and name not in self.disc_roles:
|
|
self.disc_roles.append(name)
|
|
dpg.set_value("disc_new_role_input", "")
|
|
self._rebuild_disc_roles_list()
|
|
self._rebuild_disc_list()
|
|
|
|
# ---- disc entry list ----
|
|
|
|
def _rebuild_disc_list(self):
|
|
if not dpg.does_item_exist("disc_scroll"):
|
|
return
|
|
|
|
def _toggle_read(s, a, idx):
|
|
# Save edit box content before switching to read mode
|
|
tag = f"disc_content_{idx}"
|
|
if dpg.does_item_exist(tag) and not self.disc_entries[idx].get("read_mode", False):
|
|
self.disc_entries[idx]["content"] = dpg.get_value(tag)
|
|
self.disc_entries[idx]["read_mode"] = not self.disc_entries[idx].get("read_mode", False)
|
|
self._rebuild_disc_list()
|
|
|
|
dpg.delete_item("disc_scroll", children_only=True)
|
|
for i, entry in enumerate(self.disc_entries):
|
|
collapsed = entry.get("collapsed", False)
|
|
read_mode = entry.get("read_mode", False)
|
|
ts_str = entry.get("ts", "")
|
|
|
|
preview = entry["content"].replace("\n", " ")[:60]
|
|
if len(entry["content"]) > 60:
|
|
preview += "..."
|
|
|
|
with dpg.group(parent="disc_scroll"):
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_button(
|
|
tag=f"disc_toggle_{i}",
|
|
label="+" if collapsed else "-",
|
|
width=24,
|
|
callback=self._make_disc_toggle_cb(i),
|
|
)
|
|
dpg.add_combo(
|
|
tag=f"disc_role_{i}",
|
|
items=self.disc_roles,
|
|
default_value=entry["role"],
|
|
width=120,
|
|
callback=self._make_disc_role_cb(i),
|
|
)
|
|
if not collapsed:
|
|
dpg.add_button(
|
|
label="[Edit]" if read_mode else "[Read]",
|
|
user_data=i,
|
|
callback=_toggle_read
|
|
)
|
|
if ts_str:
|
|
dpg.add_text(ts_str, color=(120, 120, 100))
|
|
if collapsed:
|
|
dpg.add_button(
|
|
label="Ins",
|
|
width=36,
|
|
callback=self._make_disc_insert_cb(i),
|
|
)
|
|
dpg.add_button(
|
|
label="[+ Max]",
|
|
user_data=i,
|
|
callback=lambda s, a, u: _show_text_viewer(f"Entry #{u+1}", self.disc_entries[u]["content"])
|
|
)
|
|
dpg.add_button(
|
|
label="Del",
|
|
width=36,
|
|
callback=self._make_disc_remove_cb(i),
|
|
)
|
|
dpg.add_text(preview, color=(160, 160, 150))
|
|
|
|
with dpg.group(tag=f"disc_body_{i}", show=not collapsed):
|
|
if read_mode:
|
|
with dpg.child_window(height=150, border=True):
|
|
dpg.add_text(entry["content"], wrap=0, color=(200, 200, 200))
|
|
else:
|
|
dpg.add_input_text(
|
|
tag=f"disc_content_{i}",
|
|
default_value=entry["content"],
|
|
multiline=True,
|
|
width=-1,
|
|
height=150,
|
|
callback=self._make_disc_content_cb(i),
|
|
on_enter=False,
|
|
)
|
|
dpg.add_separator()
|
|
|
|
def _make_disc_role_cb(self, idx: int):
|
|
def cb(sender, app_data):
|
|
if idx < len(self.disc_entries):
|
|
self.disc_entries[idx]["role"] = app_data
|
|
return cb
|
|
|
|
def _make_disc_content_cb(self, idx: int):
|
|
def cb(sender, app_data):
|
|
if idx < len(self.disc_entries):
|
|
self.disc_entries[idx]["content"] = app_data
|
|
return cb
|
|
|
|
def _make_disc_insert_cb(self, idx: int):
|
|
def cb():
|
|
self.disc_entries.insert(idx, {
|
|
"role": "User",
|
|
"content": "",
|
|
"collapsed": False,
|
|
"ts": project_manager.now_ts(),
|
|
})
|
|
self._rebuild_disc_list()
|
|
return cb
|
|
|
|
def _make_disc_remove_cb(self, idx: int):
|
|
def cb():
|
|
if idx < len(self.disc_entries):
|
|
self.disc_entries.pop(idx)
|
|
self._rebuild_disc_list()
|
|
return cb
|
|
|
|
def _make_disc_toggle_cb(self, idx: int):
|
|
def cb():
|
|
if idx < len(self.disc_entries):
|
|
tag = f"disc_content_{idx}"
|
|
if dpg.does_item_exist(tag):
|
|
self.disc_entries[idx]["content"] = dpg.get_value(tag)
|
|
self.disc_entries[idx]["collapsed"] = not self.disc_entries[idx].get("collapsed", False)
|
|
self._rebuild_disc_list()
|
|
return cb
|
|
|
|
# ------------------------------------------------------------ theme
|
|
|
|
def cb_palette_changed(self, sender, app_data):
|
|
theme.apply(app_data, self._read_colour_overrides())
|
|
self._update_status(f"palette: {app_data}")
|
|
|
|
def cb_apply_font(self):
|
|
path = dpg.get_value("theme_font_path").strip()
|
|
size = dpg.get_value("theme_font_size")
|
|
theme.apply_font(path, size)
|
|
self._update_status(f"font applied: {path or '(default)'} @{size}px")
|
|
|
|
def cb_browse_font(self):
|
|
root = hide_tk_root()
|
|
p = filedialog.askopenfilename(
|
|
title="Select Font",
|
|
filetypes=[("TrueType / OpenType", "*.ttf *.otf"), ("All", "*.*")],
|
|
)
|
|
root.destroy()
|
|
if p:
|
|
dpg.set_value("theme_font_path", p)
|
|
self.cb_apply_font()
|
|
|
|
def cb_scale_changed(self, sender, app_data):
|
|
theme.set_scale(round(app_data, 2))
|
|
|
|
def _read_colour_overrides(self) -> dict:
|
|
return {}
|
|
|
|
# ------------------------------------------------------------ build ui
|
|
|
|
def _build_theme_window(self):
|
|
t_cfg = self.config.get("theme", {})
|
|
cur_palette = t_cfg.get("palette", "DPG Default")
|
|
cur_font_path = t_cfg.get("font_path", "")
|
|
cur_font_size = float(t_cfg.get("font_size", 14.0))
|
|
cur_scale = float(t_cfg.get("scale", 1.0))
|
|
|
|
with dpg.window(
|
|
label="Theme",
|
|
tag="win_theme",
|
|
pos=(416, 516),
|
|
width=400,
|
|
height=280,
|
|
no_close=False,
|
|
):
|
|
dpg.add_text("Palette")
|
|
dpg.add_combo(
|
|
tag="theme_palette",
|
|
items=theme.PALETTE_NAMES,
|
|
default_value=cur_palette,
|
|
width=-1,
|
|
callback=self.cb_palette_changed,
|
|
)
|
|
dpg.add_separator()
|
|
dpg.add_text("Font")
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_input_text(
|
|
tag="theme_font_path",
|
|
default_value=cur_font_path,
|
|
hint="Path to .ttf / .otf (blank = built-in)",
|
|
width=-148,
|
|
)
|
|
dpg.add_button(label="Browse##font", callback=self.cb_browse_font)
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_text("Size (px)")
|
|
dpg.add_input_float(
|
|
tag="theme_font_size",
|
|
default_value=cur_font_size,
|
|
min_value=8.0,
|
|
max_value=64.0,
|
|
step=1.0,
|
|
width=90,
|
|
format="%.0f",
|
|
)
|
|
dpg.add_button(label="Apply Font", callback=self.cb_apply_font)
|
|
dpg.add_separator()
|
|
dpg.add_text("UI Scale (DPI)")
|
|
dpg.add_slider_float(
|
|
tag="theme_scale",
|
|
default_value=cur_scale,
|
|
min_value=0.5,
|
|
max_value=3.0,
|
|
width=-1,
|
|
callback=self.cb_scale_changed,
|
|
format="%.2f",
|
|
)
|
|
|
|
def _build_ui(self):
|
|
with dpg.viewport_menu_bar():
|
|
with dpg.menu(label="Windows"):
|
|
for label, tag in self.window_info.items():
|
|
dpg.add_menu_item(label=label, callback=lambda s, a, u: dpg.show_item(u), user_data=tag)
|
|
with dpg.menu(label="Project"):
|
|
dpg.add_menu_item(label="Save All", callback=self.cb_save_config)
|
|
dpg.add_menu_item(label="Reset Session", callback=self.cb_reset_session)
|
|
dpg.add_menu_item(label="Generate MD Only", callback=self.cb_md_only)
|
|
|
|
|
|
# ---- Projects panel ----
|
|
with dpg.window(
|
|
label="Projects",
|
|
tag="win_projects",
|
|
pos=(8, 8),
|
|
width=400,
|
|
height=380,
|
|
no_close=False,
|
|
):
|
|
proj_meta = self.project.get("project", {})
|
|
proj_name = proj_meta.get("name", Path(self.active_project_path).stem)
|
|
dpg.add_text(f"Active: {proj_name}", tag="project_name_text", color=(140, 255, 160))
|
|
dpg.add_separator()
|
|
dpg.add_text("Git Directory")
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_input_text(
|
|
tag="project_git_dir",
|
|
default_value=proj_meta.get("git_dir", ""),
|
|
width=-100,
|
|
)
|
|
dpg.add_button(label="Browse##git", callback=self.cb_browse_git_dir)
|
|
dpg.add_separator()
|
|
dpg.add_text("Main Context File")
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_input_text(
|
|
tag="project_main_context",
|
|
default_value=proj_meta.get("main_context", ""),
|
|
width=-100,
|
|
)
|
|
dpg.add_button(label="Browse##ctx", callback=self.cb_browse_main_context)
|
|
dpg.add_separator()
|
|
dpg.add_text("Output Dir")
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_input_text(
|
|
tag="output_dir",
|
|
default_value=self.project.get("output", {}).get("output_dir", "./md_gen"),
|
|
width=-100,
|
|
)
|
|
dpg.add_button(label="Browse##out", callback=self.cb_browse_output)
|
|
dpg.add_separator()
|
|
dpg.add_text("Project Files")
|
|
with dpg.child_window(tag="projects_scroll", height=-60, border=True):
|
|
pass
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_button(label="Add Project", callback=self.cb_add_project)
|
|
dpg.add_button(label="New Project", callback=self.cb_new_project)
|
|
dpg.add_button(label="Save All", callback=self.cb_save_config)
|
|
dpg.add_checkbox(
|
|
tag="project_word_wrap",
|
|
label="Word-Wrap (Read-only panels)",
|
|
default_value=self.project.get("project", {}).get("word_wrap", True),
|
|
callback=self.cb_word_wrap_toggled
|
|
)
|
|
dpg.add_separator()
|
|
dpg.add_text("Agent Capabilities")
|
|
agent_tools = self.project.get("agent", {}).get("tools", {})
|
|
for t_name in ["run_powershell", "read_file", "list_directory", "search_files", "get_file_summary", "web_search", "fetch_url"]:
|
|
dpg.add_checkbox(
|
|
tag=f"tool_toggle_{t_name}",
|
|
label=f"Enable {t_name}",
|
|
default_value=agent_tools.get(t_name, True)
|
|
)
|
|
|
|
# ---- Files panel ----
|
|
with dpg.window(
|
|
label="Files",
|
|
tag="win_files",
|
|
pos=(8, 396),
|
|
width=400,
|
|
height=360,
|
|
no_close=False,
|
|
):
|
|
dpg.add_text("Base Dir")
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_input_text(
|
|
tag="files_base_dir",
|
|
default_value=self.project.get("files", {}).get("base_dir", "."),
|
|
width=-220,
|
|
)
|
|
dpg.add_button(
|
|
label="Browse##filesbase", callback=self.cb_browse_files_base
|
|
)
|
|
dpg.add_separator()
|
|
dpg.add_text("Paths")
|
|
with dpg.child_window(tag="files_scroll", height=-64, border=True):
|
|
pass
|
|
dpg.add_separator()
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_button(label="Add File(s)", callback=self.cb_add_files)
|
|
dpg.add_button(label="Add Wildcard", callback=self.cb_add_wildcard)
|
|
|
|
# ---- Screenshots panel ----
|
|
with dpg.window(
|
|
label="Screenshots",
|
|
tag="win_screenshots",
|
|
pos=(416, 8),
|
|
width=400,
|
|
height=500,
|
|
no_close=False,
|
|
):
|
|
dpg.add_text("Base Dir")
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_input_text(
|
|
tag="shots_base_dir",
|
|
default_value=self.project.get("screenshots", {}).get("base_dir", "."),
|
|
width=-220,
|
|
)
|
|
dpg.add_button(
|
|
label="Browse##shotsbase", callback=self.cb_browse_shots_base
|
|
)
|
|
dpg.add_separator()
|
|
dpg.add_text("Paths")
|
|
with dpg.child_window(tag="shots_scroll", height=-48, border=True):
|
|
pass
|
|
self._rebuild_shots_list()
|
|
dpg.add_separator()
|
|
dpg.add_button(label="Add Screenshot(s)", callback=self.cb_add_shots)
|
|
|
|
# ---- Discussion History panel ----
|
|
with dpg.window(
|
|
label="Discussion History",
|
|
tag="win_discussion",
|
|
pos=(824, 8),
|
|
width=420,
|
|
height=600,
|
|
no_close=False,
|
|
):
|
|
# Discussion selector section
|
|
with dpg.collapsing_header(label="Discussions", default_open=True):
|
|
with dpg.group(tag="disc_selector_group"):
|
|
pass # populated by _rebuild_discussion_selector
|
|
|
|
dpg.add_separator()
|
|
|
|
# Entry toolbar
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_button(label="+ Entry", callback=self.cb_disc_append_entry)
|
|
dpg.add_button(label="-All", callback=self.cb_disc_collapse_all)
|
|
dpg.add_button(label="+All", callback=self.cb_disc_expand_all)
|
|
dpg.add_text("Keep Pairs:", color=(160, 160, 160))
|
|
dpg.add_input_int(tag="disc_truncate_pairs", default_value=2, width=120, min_value=1)
|
|
dpg.add_button(label="Truncate", callback=self.cb_disc_truncate)
|
|
dpg.add_button(label="Clear All", callback=self.cb_disc_clear)
|
|
dpg.add_button(label="Save", callback=self.cb_disc_save)
|
|
dpg.add_checkbox(
|
|
tag="auto_add_history",
|
|
label="Auto-add message & response to history",
|
|
default_value=self.project.get("discussion", {}).get("auto_add", False)
|
|
)
|
|
dpg.add_separator()
|
|
with dpg.collapsing_header(label="Roles", default_open=False):
|
|
with dpg.child_window(tag="disc_roles_scroll", height=96, border=True):
|
|
pass
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_input_text(
|
|
tag="disc_new_role_input",
|
|
hint="New role name",
|
|
width=-72,
|
|
)
|
|
dpg.add_button(label="Add", callback=self.cb_disc_add_role)
|
|
dpg.add_separator()
|
|
with dpg.child_window(tag="disc_scroll", height=-1, border=False):
|
|
pass
|
|
|
|
# ---- Provider panel ----
|
|
with dpg.window(
|
|
label="Provider",
|
|
tag="win_provider",
|
|
pos=(1252, 8),
|
|
width=420,
|
|
height=260,
|
|
no_close=False,
|
|
):
|
|
dpg.add_text("Provider")
|
|
dpg.add_combo(
|
|
tag="provider_combo",
|
|
items=PROVIDERS,
|
|
default_value=self.current_provider,
|
|
width=-1,
|
|
callback=self.cb_provider_changed,
|
|
)
|
|
dpg.add_separator()
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_text("Model")
|
|
dpg.add_button(label="Fetch Models", callback=self.cb_fetch_models)
|
|
dpg.add_listbox(
|
|
tag="model_listbox",
|
|
items=self.available_models,
|
|
default_value=self.current_model,
|
|
width=-1,
|
|
num_items=5,
|
|
callback=self.cb_model_changed,
|
|
)
|
|
dpg.add_separator()
|
|
dpg.add_text("Telemetry")
|
|
dpg.add_text("History Token Budget:", color=_LABEL_COLOR)
|
|
dpg.add_progress_bar(tag="token_budget_bar", default_value=0.0, width=-1)
|
|
dpg.add_text("0 / 0", tag="token_budget_label")
|
|
dpg.add_text("", tag="gemini_cache_label", show=False)
|
|
dpg.add_separator()
|
|
dpg.add_text("Parameters")
|
|
dpg.add_input_float(tag="ai_temperature", label="Temperature", default_value=self.temperature, min_value=0.0, max_value=2.0)
|
|
dpg.add_input_int(tag="ai_max_tokens", label="Max Tokens (Output)", default_value=self.max_tokens, step=1024)
|
|
dpg.add_input_int(tag="ai_history_trunc", label="History Truncation Limit", default_value=self.history_trunc_limit, step=1024)
|
|
|
|
# ---- Message panel ----
|
|
with dpg.window(
|
|
label="Message",
|
|
tag="win_message",
|
|
pos=(1252, 276),
|
|
width=420,
|
|
height=280,
|
|
no_close=False,
|
|
):
|
|
dpg.add_input_text(
|
|
tag="ai_input",
|
|
multiline=True,
|
|
width=-1,
|
|
height=-64,
|
|
)
|
|
dpg.add_separator()
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_button(label="Gen + Send", callback=self.cb_generate_send)
|
|
dpg.add_button(label="MD Only", callback=self.cb_md_only)
|
|
dpg.add_button(label="Reset", callback=self.cb_reset_session)
|
|
dpg.add_button(label="-> History", callback=self.cb_append_message_to_history)
|
|
|
|
# ---- Response panel ----
|
|
with dpg.window(
|
|
label="Response",
|
|
tag="win_response",
|
|
pos=(1252, 564),
|
|
width=420,
|
|
height=300,
|
|
no_close=False,
|
|
):
|
|
dpg.add_input_text(
|
|
tag="ai_response",
|
|
multiline=True,
|
|
readonly=True,
|
|
width=-1,
|
|
height=-48,
|
|
)
|
|
with dpg.child_window(tag="ai_response_wrap_container", width=-1, height=-48, border=True, show=False):
|
|
dpg.add_text("", tag="ai_response_wrap", wrap=0)
|
|
dpg.add_separator()
|
|
dpg.add_button(label="-> History", callback=self.cb_append_response_to_history)
|
|
|
|
# ---- Tool Calls panel ----
|
|
with dpg.window(
|
|
label="Tool Calls",
|
|
tag="win_tool_log",
|
|
pos=(1252, 872),
|
|
width=420,
|
|
height=300,
|
|
no_close=False,
|
|
):
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_text("Tool call history")
|
|
dpg.add_button(label="Clear", callback=self.cb_clear_tool_log)
|
|
dpg.add_separator()
|
|
with dpg.child_window(tag="tool_log_scroll", height=-1, border=False):
|
|
pass
|
|
|
|
# ---- Comms History panel ----
|
|
with dpg.window(
|
|
label="Comms History",
|
|
tag="win_comms",
|
|
pos=(1680, 8),
|
|
width=520,
|
|
height=1164,
|
|
no_close=False,
|
|
):
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_text("Status: idle", tag="ai_status", color=(200, 220, 160))
|
|
dpg.add_spacer(width=16)
|
|
dpg.add_text("Tokens: 0 (In: 0 Out: 0)", tag="ai_token_usage", color=(180, 255, 180))
|
|
dpg.add_spacer(width=16)
|
|
dpg.add_button(label="Clear", callback=self.cb_clear_comms)
|
|
dpg.add_separator()
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_text("OUT", color=_DIR_COLORS["OUT"])
|
|
dpg.add_text("request", color=_KIND_COLORS["request"])
|
|
dpg.add_text("tool_call", color=_KIND_COLORS["tool_call"])
|
|
dpg.add_spacer(width=8)
|
|
dpg.add_text("IN", color=_DIR_COLORS["IN"])
|
|
dpg.add_text("response", color=_KIND_COLORS["response"])
|
|
dpg.add_text("tool_result", color=_KIND_COLORS["tool_result"])
|
|
dpg.add_separator()
|
|
with dpg.child_window(tag="comms_scroll", height=-1, border=False, horizontal_scrollbar=True):
|
|
pass
|
|
|
|
# ---- System Prompts panel ----
|
|
with dpg.window(
|
|
label="System Prompts",
|
|
tag="win_system_prompts",
|
|
pos=(416, 804),
|
|
width=400,
|
|
height=300,
|
|
no_close=False,
|
|
):
|
|
dpg.add_text("Global System Prompt (all projects)")
|
|
dpg.add_input_text(
|
|
tag="global_system_prompt",
|
|
default_value=self.config.get("ai", {}).get("system_prompt", ""),
|
|
multiline=True,
|
|
width=-1,
|
|
height=100,
|
|
)
|
|
dpg.add_separator()
|
|
dpg.add_text("Project System Prompt")
|
|
dpg.add_input_text(
|
|
tag="project_system_prompt",
|
|
default_value=self.project.get("project", {}).get("system_prompt", ""),
|
|
multiline=True,
|
|
width=-1,
|
|
height=100,
|
|
)
|
|
|
|
self._build_theme_window()
|
|
|
|
# ---- Script Output Popup ----
|
|
with dpg.window(
|
|
label="Last Script Output",
|
|
tag="win_script_output",
|
|
show=False,
|
|
width=800,
|
|
height=600,
|
|
pos=(100, 100),
|
|
no_collapse=True
|
|
):
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_text("Script:")
|
|
dpg.add_button(
|
|
label="[+ Maximize]",
|
|
callback=lambda s, a, u: _show_text_viewer("Last Script", self._last_script),
|
|
)
|
|
dpg.add_input_text(
|
|
tag="last_script_text",
|
|
multiline=True,
|
|
readonly=True,
|
|
width=-1,
|
|
height=200,
|
|
)
|
|
with dpg.child_window(tag="last_script_text_wrap_container", width=-1, height=200, border=True, show=False):
|
|
dpg.add_text("", tag="last_script_text_wrap", wrap=0)
|
|
dpg.add_separator()
|
|
with dpg.group(horizontal=True):
|
|
dpg.add_text("Output:")
|
|
dpg.add_button(
|
|
label="[+ Maximize]",
|
|
callback=lambda s, a, u: _show_text_viewer("Last Output", self._last_output),
|
|
)
|
|
dpg.add_input_text(
|
|
tag="last_script_output",
|
|
multiline=True,
|
|
readonly=True,
|
|
width=-1,
|
|
height=-1,
|
|
)
|
|
with dpg.child_window(tag="last_script_output_wrap_container", width=-1, height=-1, border=True, show=False):
|
|
dpg.add_text("", tag="last_script_output_wrap", wrap=0)
|
|
|
|
# ---- Global Text Viewer Popup ----
|
|
with dpg.window(
|
|
label="Text Viewer",
|
|
tag="win_text_viewer",
|
|
show=False,
|
|
width=900,
|
|
height=700,
|
|
pos=(150, 150),
|
|
no_collapse=True
|
|
):
|
|
dpg.add_input_text(
|
|
tag="text_viewer_content",
|
|
multiline=True,
|
|
readonly=True,
|
|
width=-1,
|
|
height=-1,
|
|
)
|
|
with dpg.child_window(tag="text_viewer_wrap_container", width=-1, height=-1, border=False, show=False):
|
|
dpg.add_text("", tag="text_viewer_wrap", wrap=0)
|
|
|
|
def run(self):
|
|
dpg.create_context()
|
|
dpg.configure_app(docking=True, docking_space=True, init_file="dpg_layout.ini")
|
|
dpg.create_viewport(title="manual slop", width=1680, height=1200)
|
|
dpg.setup_dearpygui()
|
|
dpg.show_viewport()
|
|
dpg.maximize_viewport()
|
|
self._build_ui()
|
|
theme.load_from_config(self.config)
|
|
self._rebuild_files_list()
|
|
self._rebuild_shots_list()
|
|
self._rebuild_disc_list()
|
|
self._rebuild_disc_roles_list()
|
|
self._rebuild_projects_list()
|
|
self._rebuild_discussion_selector()
|
|
self._fetch_models(self.current_provider)
|
|
|
|
self.hook_server.start()
|
|
|
|
while dpg.is_dearpygui_running():
|
|
# Show any pending confirmation dialog on the main thread safely
|
|
with self._pending_dialog_lock:
|
|
dialog = self._pending_dialog
|
|
self._pending_dialog = None
|
|
if dialog is not None:
|
|
dialog.show()
|
|
|
|
# Process queued history additions
|
|
with self._pending_history_adds_lock:
|
|
adds = self._pending_history_adds[:]
|
|
self._pending_history_adds.clear()
|
|
if adds:
|
|
for item in adds:
|
|
if item["role"] not in self.disc_roles:
|
|
self.disc_roles.append(item["role"])
|
|
self._rebuild_disc_roles_list()
|
|
self.disc_entries.append(item)
|
|
self._rebuild_disc_list()
|
|
if dpg.does_item_exist("disc_scroll"):
|
|
# Force scroll to bottom using a very large number
|
|
dpg.set_y_scroll("disc_scroll", 99999)
|
|
|
|
# Process queued API GUI tasks
|
|
with self._pending_gui_tasks_lock:
|
|
gui_tasks = self._pending_gui_tasks[:]
|
|
self._pending_gui_tasks.clear()
|
|
for task in gui_tasks:
|
|
try:
|
|
action = task.get("action")
|
|
if action == "set_value":
|
|
item = task.get("item")
|
|
val = task.get("value")
|
|
if item and dpg.does_item_exist(item):
|
|
dpg.set_value(item, val)
|
|
elif action == "click":
|
|
item = task.get("item")
|
|
if item and dpg.does_item_exist(item):
|
|
cb = dpg.get_item_callback(item)
|
|
if cb:
|
|
cb()
|
|
except Exception as e:
|
|
print(f"Error executing GUI hook task: {e}")
|
|
|
|
# Handle retro arcade blinking effect
|
|
if self._trigger_script_blink:
|
|
self._trigger_script_blink = False
|
|
self._is_script_blinking = True
|
|
self._script_blink_start_time = time.time()
|
|
if dpg.does_item_exist("win_script_output"):
|
|
dpg.show_item("win_script_output")
|
|
# dpg.focus_item("win_script_output") # Focus can sometimes be jarring, but requested
|
|
|
|
if self._is_script_blinking:
|
|
elapsed = time.time() - self._script_blink_start_time
|
|
if elapsed > 1.5:
|
|
self._is_script_blinking = False
|
|
if dpg.does_item_exist("script_blink_theme"):
|
|
try:
|
|
dpg.bind_item_theme("last_script_output", 0)
|
|
dpg.bind_item_theme("last_script_text", 0)
|
|
if dpg.does_item_exist("last_script_output_wrap_container"):
|
|
dpg.bind_item_theme("last_script_output_wrap_container", 0)
|
|
if dpg.does_item_exist("last_script_text_wrap_container"):
|
|
dpg.bind_item_theme("last_script_text_wrap_container", 0)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
val = math.sin(elapsed * 8 * math.pi)
|
|
alpha = 60 if val > 0 else 0
|
|
|
|
if not dpg.does_item_exist("script_blink_theme"):
|
|
with dpg.theme(tag="script_blink_theme"):
|
|
with dpg.theme_component(dpg.mvAll):
|
|
dpg.add_theme_color(dpg.mvThemeCol_FrameBg, (0, 100, 255, alpha), tag="script_blink_color")
|
|
dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (0, 100, 255, alpha), tag="script_blink_color2")
|
|
else:
|
|
dpg.set_value("script_blink_color", [0, 100, 255, alpha])
|
|
if dpg.does_item_exist("script_blink_color2"):
|
|
dpg.set_value("script_blink_color2", [0, 100, 255, alpha])
|
|
|
|
if dpg.does_item_exist("last_script_output"):
|
|
try:
|
|
dpg.bind_item_theme("last_script_output", "script_blink_theme")
|
|
dpg.bind_item_theme("last_script_text", "script_blink_theme")
|
|
if dpg.does_item_exist("last_script_output_wrap_container"):
|
|
dpg.bind_item_theme("last_script_output_wrap_container", "script_blink_theme")
|
|
if dpg.does_item_exist("last_script_text_wrap_container"):
|
|
dpg.bind_item_theme("last_script_text_wrap_container", "script_blink_theme")
|
|
except Exception:
|
|
pass
|
|
|
|
if self._trigger_blink:
|
|
self._trigger_blink = False
|
|
self._is_blinking = True
|
|
self._blink_start_time = time.time()
|
|
if dpg.does_item_exist("win_response"):
|
|
dpg.focus_item("win_response")
|
|
|
|
if self._is_blinking:
|
|
elapsed = time.time() - self._blink_start_time
|
|
if elapsed > 1.5:
|
|
self._is_blinking = False
|
|
if dpg.does_item_exist("response_blink_theme"):
|
|
try:
|
|
dpg.bind_item_theme("ai_response", 0)
|
|
if dpg.does_item_exist("ai_response_wrap_container"):
|
|
dpg.bind_item_theme("ai_response_wrap_container", 0)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
# Square-wave style retro blink (4 times per second)
|
|
val = math.sin(elapsed * 8 * math.pi)
|
|
alpha = 50 if val > 0 else 0
|
|
|
|
if not dpg.does_item_exist("response_blink_theme"):
|
|
with dpg.theme(tag="response_blink_theme"):
|
|
with dpg.theme_component(dpg.mvAll):
|
|
dpg.add_theme_color(dpg.mvThemeCol_FrameBg, (0, 255, 0, alpha), tag="response_blink_color")
|
|
dpg.add_theme_color(dpg.mvThemeCol_ChildBg, (0, 255, 0, alpha), tag="response_blink_color2")
|
|
else:
|
|
dpg.set_value("response_blink_color", [0, 255, 0, alpha])
|
|
if dpg.does_item_exist("response_blink_color2"):
|
|
dpg.set_value("response_blink_color2", [0, 255, 0, alpha])
|
|
|
|
if dpg.does_item_exist("ai_response"):
|
|
try:
|
|
dpg.bind_item_theme("ai_response", "response_blink_theme")
|
|
if dpg.does_item_exist("ai_response_wrap_container"):
|
|
dpg.bind_item_theme("ai_response_wrap_container", "response_blink_theme")
|
|
except Exception:
|
|
pass
|
|
|
|
# Flush any comms entries queued from background threads
|
|
self._flush_pending_comms()
|
|
self._update_telemetry_panel()
|
|
|
|
dpg.render_dearpygui_frame()
|
|
|
|
# Save everything on exit
|
|
self._flush_to_project()
|
|
self._save_active_project()
|
|
self._flush_to_config()
|
|
save_config(self.config)
|
|
|
|
dpg.save_init_file("dpg_layout.ini")
|
|
session_logger.close_session()
|
|
ai_client.cleanup() # Destroy active API caches to stop billing
|
|
self.hook_server.stop()
|
|
dpg.destroy_context()
|
|
|
|
|
|
def main():
|
|
app = App()
|
|
app.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|
|
|
|
|
|
|