ux improvements
This commit is contained in:
232
gui.py
232
gui.py
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user