ux improvements

This commit is contained in:
2026-02-21 23:08:42 -05:00
parent 173e09059d
commit caa67206fa
5 changed files with 181 additions and 74 deletions

232
gui.py
View File

@@ -3,6 +3,8 @@ import dearpygui.dearpygui as dpg
import tomllib
import tomli_w
import threading
import time
import math
from pathlib import Path
from tkinter import filedialog, Tk
import aggregate
@@ -239,54 +241,71 @@ class ConfirmDialog:
ConfirmDialog._next_id += 1
self._uid = ConfirmDialog._next_id
self._tag = f"confirm_dlg_{self._uid}"
self._script = script
self._base_dir = base_dir
# 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."""
w, h = 700, 440
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)
"""Called from main thread only. Wrapped in try/except to prevent thread lockups."""
try:
w, h = 700, 440
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()
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)
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()
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):
self._script = dpg.get_value(f"{self._tag}_script")
try:
self._script = dpg.get_value(f"{self._tag}_script")
except Exception:
pass
self._approved = True
self._event.set()
dpg.delete_item(self._tag)
try:
dpg.delete_item(self._tag)
except Exception:
pass
def _cb_reject(self):
self._approved = False
self._event.set()
dpg.delete_item(self._tag)
try:
dpg.delete_item(self._tag)
except Exception:
pass
def wait(self) -> tuple[bool, str]:
"""Called from background thread. Blocks until user acts."""
@@ -362,6 +381,15 @@ class App:
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()
# Blink state
self._trigger_blink = False
self._is_blinking = False
self._blink_start_time = 0.0
session_logger.open_session()
ai_client.set_provider(self.current_provider, self.current_model)
@@ -452,8 +480,6 @@ class App:
def _refresh_project_widgets(self):
"""Push project-level values into the GUI widgets."""
proj = self.project
if dpg.does_item_exist("namespace"):
dpg.set_value("namespace", proj.get("output", {}).get("namespace", ""))
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"):
@@ -469,6 +495,8 @@ class App:
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))
def _save_active_project(self):
"""Write self.project to the active project .toml file."""
@@ -586,6 +614,16 @@ class App:
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):
@@ -671,8 +709,6 @@ class App:
# Output
proj.setdefault("output", {})
if dpg.does_item_exist("namespace"):
proj["output"]["namespace"] = dpg.get_value("namespace")
if dpg.does_item_exist("output_dir"):
proj["output"]["output_dir"] = dpg.get_value("output_dir")
@@ -702,6 +738,8 @@ class App:
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)."""
@@ -988,13 +1026,30 @@ class App:
ai_client.set_custom_system_prompt("\n\n".join(combined_sp))
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")
except Exception as e:
self._update_response(f"ERROR: {e}")
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()
@@ -1385,7 +1440,7 @@ class App:
tag="win_projects",
pos=(8, 8),
width=400,
height=260,
height=340,
no_close=True,
):
proj_meta = self.project.get("project", {})
@@ -1410,6 +1465,15 @@ class App:
)
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=-40, border=True):
pass
@@ -1418,35 +1482,11 @@ class App:
dpg.add_button(label="New Project", callback=self.cb_new_project)
dpg.add_button(label="Save All", callback=self.cb_save_config)
# ---- Config panel ----
with dpg.window(
label="Config",
tag="win_config",
pos=(8, 276),
width=400,
height=160,
no_close=True,
):
dpg.add_text("Namespace")
dpg.add_input_text(
tag="namespace",
default_value=self.project.get("output", {}).get("namespace", ""),
width=-1,
)
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=-220,
)
dpg.add_button(label="Browse Output Dir", callback=self.cb_browse_output)
# ---- Files panel ----
with dpg.window(
label="Files",
tag="win_files",
pos=(8, 444),
pos=(8, 356),
width=400,
height=400,
no_close=True,
@@ -1520,6 +1560,11 @@ class App:
dpg.add_button(label="+All", callback=self.cb_disc_expand_all)
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):
@@ -1695,13 +1740,63 @@ class App:
self._fetch_models(self.current_provider)
while dpg.is_dearpygui_running():
# Show any pending confirmation dialog on the main thread
# 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)
# Handle retro arcade blinking effect
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)
except Exception:
pass
else:
# Square-wave style retro blink (4 times per second)
val = math.sin(elapsed * 8 * math.pi)
alpha = 120 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.mvInputText):
dpg.add_theme_color(dpg.mvThemeCol_FrameBg, (0, 255, 0, alpha), tag="response_blink_color")
else:
dpg.set_value("response_blink_color", [0, 255, 0, alpha])
if dpg.does_item_exist("ai_response"):
try:
dpg.bind_item_theme("ai_response", "response_blink_theme")
except Exception:
pass
# Flush any comms entries queued from background threads
self._flush_pending_comms()
@@ -1725,4 +1820,3 @@ def main():
if __name__ == "__main__":
main()