#!/usr/bin/env python3
# Copyright 2026 Stephen William Wiley
# This software comes with absolutely no warranty or implied warranty.
# You may modify and redistribute it provided this notice remains intact.
# By using this software you agree to the terms of this license.

import sys, traceback, re

if __name__ == "__main__" and "--just-proxy" in sys.argv[1:]:
    # Windowed Windows executables have no Python streams, even with inherited
    # pipe handles. Restore the redirected child output before proxy imports.
    if sys.platform == "win32" and sys.stdout is None:
        import ctypes, ctypes.wintypes, msvcrt, os
        get_handle = ctypes.windll.kernel32.GetStdHandle
        get_handle.restype = ctypes.wintypes.HANDLE
        handle = get_handle(-11)  # STD_OUTPUT_HANDLE
        if handle and handle != ctypes.wintypes.HANDLE(-1).value:
            sys.stdout = os.fdopen(msvcrt.open_osfhandle(handle, os.O_WRONLY),
                                   "w", encoding="utf-8", errors="replace", buffering=1)
            sys.stderr = sys.stdout
        if sys.stdin is None: sys.stdin = open(os.devnull)
    import codex_proxy
    args = sys.argv[1:]
    args.remove("--just-proxy")
    sys.exit(codex_proxy.main(args))

import agent

def bind_readline(entry, changed, undo):
    """Readline editing and shared command history for the command entry."""
    killed = ""
    last_kill = False
    history = agent.get_readline_history()
    history_pos = len(history)
    history_draft = ""

    def navigate(event):
        nonlocal history_pos, history_draft, history
        if str(entry.cget("state")) != "normal": return "break"
        history = agent.refresh_readline_history()
        history_pos = min(history_pos, len(history))
        if not history: return "break"
        changed()
        if history_pos == len(history): history_draft = entry.get()
        history_pos = max(0, min(len(history), history_pos + (-1 if event.keysym == "Up" else 1)))
        entry.delete(0, "end")
        entry.insert(0, history[history_pos] if history_pos < len(history) else history_draft)
        entry.icursor("end")
        changed()
        return "break"

    entry.bind("<Up>", navigate)
    entry.bind("<Down>", navigate)

    def edit(action):
        nonlocal killed, last_kill
        if str(entry.cget("state")) != "normal": return "break"
        changed()
        text = entry.get()
        # Convert between Python and Tcl character indices (notably on Windows).
        cursor = len(entry.tk.call("string", "range", text, 0, entry.index("insert") - 1))
        def index(position):
            return entry.tk.call("string", "length", text[:position])
        def word_left(position):
            while position and not text[position - 1].isalnum(): position -= 1
            while position and text[position - 1].isalnum(): position -= 1
            return position
        def word_right(position):
            while position < len(text) and not text[position].isalnum(): position += 1
            while position < len(text) and text[position].isalnum(): position += 1
            return position
        start = end = cursor
        kill = action in ("kill_end", "kill_start", "kill_word", "kill_back_word", "rubout_word")
        if action == "home": cursor = 0
        elif action == "end": cursor = len(text)
        elif action == "left": cursor = max(0, cursor - 1)
        elif action == "right": cursor = min(len(text), cursor + 1)
        elif action == "word_left": cursor = word_left(cursor)
        elif action == "word_right": cursor = word_right(cursor)
        elif action == "kill_end": end = len(text)
        elif action == "kill_start": start = 0
        elif action == "kill_word": end = word_right(cursor)
        elif action == "kill_back_word": start = word_left(cursor)
        elif action == "rubout_word":
            while start and text[start - 1].isspace(): start -= 1
            while start and not text[start - 1].isspace(): start -= 1
        elif action == "backspace": start = max(0, cursor - 1)
        elif action == "yank":
            entry.insert(index(cursor), killed)
            cursor += len(killed)
            text = entry.get()
        elif action == "transpose" and len(text) > 1 and cursor:
            end = min(len(text), cursor + 1)
            start = end - 2
            replacement = text[start:end][::-1]
            entry.delete(index(start), index(end))
            entry.insert(index(start), replacement)
            cursor = end
            start = end
        elif action == "undo": undo()
        if start < end:
            if kill:
                fragment = text[start:end]
                killed = ((fragment + killed if start < cursor else killed + fragment)
                          if last_kill else fragment)
            entry.delete(index(start), index(end))
            cursor = start
        last_kill = kill and start < end
        if action != "undo":
            entry.selection_clear()
            entry.icursor(index(cursor))
        changed()
        return "break"

    bindings = {
        "<Control-a>": "home", "<Control-e>": "end",
        "<Control-b>": "left", "<Control-f>": "right",
        "<Alt-b>": "word_left", "<Alt-f>": "word_right",
        "<Control-k>": "kill_end", "<Control-u>": "kill_start",
        "<Control-w>": "rubout_word", "<Alt-d>": "kill_word",
        "<Alt-BackSpace>": "kill_back_word", "<Control-h>": "backspace",
        "<Control-y>": "yank", "<Control-t>": "transpose",
        "<Control-underscore>": "undo", "<Control-slash>": "undo",
    }
    for sequence, action in bindings.items():
        entry.bind(sequence, lambda event, action=action: edit(action))
    # Do not append later, unrelated kills to the previous killed text.
    def reset_kill(event):
        nonlocal last_kill
        if event.keysym not in ("Control_L", "Control_R", "Alt_L", "Alt_R"):
            last_kill = False
    entry.bind("<KeyPress>", reset_kill, add="+")
    return edit


def config_dialog(tk, root):
    from tkinter import messagebox
    import queue, threading
    result = None
    closed = False
    model_request = 0
    window = tk.Toplevel(root)
    window.title("Configuration")
    window.transient(root)
    tk.Label(window, text="Effective values (including defaults). OK saves and applies settings now.\n"
             "Other rc commands are retained; these settings override earlier settings.\n"
             "Environment overrides still apply at startup.", justify="left").pack(fill="x")
    frame = tk.Frame(window)
    frame.pack(fill="both", expand=True)
    canvas = tk.Canvas(frame)
    scroll = tk.Scrollbar(frame, command=canvas.yview)
    canvas.configure(yscrollcommand=scroll.set)
    scroll.pack(side="right", fill="y")
    canvas.pack(side="left", fill="both", expand=True)
    table = tk.Frame(canvas)
    item = canvas.create_window((0, 0), window=table, anchor="nw")
    table.bind("<Configure>", lambda event: canvas.configure(scrollregion=canvas.bbox("all")))
    canvas.bind("<Configure>", lambda event: canvas.itemconfigure(item, width=event.width))
    table.columnconfigure(1, weight=1)
    tk.Label(table, text="Key").grid(row=0, column=0, sticky="w")
    tk.Label(table, text="Value").grid(row=0, column=1, sticky="w")
    fields = {}
    for row, (key, value) in enumerate(agent.effective_config().items(), 1):
        tk.Label(table, text=key).grid(row=row, column=0, sticky="nw")
        if key == "model":
            from tkinter import ttk
            field = ttk.Combobox(table, width=60, values=(str(value), "Custom..."),
                                 postcommand=lambda: refresh_models())
            field.set(str(value))
            def custom_model(event):
                if fields["model"].get() == "Custom...":
                    fields["model"].set("")
                    fields["model"].focus_set()
            field.bind("<<ComboboxSelected>>", custom_model)
        else:
            field = tk.Text(table, height=3 if "\n" in str(value) else 1, width=60, wrap="word", undo=True)
            field.insert("1.0", str(value))
        field.grid(row=row, column=1, sticky="ew")
        fields[key] = field
        if "\n" not in str(value):
            field.bind("<Return>", lambda event: (save(), "break")[1])
    def field_value(key):
        return fields[key].get() if key == "model" else fields[key].get("1.0", "end-1c")

    def refresh_models():
        nonlocal model_request
        model_request += 1
        request = model_request
        endpoint = (field_value("api"), field_value("api_key"))
        replies = queue.Queue()
        model_status.configure(text="Loading models…")
        def query():
            try:
                models = [item["id"] for item in agent.query_models(*endpoint)
                          if isinstance(item, dict) and isinstance(item.get("id"), str)]
                replies.put((models, False))
            except Exception:
                replies.put(([], True))
        def poll():
            if closed or request != model_request:
                return
            if endpoint != (field_value("api"), field_value("api_key")):
                model_status.configure(text="Credentials changed; refresh models.")
                return
            try: models, failed = replies.get_nowait()
            except queue.Empty:
                window.after(100, poll)
                return
            current = field_value("model")
            choices = list(dict.fromkeys([current] + models))
            fields["model"].configure(values=[value for value in choices if value != "Custom..."] + ["Custom..."])
            model_status.configure(text="Model query failed; custom entry is available." if failed else "Models loaded; reopen dropdown to view.")
        threading.Thread(target=query, daemon=True).start()
        window.after(100, poll)

    def close():
        nonlocal closed
        closed = True
        window.destroy()

    def save():
        nonlocal result
        values = {key: field_value(key) for key in fields}
        try:
            if values.get("model") == "Custom...":
                raise ValueError("Enter a custom model name.")
            agent.save_config(values)
        except (OSError, ValueError) as e:
            messagebox.showerror("Configuration not saved", str(e), parent=window)
            return
        result = values
        close()
    buttons = tk.Frame(window)
    buttons.pack(fill="x")
    tk.Button(buttons, text="OK", command=save).pack(side="right")
    tk.Button(buttons, text="Cancel", command=close).pack(side="right")
    if "model" in fields:
        tk.Button(buttons, text="Refresh models", command=refresh_models).pack(side="left")
        model_status = tk.Label(buttons, text="")
        model_status.pack(side="left")
    window.protocol("WM_DELETE_WINDOW", close)
    window.bind("<Escape>", lambda event: close())
    window.geometry("800x600")
    window.grab_set()
    root.wait_window(window)
    return result


class TerminalDisplay:
    """Render the terminal controls emitted by agent.Agent, across arbitrary writes."""
    def __init__(self, primary, alternate_factory):
        self.primary = self.widget = primary
        self.alternate_factory = alternate_factory
        self.alternate = None
        self.escape = ""
        self.style = ()
        self.saved_style = ()
        self.primary_view = None
        self._prepare(primary)

    def _prepare(self, widget):
        # ANSI names, not a second role-to-color mapping.
        for code, color in ((31, "red"), (32, "green"), (34, "blue"),
                            (36, "cyan"), (90, "gray50")):
            widget.tag_configure(str(code), foreground=color)
        widget.mark_set("render", "end-1c")
        widget.mark_gravity("render", "right")

    def _alternate(self, enter):
        if enter and self.widget is self.primary:
            self.saved_style = self.style
            self.primary_view = self.primary.yview()
            self.primary.configure(state="disabled")
            if self.alternate is None:
                self.alternate = self.alternate_factory()
                self._prepare(self.alternate)
            self.primary.pack_forget()
            self.widget = self.alternate
            self.widget.configure(state="normal")
            self.widget.delete("1.0", "end")
            self.widget.mark_set("render", "1.0")
        elif not enter and self.widget is not self.primary:
            self.widget.pack_forget()
            self.widget.configure(state="disabled")
            self.widget = self.primary
            self.style = self.saved_style
        else:
            return
        # The GUI factory supplies the layout callback; tests need only Text.
        callback = getattr(self, "on_switch", None)
        if callback: callback(self.widget)
        if self.widget is self.primary and self.primary_view:
            self.primary.yview("moveto", self.primary_view[0])
        self.widget.configure(state="normal")

    def _control(self, sequence):
        widget = self.widget
        if sequence == "\033[?1049h": self._alternate(True)
        elif sequence == "\033[?1049l": self._alternate(False)
        elif sequence in ("\033[H", "\033[1;1H"):
            widget.mark_set("render", "1.0")
        elif sequence == "\033[2J":
            widget.delete("1.0", "end")
            widget.mark_set("render", "1.0")
        elif sequence in ("\033[K", "\033[0K"):
            widget.delete("render", "render lineend")
        elif sequence.startswith("\033[") and sequence.endswith("m"):
            for code in sequence[2:-1].split(";"):
                if code in ("", "0", "39"): self.style = ()
                elif code in ("31", "32", "34", "36", "90"): self.style = (code,)

    def _text(self, text):
        widget = self.widget
        # Runs never contain cursor controls or newlines. Replace only existing
        # cells on this line. Insertions beyond line end extend the transcript.
        end = widget.index("render+%dc" % len(text))
        if widget.compare(end, ">", "render lineend"):
            end = widget.index("render lineend")
        widget.delete("render", end)
        widget.insert("render", text, self.style)

    def write(self, text):
        self.widget.configure(state="normal")
        run = []
        def flush():
            if run:
                self._text("".join(run))
                run.clear()
        for char in text:
            if self.escape:
                self.escape += char
                if self.escape.startswith(("\033]", "\033P", "\033^", "\033_")):
                    if char == "\a" or self.escape.endswith("\033\\"): self.escape = ""
                elif self.escape == "\033[": pass
                elif (self.escape.startswith("\033[") and "@" <= char <= "~") or (not self.escape.startswith("\033[") and "0" <= char <= "~"):
                    self._control(self.escape)
                    self.escape = ""
                continue
            if char == "\033":
                flush()
                self.escape = char
            elif char in "\r\b\n\t":
                flush()
                widget = self.widget
                if char == "\r": widget.mark_set("render", "render linestart")
                elif char == "\b":
                    if widget.compare("render", ">", "render linestart"):
                        widget.mark_set("render", "render-1c")
                elif char == "\n":
                    if widget.compare("render lineend", "<", "end-1c"):
                        widget.mark_set("render", "render+1l linestart")
                    else:
                        widget.mark_set("render", "render lineend")
                        widget.insert("render", "\n", self.style)
                else:
                    column = int(widget.index("render").split(".")[1])
                    target = column + 8 - column % 8
                    end = int(widget.index("render lineend").split(".")[1])
                    widget.mark_set("render", "render lineend" if target > end else "render+%dc" % (target - column))
                    if target > end: self._text(" " * (target - end))
            elif char >= " " and char != "\x7f": run.append(char)
        flush()
        self.widget.configure(state="disabled")


def switch_workspace(backend, directory):
    """Worker-only transition. Retain the old backend and paths until commit."""
    import json, os
    directory = os.path.abspath(directory)
    previous = os.getcwd()
    if os.path.samefile(previous, directory): return backend
    if not os.path.isdir(directory): raise NotADirectoryError(directory)
    paths = agent.workspace_paths(directory)
    # load() is intentionally permissive for startup, not for a switch.
    if os.path.exists(paths["HISTPATH"]):
        with open(paths["HISTPATH"], encoding="utf8") as stream:
            history = json.load(stream)
        if not isinstance(history, list) or any(not isinstance(m, dict) or
                not isinstance(m.get("role"), str) for m in history):
            raise ValueError("Target history must be a list of messages with roles")
    old_paths = {key: getattr(agent, key) for key in paths}
    if not backend.save(): raise OSError("Previous history was not saved; workspace unchanged")
    backend.shutdown()
    try:
        agent.set_workspace(directory)
        replacement = agent.Agent()
        replacement.context_size = backend.context_size
        replacement.load()
    except BaseException:
        os.chdir(previous)
        for key, value in old_paths.items(): setattr(agent, key, value)
        raise
    # Save to recents
    recents_path = os.path.expanduser("~/.agentfiles/recents")
    recents = []
    if os.path.exists(recents_path):
        with open(recents_path, "r") as f: recents = f.read().splitlines()
    if directory in recents: recents.remove(directory)
    recents.insert(0, directory)
    with open(recents_path, "w") as f: f.write("\n".join(recents))
    return replacement


def run_ui(tk, root):
    import os, queue, threading
    from tkinter import ttk

    events, commands = queue.Queue(), queue.Queue()
    closing = threading.Event()
    cancellation = agent.Cancellation()
    workspace_operation = object()
    mail_operation = object()
    mail_session = None
    pending = None
    proxy_sessions = {}
    proxy_config = None
    ready = False
    completion_names, completion_state = (), None
    transcript_frame = tk.Frame(root)
    transcript_frame.pack(fill="both", expand=True)
    transcript = tk.Text(transcript_frame, wrap="word", state="disabled")
    scroll = tk.Scrollbar(transcript_frame, command=transcript.yview)
    transcript.configure(yscrollcommand=scroll.set)
    scroll.pack(side="right", fill="y")
    transcript.pack(fill="both", expand=True)
    entry = tk.Entry(root)
    entry.pack(fill="x")
    status = tk.StringVar(value="Starting")
    status_frame = tk.Frame(root)
    status_frame.pack(fill="x")
    tk.Label(status_frame, textvariable=status, anchor="w").pack(side="left", fill="x", expand=True)
    identity = tk.StringVar(value=os.path.basename(agent.AGENT_DIR))
    identity_label = tk.Label(status_frame, textvariable=identity, anchor="e")
    identity_label.pack(side="left")
    ttk.Style(root).configure("Context.Horizontal.TProgressbar", background="red")
    meter = ttk.Progressbar(status_frame, maximum=100, style="Context.Horizontal.TProgressbar")
    meter.pack(side="right", fill="x")

    context_percent = 0

    def set_status(text, percent=None, context=False):
        nonlocal context_percent
        status.set(text)
        if context: context_percent = percent
        show_context = context or percent is None
        meter.configure(value=max(0, min(100, context_percent if show_context else percent)),
                        style="Context.Horizontal.TProgressbar" if show_context else "Horizontal.TProgressbar")

    root.title(f"agent.py (melder) - {os.path.basename(agent.AGENT_DIR)}")
    root.geometry("900x600")

    class Output:
        encoding = "utf-8"
        errors = "replace"

        def __init__(self, original): self.original = original
        def write(self, text):
            if threading.current_thread() is worker:
                events.put(("output", text))
                return len(text)
            return self.original.write(text)
        def flush(self):
            if threading.current_thread() is not worker: self.original.flush()
        @property
        def presentation(self):
            return threading.current_thread() is worker or getattr(self.original, "presentation", False)
        def isatty(self):
            return False if threading.current_thread() is worker else self.original.isatty()
        def __getattr__(self, name): return getattr(self.original, name)

    class UI:
        def progress(self, percent):
            events.put(("progress", percent))

        def request(self, kind, text):
            if closing.is_set(): raise EOFError
            reply = queue.Queue()
            cancellation.active.clear()
            events.put((kind, (text, reply)))
            while not closing.is_set():
                try: answer = reply.get(timeout=0.1)
                except queue.Empty: continue
                if answer is None: raise EOFError
                cancellation.active.set()
                return answer
            raise EOFError

    def notify_ready(backend):
        names = tuple(dict.fromkeys((*agent.Agent.COMMANDS, *getattr(backend, "user_commands", {}))))
        ratio = backend.ctx_ratio(backend.history) if backend.context_size else None
        deletable = tuple(sorted(set(getattr(backend, "methods", {})) - getattr(backend, "base_tool_names", set())))
        events.put(("ready", (names, ratio, deletable)))

    def execute():
        backend = None
        try:
            agent.security_warning()
            backend = agent.Agent(agentrc=agent.AGENTRC)
            backend.load()
            initial = " ".join(sys.argv[1:])
            if initial: commands.put(initial)
            if not initial:
                notify_ready(backend)
            while not closing.is_set():
                text = commands.get()
                if text is None: break
                if isinstance(text, tuple) and text[0] is mail_operation:
                    _, session, action, message_id = text
                    result, error = None, None
                    cancellation.pending.clear()
                    cancellation.active.set()
                    try:
                        if action == "read":
                            result = agent.read_mailbox_message(message_id)
                        elif action == "send":
                            result = agent.mail(*message_id[:3], sender="user")
                        elif action == "delete":
                            agent.delete_mailbox_message(message_id)
                            result = agent.list_mailbox()
                        else:
                            result = agent.list_mailbox()
                    except (Exception, KeyboardInterrupt) as exc:
                        cancellation.pending.clear()
                        error = str(exc) or "Mail operation interrupted"
                        # A stale selection must be replaced, never retried by position.
                        if action != "send":
                            try: result = agent.list_mailbox()
                            except Exception: pass
                    finally:
                        cancellation.active.clear()
                        cancellation.pending.clear()
                    events.put(("mail_result", (session, action, result, error)))
                    notify_ready(backend)
                    continue
                events.put(("progress", None))
                switching = isinstance(text, tuple) and text[0] is workspace_operation
                events.put(("output", "(You) > " + ("Switch workspace/agent" if switching else "Apply configuration" if isinstance(text, (dict, tuple)) else text) + "\n"))
                cancellation.pending.clear()
                cancellation.active.set()
                try:
                    if switching:
                        replacement = switch_workspace(backend, text[1])
                        if replacement is not backend:
                            backend = replacement
                            events.put(("workspace", os.path.basename(agent.AGENT_DIR)))
                    elif isinstance(text, tuple):
                        session, values = text
                        try: agent.save_config(values)
                        except (OSError, ValueError) as e:
                            events.put(("proxy_output", (session, "Configuration not saved: " + str(e) + "\n")))
                        else: backend._apply_config(values)
                    elif isinstance(text, dict): backend._apply_config(text)
                    elif not backend.cmdline(text, False): break
                except KeyboardInterrupt:
                    backend.resettty("[interrupted]\n")
                except EOFError:
                    if closing.is_set(): break
                    backend.resettty()
                except Exception:
                    backend.resettty()
                    traceback.print_exc()
                finally:
                    cancellation.active.clear()
                    cancellation.pending.clear()
                notify_ready(backend)
        except Exception:
            traceback.print_exc()
        finally:
            try:
                if backend: backend.shutdown()
            except Exception:
                traceback.print_exc()
            finally:
                agent.save_readline()
                print("Goodbye.")
                events.put(("done", None))

    def submit(event=None, text=None):
        nonlocal pending, ready, completion_names, completion_state
        if closing.is_set() or (not ready and pending is None): return "break"
        if status.get() == "Editing": return "break"
        from_entry = text is None
        if from_entry:
            text = entry.get()
            entry.delete(0, "end")
            remember_entry()
        if from_entry and isinstance(text, str) and text and agent.HAVE_READLINE:
            agent.readline.add_history(text)
            agent.save_readline()
        if pending:
            pending.put(text)
            pending = None
        else:
            commands.put(text)
        ready = False
        set_status("Running")
        entry.configure(state="disabled")
        update_menus()
        return "break"

    interrupt_button = tk.Button(status_frame, text="Interrupt", state="disabled",
                                 borderwidth=0, highlightthickness=0, padx=0, pady=0,
                                 command=cancellation.interrupt)
    interrupt_button.pack(side="left", before=identity_label)

    def interrupt_key(event):
        if cancellation.active.is_set():
            cancellation.interrupt()
            return "break"

    # A private bindtag precedes widget and class bindings, including Text Copy.
    # Map also covers editors and other widgets created after startup.
    interrupt_tag = "Interrupt" + root._w
    root.bind_class(interrupt_tag, "<Control-c>", interrupt_key)
    root.bind_class(interrupt_tag, "<Control-C>", interrupt_key)
    def bind_interrupt(widget):
        if isinstance(widget, tk.Misc):
            if interrupt_tag not in widget.bindtags():
                widget.bindtags((interrupt_tag,) + widget.bindtags())
            for child in widget.winfo_children():
                bind_interrupt(child)
    bind_interrupt(root)
    root.bind_all("<Map>", lambda event: bind_interrupt(event.widget), add="+")

    def close():
        nonlocal pending
        closing.set()
        stop_proxies()
        if pending:
            pending.put(None)
            pending = None
        commands.put(None)
        entry.configure(state="disabled")
        set_status("Closing after current operation and shutdown")
        update_menus()

    # Commands remain worker-owned. Dialogs collect arguments, never confirmations.
    from tkinter import simpledialog, filedialog, messagebox
    deletable_tools = ()
    tool_window = import_window = None
    menubar = tk.Menu(root)
    file_menu = tk.Menu(menubar, tearoff=False)
    edit_menu = tk.Menu(menubar, tearoff=False)
    command_menu = tk.Menu(menubar, tearoff=False)
    mail_menu = tk.Menu(menubar, tearoff=False)
    menubar.add_cascade(label="File", underline=0, menu=file_menu)
    menubar.add_cascade(label="Edit", underline=0, menu=edit_menu)
    menubar.add_cascade(label="Command", underline=0, menu=command_menu)
    menubar.add_cascade(label="Mail", underline=0, menu=mail_menu)
    root.configure(menu=menubar)
    argument_commands = {"import", "source", "set", "meld", "newtool", "deltools", "loop", "export", "forget", "system", "edit"}

    def compose_mail():
        nonlocal ready
        if not ready or pending is not None or closing.is_set(): return
        ready = False
        entry.configure(state="disabled")
        update_menus()
        def done():
            nonlocal ready
            ready = not closing.is_set()
            if ready: entry.configure(state="normal")
            update_menus()
            apply_proxy_config()
        mail_dialog(root, "Compose", done=done)

    def mail_dialog(parent, title, to="", subject="", body="", done=None):
        window = tk.Toplevel(parent)
        window.title(title)
        window.transient(parent)
        tk.Label(window, text="Recipient").grid(row=0, column=0, sticky="w")
        agent_root = os.path.dirname(os.path.abspath(agent.AGENT_DIR))
        try:
            names = os.listdir(agent_root)
        except OSError:
            names = []
        recipients = ["user"] + sorted(
            name for name in names
            if os.path.isdir(os.path.join(agent_root, name)) and
            os.path.realpath(os.path.join(agent_root, name)) == os.path.join(os.path.realpath(agent_root), name)
        )
        to_field = ttk.Combobox(window, width=60, values=recipients + ["Custom..."])
        to_field.grid(row=0, column=1, sticky="ew")
        to_field.insert(0, to)
        def custom_recipient(event):
            if to_field.get() == "Custom...":
                to_field.set("")
                to_field.focus_set()
        to_field.bind("<<ComboboxSelected>>", custom_recipient)
        tk.Label(window, text="Subject").grid(row=1, column=0, sticky="w")
        subject_field = tk.Entry(window, width=60)
        subject_field.grid(row=1, column=1, sticky="ew")
        subject_field.insert(0, subject)
        body_field = tk.Text(window, width=70, height=20, wrap="word")
        body_field.grid(row=2, column=0, columnspan=2, sticky="nsew")
        body_field.insert("1.0", body)
        window.columnconfigure(1, weight=1)
        window.rowconfigure(2, weight=1)
        def finish():
            if done: done()
            window.destroy()
        def send():
            nonlocal ready
            if to_field.get().strip():
                commands.put((mail_operation, None, "send", (to_field.get().strip(), subject_field.get(), body_field.get("1.0", "end-1c"))))
                set_status("Sending mail")
                ready = False
                entry.configure(state="disabled")
                update_menus()
                window.destroy()
                return
            finish()
        tk.Button(window, text="Send", command=send).grid(row=3, column=1, sticky="e")
        window.protocol("WM_DELETE_WINDOW", finish)
        window.bind("<Escape>", lambda event: finish())
        return window

    def mail_request(session, action, message_id=None):
        nonlocal ready
        if (not ready or pending is not None or closing.is_set()
                or session is not mail_session or not session["window"].winfo_exists()): return
        ready = False
        session["busy"] = True
        session["status"].set("Loading")
        entry.configure(state="disabled")
        set_status("Running")
        update_menus()
        commands.put((mail_operation, session, action, message_id))

    def readmail():
        nonlocal mail_session
        if not ready or pending is not None or closing.is_set(): return
        if mail_session is not None and mail_session["window"].winfo_exists():
            mail_session["window"].lift()
            return
        window = tk.Toplevel(root)
        window.title("Readmail")
        window.geometry("760x400")
        table = ttk.Treeview(window, columns=("sender", "subject", "body"),
                             show="headings", selectmode="browse")
        for column, label, width in (("sender", "Sender", 100),
                                     ("subject", "Subject", 200),
                                     ("body", "Body", 500)):
            table.heading(column, text=label)
            table.column(column, width=width, stretch=False)
        scroll = ttk.Scrollbar(window, command=table.yview)
        table.configure(yscrollcommand=scroll.set)
        scroll.pack(side="right", fill="y")
        table.pack(fill="both", expand=True)
        state = tk.StringVar(value="")
        tk.Label(window, textvariable=state, anchor="w").pack(fill="x")
        session = {"window": window, "tree": table, "status": state, "busy": False,
                   "rows": {}, "buttons": [], "viewers": []}
        mail_session = session

        def selected(action):
            selection = table.selection()
            if selection:
                mail_request(session, action, session["rows"].get(selection[0]))
            return "break"

        def open_selected(event=None):
            return selected("read")

        table.bind("<Double-1>", open_selected)
        table.bind("<Return>", open_selected)
        table.bind("<Delete>", lambda event: selected("delete"))
        window.bind("<Escape>", lambda event: window.destroy())
        mail_request(session, "list")

    def mail_result(session, action, result, error):
        if session is None:
            if error: events.put(("output", "Mail send failed: " + error + "\n"))
            else: events.put(("output", str(result or "Mail sent.") + "\n"))
            return
        if closing.is_set() or session is not mail_session or not session["window"].winfo_exists(): return
        session["busy"] = False
        session["status"].set(error or "")
        if action == "read" and not error:
            window = tk.Toplevel(session["window"])
            window.title("Message")
            window.geometry("700x500")
            session["viewers"].append(window)
            controls = tk.Frame(window)
            controls.pack(side="bottom", fill="x")
            text = tk.Text(window, wrap="word")
            scroll = ttk.Scrollbar(window, command=text.yview)
            text.configure(yscrollcommand=scroll.set)
            scroll.pack(side="right", fill="y")
            text.pack(fill="both", expand=True)
            text.insert("1.0", "\n".join(label + ": " + result.get(key, "") for label, key in
                        (("From", "from"), ("To", "to"), ("Subject", "subject"), ("Date", "date")))
                        + "\n\n" + result["body"])
            text.configure(state="disabled")
            def reply():
                subject = result.get("subject", "")
                if not subject.upper().startswith("RE:"):
                    subject = "RE: " + subject
                body = "\n\nOn the previous message:\n" + "\n".join("> " + line for line in result.get("body", "").splitlines())
                sender = result.get("from", "").strip()
                match = re.search(r"<([^<>]+)>", sender)
                recipient = match.group(1).strip() if match else sender
                mail_dialog(window, "Reply", recipient, subject, body)
            def delete_message():
                mail_request(session, "delete", result.get("id"))
                window.destroy()
            tk.Button(controls, text="Reply", command=reply).pack(side="left")
            tk.Button(controls, text="Delete", command=delete_message).pack(side="left")
            window.bind("<Escape>", lambda event: window.destroy())
        elif action != "send" and result is not None:
            table = session["tree"]
            table.delete(*table.get_children())
            session["rows"].clear()
            for message in result:
                row = table.insert("", "end", values=(message.get("from", "")[:10],
                                                       message.get("subject", "")[:20],
                                                       message.get("body", "")[:50]))
                session["rows"][row] = message["id"]

    mail_menu.add_command(label="Compose…", command=compose_mail)
    mail_menu.add_command(label="Readmail", command=readmail)

    def delete_tools():
        nonlocal tool_window
        if tool_window is not None and tool_window.winfo_exists():
            tool_window.lift()
            return
        tool_window = tk.Toplevel(root)
        tool_window.title("Delete tools")
        tool_window.transient(root)
        refresh_tools()

    def refresh_tools():
        if tool_window is None or not tool_window.winfo_exists(): return
        for child in tool_window.winfo_children(): child.destroy()
        tk.Label(tool_window, text="Results and errors appear in the transcript.").pack()
        frame = tk.Frame(tool_window)
        frame.pack(fill="both", expand=True)
        canvas = tk.Canvas(frame)
        scroll = tk.Scrollbar(frame, command=canvas.yview)
        canvas.configure(yscrollcommand=scroll.set)
        scroll.pack(side="right", fill="y")
        canvas.pack(side="left", fill="both", expand=True)
        table = tk.Frame(canvas)
        item = canvas.create_window((0, 0), window=table, anchor="nw")
        table.bind("<Configure>", lambda event: canvas.configure(scrollregion=canvas.bbox("all")))
        canvas.bind("<Configure>", lambda event: canvas.itemconfigure(item, width=event.width))
        if not deletable_tools:
            tk.Label(table, text="No deletable tools.").pack()
        for name in deletable_tools:
            row = tk.Frame(table)
            row.pack(fill="x")
            tk.Button(row, text="Delete", state="normal" if ready and pending is None and not closing.is_set() else "disabled",
                      command=lambda name=name: submit(text="/deltools " + name)).pack(side="right")
            tk.Label(row, text=name, anchor="w").pack(side="left", fill="x", expand=True)
        tk.Button(tool_window, text="Close", command=tool_window.destroy).pack()

    def import_tools():
        nonlocal import_window
        if import_window is not None and import_window.winfo_exists():
            import_window.lift()
            return
        import_window = tk.Toplevel(root)
        window = import_window
        window.title("Import tools")
        window.transient(root)
        tk.Label(window, text="Python module:").pack()
        module = tk.Entry(window)
        module.pack(fill="x")
        def finish(value):
            if not window.winfo_exists() or not value or not ready or pending is not None or closing.is_set(): return
            window.destroy()
            submit(text="/import " + value)
        tk.Button(window, text="Import module", command=lambda: finish(module.get())).pack()
        tk.Button(window, text="Python file…", command=lambda: finish(filedialog.askopenfilename(
            parent=root, title="Import Python tools", filetypes=(("Python files", "*.py"), ("All files", "*"))))).pack()
        tk.Button(window, text="Cancel", command=window.destroy).pack()
        module.focus_set()

    def invoke(name):
        if not ready or pending is not None or closing.is_set(): return "break"
        text = "/" + name
        if name == "deltools":
            delete_tools()
            return "break"
        if name == "import":
            import_tools()
            return "break"
        if name == "set":
            configure()
            return "break"
        if name in ("source", "meld", "export"):
            if name == "export":
                arg = filedialog.asksaveasfilename(parent=root, title="Export history and memories",
                    defaultextension=".json", filetypes=(("JSON files", "*.json"), ("All files", "*")))
            else:
                arg = filedialog.askopenfilename(parent=root, title="Source configuration" if name == "source" else "Merge exported history")
            if not arg: return "break"
            text += " " + arg
        elif name == "loop":
            arg = simpledialog.askinteger("Command /loop", "Interval in seconds:", initialvalue=60, minvalue=1, parent=root)
            if arg is None: return "break"
            text += " " + str(arg)
        elif (name in argument_commands and name not in ("newtool", "edit")) or name not in agent.Agent.COMMANDS:
            arg = simpledialog.askstring("Command /" + name,
                                         (getattr(agent.Agent.COMMANDS.get(name), "__doc__", None) or name)
                                         + "\nArguments:", parent=root)
            if arg is None: return "break"
            text += " " + arg
        # Native dialogs run nested Tk events. Recheck ownership after closing.
        if ready and pending is None and not closing.is_set(): submit(text=text)
        return "break"

    def stop_proxies():
        # Never wait for children on the Tk thread. Readers reap them.
        for session in proxy_sessions.values():
            process = session["process"]
            if process is not None and process.poll() is None:
                try: process.terminate()
                except OSError: pass
                def reap(child=process):
                    import subprocess
                    try: child.wait(timeout=2)
                    except subprocess.TimeoutExpired:
                        try: child.kill()
                        except OSError: pass
                        child.wait()
                threading.Thread(target=reap, name="proxy-shutdown", daemon=True).start()

    def proxy_output(session, text):
        widget = session["output"]
        view = widget.yview()
        widget.configure(state="normal")
        widget.insert("end", text)
        widget.configure(state="disabled")
        if not view or view[1] == 1.0: widget.see("end")

    def apply_proxy_config():
        nonlocal proxy_config
        if proxy_config is None or not ready or pending is not None or closing.is_set(): return
        session, api = proxy_config
        proxy_config = None
        if session["process"].poll() is not None: return
        # Snapshot only at the idle boundary, not when the process was launched.
        values = agent.effective_config()
        values.update(api=api, api_key="dummy")
        # Persistence and application share worker ownership, including failures.
        submit(text=(session, values))

    def run_proxy(login=False, entrypoint=os.path.abspath(__file__)):
        nonlocal proxy_config
        if closing.is_set(): return
        import subprocess
        import codex_proxy
        session = proxy_sessions.get(login)
        if session is not None:
            session["window"].deiconify()
            session["window"].lift()
            if session["process"] is not None and session["process"].poll() is None: return
        else:
            window = tk.Toplevel(root)
            window.title("Codex login" if login else "Codex proxy")
            window.geometry("800x400")
            window.protocol("WM_DELETE_WINDOW", window.withdraw)
            output = tk.Text(window, wrap="word", state="disabled")
            scrollbar = tk.Scrollbar(window, command=output.yview)
            output.configure(yscrollcommand=scrollbar.set)
            scrollbar.pack(side="right", fill="y")
            output.pack(fill="both", expand=True)
            session = {"window": window, "output": output, "process": None}
            proxy_sessions[login] = session
        command = [sys.executable]
        if not getattr(sys, "frozen", False):
            command.extend(["-u", entrypoint])
        command.append("--just-proxy")
        if login: command.append("--login")
        env = os.environ.copy()
        env.update(PYTHONUNBUFFERED="1", PYTHONIOENCODING="utf-8")
        try:
            process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                stdin=subprocess.DEVNULL, env=env,
                creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0)
        except OSError as e:
            proxy_output(session, "Cannot start process: " + str(e) + "\n")
            return
        if proxy_config is not None and proxy_config[0] is session:
            proxy_config = None
        session["process"] = process
        proxy_output(session, "Starting login…\n" if login else "Starting proxy…\n")

        def read_output():
            import codecs
            decoder = codecs.getincrementaldecoder("utf-8")("replace")
            try:
                # read1 preserves prompts and partial lines without waiting for EOF.
                while True:
                    data = process.stdout.read1(4096)
                    if not data: break
                    events.put(("proxy_output", (session, decoder.decode(data))))
                events.put(("proxy_output", (session, decoder.decode(b"", final=True))))
            except (OSError, ValueError) as e:
                events.put(("proxy_output", (session, str(e) + "\n")))
            finally:
                process.stdout.close()
                code = process.wait()
                events.put(("proxy_output", (session, "Process exited: %s\n" % code)))

        def check_health():
            import json, time, urllib.request
            api = "http://%s:%s/v1" % (codex_proxy.HOST, codex_proxy.PORT)
            # Match the owned child, not a different server already on this port.
            opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
            deadline = time.monotonic() + 120
            while not closing.is_set() and process.poll() is None and time.monotonic() < deadline:
                try:
                    with opener.open(api + "/health", timeout=1) as response:
                        health = json.load(response)
                    if isinstance(health, dict) and health.get("ok") is True and health.get("pid") == process.pid and process.poll() is None:
                        events.put(("proxy_ready", (session, process, api)))
                        return
                except (OSError, ValueError): pass
                if closing.wait(0.2): return
            if not closing.is_set() and process.poll() is None:
                events.put(("proxy_output", (session, "Readiness timed out; configuration unchanged.\n")))
                try: process.terminate()
                except OSError: pass

        threading.Thread(target=read_output, name="proxy-output", daemon=True).start()
        if not login:
            threading.Thread(target=check_health, name="proxy-health", daemon=True).start()

    def codex_login():
        run_proxy(login=True)

    def new_workspace(event=None):
        name=simpledialog.askstring("Name","Name of new agent")
        if name:
            path = os.path.expanduser(f"~/.agents/{name.strip()}")
            os.makedirs(path, exist_ok=True)
            choose_workspace(directory=path)


    def choose_workspace(event=None,directory=""):
        nonlocal ready
        if not ready or pending is not None or closing.is_set() or status.get() == "Editing":
            return "break"
        ready = False
        entry.configure(state="disabled")
        update_menus()
        try:
            if directory == "":
                directory = filedialog.askdirectory(parent=root, mustexist=True,
                    title="Switch workspace/agent (directory basename identifies agent)")
            if directory and not closing.is_set():
                paths = agent.workspace_paths(directory)
                warnings = []
                if paths["AGENT_DIR"] == agent.AGENT_DIR:
                    warnings.append("This directory shares the current agent storage.")
                if agent._HISTORY_OVERRIDE is not None:
                    warnings.append("Explicit history override remains shared: " + paths["HISTPATH"])
                if warnings and not messagebox.askokcancel("Switch workspace/agent",
                        "\n".join(warnings), parent=root):
                    directory = ""
        except Exception as error:
            directory = ""
            if not closing.is_set():
                messagebox.showerror("Workspace selection failed", str(error), parent=root)
        finally:
            if not closing.is_set():
                if directory:
                    commands.put((workspace_operation, directory))
                    set_status("Running")
                else:
                    ready = True
                    entry.configure(state="normal")
                update_menus()
                if not directory: apply_proxy_config()
        return "break"

    file_actions = (("New agent…", new_workspace, "Ctrl+N"),
                    ("Switch agent…", choose_workspace, "Ctrl+O"),
                    ("New conversation", lambda: invoke("new"), "Ctrl+Shift+N"),
                    ("Export history and memories…", lambda: invoke("export"), "Ctrl+S"),
                    ("Merge exported history…", lambda: invoke("meld"), "Ctrl+Shift+O"),
                    ("Source configuration…", lambda: invoke("source"), ""),
                    ("Import tools…", lambda: invoke("import"), ""),
                    ("run_proxy", run_proxy, ""),
                    ("codex_login", codex_login, ""))
    def update_recent_menu():
        recents_path = os.path.expanduser("~/.agentfiles/recents")
        paths = []
        if os.path.exists(recents_path):
            with open(recents_path, "r") as f: paths = f.read().splitlines()
        try:
            file_menu.delete(file_menu.index("Open Recent"))
        except tk.TclError:
            pass
        recent_menu = tk.Menu(file_menu, tearoff=0)
        if paths:
            for p in paths:
                recent_menu.add_command(label=p, command=lambda path=p: choose_workspace(directory=path))
        else:
            recent_menu.add_command(label="No recent workspaces", state="disabled")
        file_menu.insert_cascade(len(file_actions), label="Open Recent", menu=recent_menu)
    def configure():
        nonlocal ready
        if ready and pending is None and not closing.is_set():
            # Reserve the idle worker while the dialog runs nested Tk events.
            ready = False
            update_menus()
            try:
                values = config_dialog(tk, root)
            finally:
                ready = not closing.is_set()
                update_menus()
            if values is not None: submit(text=values)
            apply_proxy_config()
    for label, callback, accel in file_actions:
        file_menu.add_command(label=label, command=callback, accelerator=accel if accel else "")
    file_menu.add_separator()
    file_menu.add_command(label="Edit configuration…", command=configure)
    file_menu.add_separator()
    file_menu.add_command(label="Exit", underline=1, accelerator="Ctrl+Q", command=close)

    # Entry has no native undo. Keep its local edit history; Text uses Tk undo.
    entry_history, entry_redo = [""], []
    def remember_entry(event=None):
        value = entry.get()
        if value != entry_history[-1]:
            entry_history.append(value)
            entry_redo.clear()
    entry.bind("<KeyRelease>", remember_entry)

    def complete_entry(event=None):
        nonlocal completion_state
        if not ready or pending is not None or closing.is_set(): return "break"
        if str(entry.cget("state")) != "normal": return "break"
        remember_entry()
        # Tk indices count Tcl characters, which can differ for non-BMP text.
        cursor = len(entry.tk.call("string", "range", entry.get(), 0, entry.index("insert") - 1))
        text, cursor, completion_state = agent.completion_edit(
            entry.get(), cursor, completion_names, completion_state)
        if text != entry.get():
            entry.delete(0, "end")
            entry.insert(0, text)
            entry.icursor(entry.tk.call("string", "length", text[:cursor]))
            remember_entry()
        return "break"
    entry.bind("<Tab>", complete_entry)

    def edit_action(action):
        widget = root.focus_get()
        if widget is None: return "break"
        if widget is entry and action in ("Undo", "Redo"):
            if str(widget.cget("state")) == "disabled": return "break"
            remember_entry()
            if action == "Undo" and len(entry_history) > 1:
                entry_redo.append(entry_history.pop())
            elif action == "Redo" and entry_redo:
                entry_history.append(entry_redo.pop())
            if str(widget.cget("state")) != "disabled":
                widget.delete(0, "end")
                widget.insert(0, entry_history[-1])
        else:
            widget.event_generate("<<" + action + ">>")
            if widget is entry: remember_entry()
        update_edit_menu()
        return "break"

    edit_actions = (("Undo", "Ctrl+Z"), ("Redo", ""),
                    ("Cut", "Ctrl+Shift+X"), ("Copy", "Ctrl+Shift+C"),
                    ("Paste", "Ctrl+Shift+V"), ("SelectAll", ""))
    def update_edit_menu():
        widget = root.focus_get()
        try: editable = widget is not None and str(widget.cget("state")) == "normal"
        except tk.TclError: editable = False
        selected = False
        if widget is not None:
            try:
                selected = bool(widget.tag_ranges("sel")) if widget.winfo_class() == "Text" else widget.selection_present()
            except (AttributeError, tk.TclError): pass
        for index, (action, _) in enumerate(edit_actions):
            enabled = widget is not None
            if action in ("Cut", "Copy"): enabled = selected and (editable or action == "Copy")
            elif action in ("Undo", "Redo"):
                enabled = editable
                if widget is entry:
                    enabled = enabled and (len(entry_history) > 1 if action == "Undo" else bool(entry_redo))
                elif enabled:
                    try: enabled = bool(widget.tk.call(widget._w, "edit", "can" + action.lower()))
                    except tk.TclError: enabled = False
            elif action == "Paste":
                enabled = editable
                if enabled:
                    try: widget.clipboard_get()
                    except tk.TclError: enabled = False
            edit_menu.entryconfigure(index, state="normal" if enabled else "disabled")
    for action, accelerator in edit_actions:
        edit_menu.add_command(label="Select All" if action == "SelectAll" else action,
                              underline=0, accelerator=accelerator,
                              command=lambda action=action: edit_action(action))
    edit_menu.add_separator()
    edit_menu.add_command(label="Interrupt", accelerator="Ctrl+C", state="disabled",
                          command=cancellation.interrupt)
    edit_menu.configure(postcommand=update_edit_menu)

    def update_menus(names=None):
        update_recent_menu()
        refresh_tools()
        if import_window is not None and import_window.winfo_exists():
            for child in import_window.winfo_children():
                if isinstance(child, tk.Button) and child.cget("text") != "Cancel":
                    child.configure(state="normal" if ready and pending is None and not closing.is_set() else "disabled")
        enabled = ready and pending is None and not closing.is_set()
        for index in range(2):
            mail_menu.entryconfigure(index, state="normal" if enabled else "disabled")
        if mail_session is not None and mail_session["window"].winfo_exists():
            for button in mail_session["buttons"]:
                button.configure(state="normal" if enabled and not mail_session["busy"] else "disabled")
        if names is not None:
            command_menu.delete(0, "end")
            for name in names:
                command_menu.add_command(label="/" + name + ("…" if name in argument_commands or name not in agent.Agent.COMMANDS else ""),
                                         command=lambda name=name: invoke(name))
        for index, (_, callback, _) in enumerate(file_actions):
            available = not closing.is_set() if callback in (run_proxy, codex_login) else enabled
            file_menu.entryconfigure(index, state="normal" if available else "disabled")
        file_menu.entryconfigure(file_menu.index("Edit configuration…"), state="normal" if enabled else "disabled")
        end = command_menu.index("end")
        if end is not None:
            for index in range(end + 1):
                command_menu.entryconfigure(index, state="normal" if enabled else "disabled")
    root.bind_all("<Control-n>", new_workspace)
    root.bind_all("<Control-o>", choose_workspace)
    root.bind_all("<Control-s>", lambda event: invoke("export"))
    root.bind_all("<Control-Shift-N>", lambda event: invoke("new"))
    root.bind_all("<Control-Shift-O>", lambda event: invoke("meld"))
    root.bind_all("<Control-q>", lambda event: (close(), "break")[1])
    root.bind_all("<Control-d>", lambda event: (close(), "break")[1])
    entry.bind("<Control-z>", lambda event: edit_action("Undo"))
    readline_edit = bind_readline(entry, remember_entry, lambda: edit_action("Undo"))
    edit_menu.add_separator()
    readline_actions = (
        ("Beginning of line", "Ctrl+A", "home"),
        ("End of line", "Ctrl+E", "end"),
        ("Backward character", "Ctrl+B", "left"),
        ("Forward character", "Ctrl+F", "right"),
        ("Backward word", "Alt+B", "word_left"),
        ("Forward word", "Alt+F", "word_right"),
        ("Kill to end of line", "Ctrl+K", "kill_end"),
        ("Kill to beginning of line", "Ctrl+U", "kill_start"),
        ("Kill previous whitespace-delimited word", "Ctrl+W", "rubout_word"),
        ("Kill next word", "Alt+D", "kill_word"),
        ("Kill previous word", "Alt+Backspace", "kill_back_word"),
        ("Delete previous character", "Ctrl+H", "backspace"),
        ("Yank killed text", "Ctrl+Y", "yank"),
        ("Transpose characters", "Ctrl+T", "transpose"),
        ("Readline undo", "Ctrl+_ / Ctrl+/", "undo"),
    )
    def invoke_readline(action):
        entry.focus_set()
        return readline_edit(action)
    for label, accelerator, action in readline_actions:
        edit_menu.add_command(label=label, accelerator=accelerator,
                              command=lambda action=action: invoke_readline(action))
    edit_menu.add_separator()
    def clear_screen(event=None):
        renderer.write("\033[2J\033[H")
        return "break"
    edit_menu.add_command(label="Clear screen", accelerator="Ctrl+L", command=clear_screen)
    root.bind_all("<Control-l>", clear_screen)
    transcript.bind("<Control-a>", lambda event: edit_action("SelectAll"))
    update_menus(tuple(agent.Agent.COMMANDS))

    def alternate_factory():
        widget = tk.Text(transcript_frame, wrap="word", state="disabled")
        widget.bind("<Control-a>", lambda event: edit_action("SelectAll"))
        return widget

    renderer = TerminalDisplay(transcript, alternate_factory)
    def switch_view(widget):
        widget.configure(yscrollcommand=scroll.set)
        scroll.configure(command=widget.yview)
        widget.pack(fill="both", expand=True)
    renderer.on_switch = switch_view

    def display(text):
        # Do not discard a user-selected primary scroll position on preview exit.
        view = transcript.yview()
        renderer.write(text)
        if renderer.widget is not transcript or not view or view[1] == 1.0:
            renderer.widget.see("end")

    def edit(text, reply):
        nonlocal pending
        pending = reply
        entry.configure(state="disabled")
        set_status("Editing")
        window = tk.Toplevel(root)
        window.title("Edit prompt")
        editor = tk.Text(window, wrap="word", undo=True)
        editor.insert("1.0", text)
        editor.edit_reset()
        for key, action in (("z", "Undo"), ("y", "Redo"), ("a", "SelectAll")):
            editor.bind("<Control-" + key + ">", lambda event, action=action: edit_action(action))
        editor.pack(fill="both", expand=True)
        def finish(value):
            nonlocal pending
            pending = None
            set_status("Running")
            reply.put(value)
            window.destroy()
        tk.Button(window, text="Submit", command=lambda: finish(editor.get("1.0", "end-1c"))).pack()
        window.protocol("WM_DELETE_WINDOW", lambda: finish(None))
        editor.focus_set()

    def poll():
        nonlocal pending, ready, completion_names, completion_state, deletable_tools, proxy_config
        # Bound each drain so continuous output cannot block Tk events.
        for _ in range(200):
            try: kind, value = events.get_nowait()
            except queue.Empty: break
            if kind == "proxy_output":
                if not closing.is_set(): proxy_output(*value)
            elif kind == "proxy_ready":
                session, process, api = value
                if not closing.is_set() and session["process"] is process and process.poll() is None:
                    proxy_config = (session, api)
                    apply_proxy_config()
            elif kind == "mail_result":
                mail_result(*value)
            elif kind == "workspace":
                identity.set(value)
                root.title(f"agent.py (melder) - {value}")
                completion_state = None
                entry.configure(state="normal")
                entry.delete(0, "end")
                entry_history[:] = [""]
                entry_redo.clear()
                renderer.write("\033[?1049l\033[2J")
                for window in (tool_window, import_window, mail_session["window"] if mail_session else None):
                    if window is not None and window.winfo_exists(): window.destroy()
                if closing.is_set(): entry.configure(state="disabled")
            elif kind == "output": display(value)
            elif kind in ("input", "edit"):
                ready = False
                text, reply = value
                if closing.is_set(): reply.put(None)
                elif kind == "edit": edit(text, reply)
                else:
                    display(text)
                    pending = reply
                    set_status("Input required")
                    entry.configure(state="normal")
                    entry.focus_set()
                update_menus()
            elif kind == "progress" and not closing.is_set():
                if value is None:
                    set_status("Running")
                elif value == "unknown":
                    set_status("Prompt processing... unknown", 0)
                else:
                    set_status(f"Prompt processing... {value:.1f}%", value)
            elif kind == "ready" and not closing.is_set():
                ready = True
                names, ratio, deletable_tools = value
                completion_names = (*names, *agent.Agent.EXIT_COMMANDS)
                completion_state = None
                update_menus(names)
                if ratio is None:
                    set_status("Ready — context unknown", 0, context=True)
                else:
                    set_status(f"Ready — context {ratio * 100:.1f}%", ratio * 100, context=True)
                entry.configure(state="normal")
                entry.focus_set()
            elif kind == "done":
                closing.set()
                stop_proxies()
                root.destroy()
                return
        interrupt_state = "normal" if cancellation.active.is_set() and not cancellation.pending.is_set() and not closing.is_set() else "disabled"
        interrupt_button.configure(state=interrupt_state)
        edit_menu.entryconfigure("Interrupt", state=interrupt_state)
        apply_proxy_config()
        root.after(30, poll)

    old_out, old_err = sys.stdout, sys.stderr
    worker = threading.Thread(target=execute, name="agent-ui")
    ui = UI()
    callback_names = ("input_callback", "editor_callback", "progress_callback", "interactive_callback", "page_callback")
    old_callbacks = tuple(getattr(agent, name) for name in callback_names)
    agent.input_callback = lambda prompt: ui.request("input", prompt)
    agent.editor_callback = lambda text: ui.request("edit", text)
    agent.progress_callback = ui.progress
    agent.interactive_callback = lambda stream: True
    agent.page_callback = print
    sys.stdout, sys.stderr = Output(old_out), Output(old_err)
    entry.bind("<Return>", submit)
    entry.configure(state="disabled")
    root.protocol("WM_DELETE_WINDOW", close)
    try:
        worker.start()
        root.after(30, poll)
        root.mainloop()
    finally:
        closing.set()
        stop_proxies()
        if pending: pending.put(None)
        commands.put(None)
        worker.join()
        sys.stdout, sys.stderr = old_out, old_err
        for name, callback in zip(callback_names, old_callbacks):
            setattr(agent, name, callback)


if __name__ == "__main__":
    try:
        import tkinter as tk
        root = tk.Tk()
    except (ImportError, RuntimeError) as e:
        print(f"Cannot start GUI: {e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Cannot open GUI display: {e}", file=sys.stderr)
        sys.exit(1)
    run_ui(tk, root)
