From 47c581d037b063f37fc9229b3fb21376505660ef Mon Sep 17 00:00:00 2001 From: Ed_ Date: Sat, 21 Feb 2026 15:02:10 -0500 Subject: [PATCH] vibin --- .gitignore | 1 + MainContext.md | 33 +++++-- ai_client.py | 205 +++++++++++++++++++++++++++++++++++++---- config.toml | 14 +-- gui.py | 236 +++++++++++++++++++++++++++++++++++++++++------- shell_runner.py | 36 ++++++++ 6 files changed, 457 insertions(+), 68 deletions(-) create mode 100644 shell_runner.py diff --git a/.gitignore b/.gitignore index 3df2aee..464958e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__ uv.lock colorforth_bootslop_002.md +md_gen diff --git a/MainContext.md b/MainContext.md index 8cff20b..1a6a908 100644 --- a/MainContext.md +++ b/MainContext.md @@ -1,4 +1,4 @@ -**manual_slop** is a local GUI tool for manually curating and sending context to AI APIs. It aggregates files, screenshots, and discussion history into a structured markdown file and sends it to a chosen AI provider with a user-written message. +**manual_slop** is a local GUI tool for manually curating and sending context to AI APIs. It aggregates files, screenshots, and discussion history into a structured markdown file and sends it to a chosen AI provider with a user-written message. The AI can also execute PowerShell scripts within the project directory, with user confirmation required before each execution. **Stack:** - `dearpygui` - GUI with docking/floating/resizable panels @@ -8,9 +8,10 @@ - `uv` - package/env management **Files:** -- `gui.py` - main GUI, `App` class, all panels, all callbacks -- `ai_client.py` - unified provider wrapper, model listing, session management, send -- `aggregate.py` - reads config, collects files/screenshots/discussion, writes numbered `.md` files to `output_dir/md_gen/` +- `gui.py` - main GUI, `App` class, all panels, all callbacks, confirmation dialog +- `ai_client.py` - unified provider wrapper, model listing, session management, send, tool/function-call loop +- `aggregate.py` - reads config, collects files/screenshots/discussion, writes numbered `.md` files to `output_dir` +- `shell_runner.py` - subprocess wrapper that runs PowerShell scripts sandboxed to `base_dir`, returns stdout/stderr/exit code as a string - `config.toml` - namespace, output_dir, files paths+base_dir, screenshots paths+base_dir, discussion history array, ai provider+model - `credentials.toml` - gemini api_key, anthropic api_key @@ -22,21 +23,41 @@ - **Provider** - provider combo (gemini/anthropic), model listbox populated from API, fetch models button, status line - **Message** - multiline input, Gen+Send button, MD Only button, Reset session button - **Response** - readonly multiline displaying last AI response +- **Tool Calls** - scrollable log of every PowerShell tool call the AI made, showing script and result; Clear button + +**AI Tool Use (PowerShell):** +- Both Gemini and Anthropic are configured with a `run_powershell` tool/function declaration +- When the AI wants to edit or create files it emits a tool call with a `script` string +- `ai_client` runs a loop (max `MAX_TOOL_ROUNDS = 5`) feeding tool results back until the AI stops calling tools +- Before any script runs, `gui.py` shows a modal `ConfirmDialog` on the main thread; the background send thread blocks on a `threading.Event` until the user clicks Approve or Reject +- The dialog displays `base_dir`, shows the script in an editable text box (allowing last-second tweaks), and has Approve & Run / Reject buttons +- On approval the (possibly edited) script is passed to `shell_runner.run_powershell()` which prepends `Set-Location -LiteralPath ''` and runs it via `powershell -NoProfile -NonInteractive -Command` +- stdout, stderr, and exit code are returned to the AI as the tool result +- Rejections return `"USER REJECTED: command was not executed"` to the AI +- All tool calls (script + result/rejection) are appended to `_tool_log` and displayed in the Tool Calls panel **Data flow:** 1. GUI edits are held in `App` state lists (`self.files`, `self.screenshots`, `self.history`) and dpg widget values 2. `_flush_to_config()` pulls all widget values into `self.config` dict 3. `_do_generate()` calls `_flush_to_config()`, saves `config.toml`, calls `aggregate.run(config)` which writes the md and returns `(markdown_str, path)` -4. `cb_generate_send()` calls `_do_generate()` then threads a call to `ai_client.send(md, message)` +4. `cb_generate_send()` calls `_do_generate()` then threads a call to `ai_client.send(md, message, base_dir)` 5. `ai_client.send()` prepends the md as a `` block to the user message and sends via the active provider chat session -6. Sessions are stateful within a run (chat history maintained), `Reset` clears them +6. If the AI responds with tool calls, the loop handles them (with GUI confirmation) before returning the final text response +7. Sessions are stateful within a run (chat history maintained), `Reset` clears them and the tool log **Config persistence:** - Every send and save writes `config.toml` with current state including selected provider and model under `[ai]` - Discussion history is stored as a TOML array of strings in `[discussion] history` - File and screenshot paths are stored as TOML arrays, support absolute paths, relative paths from base_dir, and `**/*` wildcards +**Threading model:** +- DPG render loop runs on the main thread +- AI sends and model fetches run on daemon background threads +- `_pending_dialog` (guarded by a `threading.Lock`) is set by the background thread and consumed by the render loop each frame, calling `dialog.show()` on the main thread +- `dialog.wait()` blocks the background thread on a `threading.Event` until the user acts + **Known extension points:** - Add more providers by adding a section to `credentials.toml`, a `_list_*` and `_send_*` function in `ai_client.py`, and the provider name to the `PROVIDERS` list in `gui.py` - System prompt support could be added as a field in `config.toml` and passed in `ai_client.send()` - Discussion history excerpts could be individually toggleable for inclusion in the generated md +- `MAX_TOOL_ROUNDS` in `ai_client.py` caps agentic loops at 5 rounds; adjustable diff --git a/ai_client.py b/ai_client.py index 143034f..2706a1d 100644 --- a/ai_client.py +++ b/ai_client.py @@ -11,6 +11,13 @@ _gemini_chat = None _anthropic_client = None _anthropic_history: list[dict] = [] +# Injected by gui.py - called when AI wants to run a command. +# Signature: (script: str) -> str | None +# Returns the output string if approved, None if rejected. +confirm_and_run_callback = None + +MAX_TOOL_ROUNDS = 5 + def _load_credentials() -> dict: with open("credentials.toml", "rb") as f: return tomllib.load(f) @@ -61,21 +68,139 @@ def _list_anthropic_models() -> list[str]: models.append(m.id) return sorted(models) + +# --------------------------------------------------------- tool definition + +TOOL_NAME = "run_powershell" + +_ANTHROPIC_TOOLS = [ + { + "name": TOOL_NAME, + "description": ( + "Run a PowerShell script within the project base_dir. " + "Use this to create, edit, rename, or delete files and directories. " + "The working directory is set to base_dir automatically. " + "Always prefer targeted edits over full rewrites where possible. " + "stdout and stderr are returned to you as the result." + ), + "input_schema": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The PowerShell script to execute." + } + }, + "required": ["script"] + } + } +] + +def _gemini_tool_declaration(): + from google.genai import types + return types.Tool( + function_declarations=[ + types.FunctionDeclaration( + name=TOOL_NAME, + description=( + "Run a PowerShell script within the project base_dir. " + "Use this to create, edit, rename, or delete files and directories. " + "The working directory is set to base_dir automatically. " + "stdout and stderr are returned to you as the result." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "script": types.Schema( + type=types.Type.STRING, + description="The PowerShell script to execute." + ) + }, + required=["script"] + ) + ) + ] + ) + +def _run_script(script: str, base_dir: str) -> str: + """ + Delegate to the GUI confirmation callback. + Returns result string (stdout/stderr) or a rejection message. + """ + if confirm_and_run_callback is None: + return "ERROR: no confirmation handler registered" + result = confirm_and_run_callback(script, base_dir) + if result is None: + return "USER REJECTED: command was not executed" + return result + # ------------------------------------------------------------------ gemini -def _ensure_gemini_chat(): - global _gemini_client, _gemini_chat - if _gemini_chat is None: +def _ensure_gemini_client(): + global _gemini_client + if _gemini_client is None: from google import genai creds = _load_credentials() _gemini_client = genai.Client(api_key=creds["gemini"]["api_key"]) - _gemini_chat = _gemini_client.chats.create(model=_model) -def _send_gemini(md_content: str, user_message: str) -> str: - _ensure_gemini_chat() +def _send_gemini(md_content: str, user_message: str, base_dir: str) -> str: + global _gemini_chat + from google import genai + from google.genai import types + + _ensure_gemini_client() + + # Gemini chats don't support mutating tools after creation, + # so we recreate if None (reset_session clears it). + if _gemini_chat is None: + _gemini_chat = _gemini_client.chats.create( + model=_model, + config=types.GenerateContentConfig( + tools=[_gemini_tool_declaration()] + ) + ) + full_message = f"\n{md_content}\n\n\n{user_message}" + response = _gemini_chat.send_message(full_message) - return response.text + + for _ in range(MAX_TOOL_ROUNDS): + # Collect all function calls in this response + tool_calls = [ + part.function_call + for candidate in response.candidates + for part in candidate.content.parts + if part.function_call is not None + ] + if not tool_calls: + break + + # Execute each tool call and collect results + function_responses = [] + for fc in tool_calls: + if fc.name == TOOL_NAME: + script = fc.args.get("script", "") + output = _run_script(script, base_dir) + function_responses.append( + types.Part.from_function_response( + name=TOOL_NAME, + response={"output": output} + ) + ) + + if not function_responses: + break + + response = _gemini_chat.send_message(function_responses) + + # Extract text from final response + text_parts = [ + part.text + for candidate in response.candidates + for part in candidate.content.parts + if hasattr(part, "text") and part.text + ] + return "\n".join(text_parts) # ------------------------------------------------------------------ anthropic @@ -86,25 +211,65 @@ def _ensure_anthropic_client(): creds = _load_credentials() _anthropic_client = anthropic.Anthropic(api_key=creds["anthropic"]["api_key"]) -def _send_anthropic(md_content: str, user_message: str) -> str: +def _send_anthropic(md_content: str, user_message: str, base_dir: str) -> str: global _anthropic_history + import anthropic + _ensure_anthropic_client() + full_message = f"\n{md_content}\n\n\n{user_message}" _anthropic_history.append({"role": "user", "content": full_message}) - response = _anthropic_client.messages.create( - model=_model, - max_tokens=8096, - messages=_anthropic_history - ) - reply = response.content[0].text - _anthropic_history.append({"role": "assistant", "content": reply}) - return reply + + for _ in range(MAX_TOOL_ROUNDS): + response = _anthropic_client.messages.create( + model=_model, + max_tokens=8096, + tools=_ANTHROPIC_TOOLS, + messages=_anthropic_history + ) + + # Always record the assistant turn + _anthropic_history.append({ + "role": "assistant", + "content": response.content + }) + + if response.stop_reason != "tool_use": + break + + # Process tool calls + tool_results = [] + for block in response.content: + if block.type == "tool_use" and block.name == TOOL_NAME: + script = block.input.get("script", "") + output = _run_script(script, base_dir) + tool_results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": output + }) + + if not tool_results: + break + + _anthropic_history.append({ + "role": "user", + "content": tool_results + }) + + # Extract final text + text_parts = [ + block.text + for block in response.content + if hasattr(block, "text") and block.text + ] + return "\n".join(text_parts) # ------------------------------------------------------------------ unified send -def send(md_content: str, user_message: str) -> str: +def send(md_content: str, user_message: str, base_dir: str = ".") -> str: if _provider == "gemini": - return _send_gemini(md_content, user_message) + return _send_gemini(md_content, user_message, base_dir) elif _provider == "anthropic": - return _send_anthropic(md_content, user_message) - raise ValueError(f"unknown provider: {_provider}") + return _send_anthropic(md_content, user_message, base_dir) + raise ValueError(f"unknown provider: {_provider}") \ No newline at end of file diff --git a/config.toml b/config.toml index 2fcba56..26b01c2 100644 --- a/config.toml +++ b/config.toml @@ -5,13 +5,13 @@ output_dir = "./md_gen" [files] base_dir = "C:/projects/manual_slop" paths = [ - "config.toml", - "ai_client.py", - "aggregate.py", - "gemini.py", - "gui.py", - "pyproject.toml", - "MainContext.md" + "config.toml", + "ai_client.py", + "aggregate.py", + "gemini.py", + "gui.py", + "pyproject.toml", + "MainContext.md", ] [screenshots] diff --git a/gui.py b/gui.py index bfcb72d..abd6bab 100644 --- a/gui.py +++ b/gui.py @@ -1,4 +1,3 @@ -# gui.py import dearpygui.dearpygui as dpg import tomllib import tomli_w @@ -7,30 +6,108 @@ from pathlib import Path from tkinter import filedialog, Tk import aggregate import ai_client +import shell_runner CONFIG_PATH = Path("config.toml") PROVIDERS = ["gemini", "anthropic"] + 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 + +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}" + self._script = script + self._base_dir = base_dir + 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) + + 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) + + def _cb_approve(self): + self._script = dpg.get_value(f"{self._tag}_script") + self._approved = True + self._event.set() + dpg.delete_item(self._tag) + + def _cb_reject(self): + self._approved = False + self._event.set() + dpg.delete_item(self._tag) + + def wait(self) -> tuple[bool, str]: + """Called from background thread. Blocks until user acts.""" + self._event.wait() + return self._approved, self._script + + class App: def __init__(self): self.config = load_config() self.files: list[str] = list(self.config["files"].get("paths", [])) - self.screenshots: list[str] = list(self.config.get("screenshots", {}).get("paths", [])) - self.history: list[str] = list(self.config.get("discussion", {}).get("history", [])) + self.screenshots: list[str] = list( + self.config.get("screenshots", {}).get("paths", []) + ) + self.history: list[str] = list( + self.config.get("discussion", {}).get("history", []) + ) ai_cfg = self.config.get("ai", {}) self.current_provider: str = ai_cfg.get("provider", "gemini") @@ -44,9 +121,63 @@ class App: self.send_thread: threading.Thread | None = None self.models_thread: threading.Thread | None = None - ai_client.set_provider(self.current_provider, self.current_model) + self._pending_dialog: ConfirmDialog | None = None + self._pending_dialog_lock = threading.Lock() - # ------------------------------------------------------------------ helpers + self._tool_log: list[tuple[str, str]] = [] + + ai_client.set_provider(self.current_provider, self.current_model) + ai_client.confirm_and_run_callback = self._confirm_and_run + + # ---------------------------------------------------------------- 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._tool_log.append((script, result)) + self._rebuild_tool_log() + + def _rebuild_tool_log(self): + if not dpg.does_item_exist("tool_log_scroll"): + return + 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"): + dpg.add_text(f"Call #{i}", color=(140, 200, 255)) + dpg.add_input_text( + default_value=script, + multiline=True, + readonly=True, + width=-1, + height=72, + ) + dpg.add_text("Result:", color=(180, 255, 180)) + dpg.add_input_text( + default_value=result, + multiline=True, + readonly=True, + width=-1, + height=72, + ) + dpg.add_separator() + + # ---------------------------------------------------------------- helpers def _flush_to_config(self): self.config["output"]["namespace"] = dpg.get_value("namespace") @@ -62,7 +193,7 @@ class App: self.config["discussion"] = {"history": self.history} self.config["ai"] = { "provider": self.current_provider, - "model": self.current_model + "model": self.current_model, } def _do_generate(self) -> tuple[str, Path]: @@ -87,9 +218,7 @@ class App: 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) + label="x", width=24, callback=self._make_remove_file_cb(i) ) dpg.add_text(f) @@ -100,9 +229,7 @@ class App: 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) + label="x", width=24, callback=self._make_remove_shot_cb(i) ) dpg.add_text(s) @@ -133,6 +260,7 @@ class App: def _fetch_models(self, provider: str): self._update_status("fetching models...") + def do_fetch(): try: models = ai_client.list_models(provider) @@ -141,6 +269,7 @@ class App: 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() @@ -193,7 +322,10 @@ class App: root = hide_tk_root() paths = filedialog.askopenfilenames( title="Select Screenshots", - filetypes=[("Images", "*.png *.jpg *.jpeg *.gif *.bmp *.webp"), ("All", "*.*")] + filetypes=[ + ("Images", "*.png *.jpg *.jpeg *.gif *.bmp *.webp"), + ("All", "*.*"), + ], ) root.destroy() for p in paths: @@ -224,6 +356,8 @@ class App: def cb_reset_session(self): ai_client.reset_session() + self._tool_log.clear() + self._rebuild_tool_log() self._update_status("session reset") self._update_response("") @@ -237,12 +371,14 @@ class App: 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") def do_send(): try: - response = ai_client.send(self.last_md, user_msg) + response = ai_client.send(self.last_md, user_msg, base_dir) self._update_response(response) self._update_status("done") except Exception as e: @@ -270,6 +406,10 @@ class App: 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() + # ---------------------------------------------------------------- build ui def _build_ui(self): @@ -280,19 +420,19 @@ class App: pos=(8, 8), width=400, height=200, - no_close=True + no_close=True, ): dpg.add_text("Namespace") dpg.add_input_text( tag="namespace", default_value=self.config["output"]["namespace"], - width=-1 + width=-1, ) dpg.add_text("Output Dir") dpg.add_input_text( tag="output_dir", default_value=self.config["output"]["output_dir"], - width=-1 + width=-1, ) with dpg.group(horizontal=True): dpg.add_button(label="Browse Output Dir", callback=self.cb_browse_output) @@ -304,16 +444,18 @@ class App: pos=(8, 216), width=400, height=500, - no_close=True + no_close=True, ): dpg.add_text("Base Dir") with dpg.group(horizontal=True): dpg.add_input_text( tag="files_base_dir", default_value=self.config["files"]["base_dir"], - width=-220 + width=-220, + ) + dpg.add_button( + label="Browse##filesbase", callback=self.cb_browse_files_base ) - 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): @@ -330,16 +472,18 @@ class App: pos=(416, 8), width=400, height=500, - no_close=True + no_close=True, ): dpg.add_text("Base Dir") with dpg.group(horizontal=True): dpg.add_input_text( tag="shots_base_dir", default_value=self.config.get("screenshots", {}).get("base_dir", "."), - width=-220 + width=-220, + ) + dpg.add_button( + label="Browse##shotsbase", callback=self.cb_browse_shots_base ) - 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): @@ -354,14 +498,14 @@ class App: pos=(824, 8), width=400, height=500, - no_close=True + no_close=True, ): dpg.add_input_text( tag="discussion_box", default_value="\n---\n".join(self.history), multiline=True, width=-1, - height=-64 + height=-64, ) dpg.add_separator() with dpg.group(horizontal=True): @@ -375,7 +519,7 @@ class App: pos=(1232, 8), width=420, height=280, - no_close=True + no_close=True, ): dpg.add_text("Provider") dpg.add_combo( @@ -383,7 +527,7 @@ class App: items=PROVIDERS, default_value=self.current_provider, width=-1, - callback=self.cb_provider_changed + callback=self.cb_provider_changed, ) dpg.add_separator() with dpg.group(horizontal=True): @@ -395,7 +539,7 @@ class App: default_value=self.current_model, width=-1, num_items=6, - callback=self.cb_model_changed + callback=self.cb_model_changed, ) dpg.add_separator() dpg.add_text("Status: idle", tag="ai_status") @@ -406,13 +550,13 @@ class App: pos=(1232, 296), width=420, height=280, - no_close=True + no_close=True, ): dpg.add_input_text( tag="ai_input", multiline=True, width=-1, - height=-64 + height=-64, ) dpg.add_separator() with dpg.group(horizontal=True): @@ -425,21 +569,36 @@ class App: tag="win_response", pos=(1232, 584), width=420, - height=400, - no_close=True + height=300, + no_close=True, ): dpg.add_input_text( tag="ai_response", multiline=True, readonly=True, width=-1, - height=-1 + height=-1, ) + with dpg.window( + label="Tool Calls", + tag="win_tool_log", + pos=(1232, 892), + width=420, + height=300, + no_close=True, + ): + 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 + def run(self): dpg.create_context() dpg.configure_app(docking=True, docking_space=True) - dpg.create_viewport(title="manual slop", width=1600, height=900) + dpg.create_viewport(title="manual slop", width=1680, height=1200) dpg.setup_dearpygui() dpg.show_viewport() dpg.maximize_viewport() @@ -447,6 +606,13 @@ class App: self._fetch_models(self.current_provider) while dpg.is_dearpygui_running(): + # Show any pending confirmation dialog on the main thread + with self._pending_dialog_lock: + dialog = self._pending_dialog + self._pending_dialog = None + if dialog is not None: + dialog.show() + dpg.render_dearpygui_frame() dpg.destroy_context() @@ -458,4 +624,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/shell_runner.py b/shell_runner.py new file mode 100644 index 0000000..99ee506 --- /dev/null +++ b/shell_runner.py @@ -0,0 +1,36 @@ +import subprocess +import shlex +from pathlib import Path + +TIMEOUT_SECONDS = 60 + +def run_powershell(script: str, base_dir: str) -> str: + """ + Run a PowerShell script with working directory set to base_dir. + Returns a string combining stdout, stderr, and exit code. + Raises nothing - all errors are captured into the return string. + """ + # Prepend Set-Location so the AI doesn't need to worry about cwd + full_script = f"Set-Location -LiteralPath '{base_dir}'\n{script}" + + try: + result = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", full_script], + capture_output=True, + text=True, + timeout=TIMEOUT_SECONDS, + cwd=base_dir + ) + parts = [] + if result.stdout.strip(): + parts.append(f"STDOUT:\n{result.stdout.strip()}") + if result.stderr.strip(): + parts.append(f"STDERR:\n{result.stderr.strip()}") + parts.append(f"EXIT CODE: {result.returncode}") + return "\n".join(parts) if parts else f"EXIT CODE: {result.returncode}" + except subprocess.TimeoutExpired: + return f"ERROR: command timed out after {TIMEOUT_SECONDS}s" + except FileNotFoundError: + return "ERROR: powershell executable not found" + except Exception as e: + return f"ERROR: {e}"