ai-code-engineer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. ai_code_engineer/__init__.py +2 -0
  2. ai_code_engineer/catalog.py +143 -0
  3. ai_code_engineer/chat.py +181 -0
  4. ai_code_engineer/cli.py +384 -0
  5. ai_code_engineer/config.py +405 -0
  6. ai_code_engineer/engine.py +1282 -0
  7. ai_code_engineer/errors.py +27 -0
  8. ai_code_engineer/git_integration.py +443 -0
  9. ai_code_engineer/gui.py +2646 -0
  10. ai_code_engineer/host.py +81 -0
  11. ai_code_engineer/ignore.py +269 -0
  12. ai_code_engineer/intent.py +222 -0
  13. ai_code_engineer/labels.py +871 -0
  14. ai_code_engineer/memory.py +91 -0
  15. ai_code_engineer/modes.py +156 -0
  16. ai_code_engineer/overrides.py +540 -0
  17. ai_code_engineer/planbook.py +192 -0
  18. ai_code_engineer/providers.py +404 -0
  19. ai_code_engineer/redaction.py +54 -0
  20. ai_code_engineer/repair.py +564 -0
  21. ai_code_engineer/report.py +352 -0
  22. ai_code_engineer/runner.py +854 -0
  23. ai_code_engineer/setup.py +386 -0
  24. ai_code_engineer/symbols.py +1286 -0
  25. ai_code_engineer/verification.py +218 -0
  26. ai_code_engineer/webapp/__init__.py +1 -0
  27. ai_code_engineer/webapp/__main__.py +45 -0
  28. ai_code_engineer/webapp/contract.py +36 -0
  29. ai_code_engineer/webapp/controller.py +3556 -0
  30. ai_code_engineer/webapp/fake.py +1141 -0
  31. ai_code_engineer/webapp/launch.py +108 -0
  32. ai_code_engineer/webapp/server.py +349 -0
  33. ai_code_engineer/webapp/static/app.css +780 -0
  34. ai_code_engineer/webapp/static/app.js +2118 -0
  35. ai_code_engineer/webapp/static/boot.js +19 -0
  36. ai_code_engineer/webapp/static/index.html +89 -0
  37. ai_code_engineer/webapp/static/tokens.css +173 -0
  38. ai_code_engineer/workspace.py +385 -0
  39. ai_code_engineer-0.1.0.dist-info/METADATA +7 -0
  40. ai_code_engineer-0.1.0.dist-info/RECORD +44 -0
  41. ai_code_engineer-0.1.0.dist-info/WHEEL +5 -0
  42. ai_code_engineer-0.1.0.dist-info/entry_points.txt +2 -0
  43. ai_code_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. ai_code_engineer-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2646 @@
1
+ """Native desktop workflow; background work never touches Tk widgets directly."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import replace
5
+ import difflib
6
+ import json
7
+ import uuid
8
+ from pathlib import Path, PurePosixPath
9
+ import queue
10
+ import sys
11
+ import threading
12
+ import tkinter as tk
13
+ from tkinter import filedialog, messagebox, ttk
14
+ from tkinter import font as tkfont
15
+ from tkinter.scrolledtext import ScrolledText
16
+
17
+ from .catalog import LIVE, models_for
18
+ from .chat import create_chat, context_block, load_chat, respond, title_for
19
+ from . import config, overrides
20
+ from .config import Settings
21
+ from .engine import (MAX_TASK_CHARS, apply_proposal, atomic_json, chat_sessions,
22
+ load_session, plan,
23
+ project_key, read_plan_reference, rollback)
24
+ from .errors import AgentError, PolicyError
25
+ from .labels import (INTERRUPTED_STATES, MUTABLE_STATES, STATES, UNVERIFIED_STATES, # noqa: F401
26
+ catalog_status_line, friendly_error, is_arabic, run_warning, state_label,
27
+ status_text)
28
+ from .labels import note as shared_note # `note` is a local variable in two methods here
29
+ from .providers import make_provider
30
+ from .redaction import redact
31
+ from . import memory as memory_store
32
+ from . import planbook, repair, runner
33
+ from . import host, intent, modes, setup
34
+ from .verification import verify
35
+ from .workspace import Workspace, ensure_project_dir
36
+
37
+ # Soft, coherent cool palette. Names NAVY/TEAL are kept because they are
38
+ # referenced widely; NAVY is the sidebar tint, TEAL is the accent.
39
+ BG = "#f6f8fb"
40
+ PANEL = "#ffffff"
41
+ INK = "#2b3440"
42
+ MUTED = "#8a94a3"
43
+ NAVY = "#eef2f7"
44
+ TEAL = "#5b7cfa"
45
+ TEAL_INK = "#ffffff"
46
+ LINE = "#e4e9f0"
47
+ HOVER = "#eaeff6"
48
+ ACCENT_SOFT = "#eaf0ff"
49
+ # Sidebar pseudo-project for conversations that have no folder attached.
50
+ CHAT_GROUP = "chats"
51
+ SEARCH_PLACEHOLDER = "Search tasks and chats"
52
+ BUBBLE_FONT = ("Segoe UI", 12)
53
+ # Measured on this machine with the app's own request path (see docs/MODEL-BENCHMARK.md):
54
+ # qwen2.5-coder:1.5b was the fastest model that produced valid, correct proposals.
55
+ DEFAULT_MODEL = "qwen2.5-coder:1.5b"
56
+ RECOMMENDED = {
57
+ "qwen2.5-coder:1.5b": "recommended here — valid proposals in ~25s, good default for iterating",
58
+ "qwen3:4b": "more careful answers, roughly 2× slower on this machine",
59
+ }
60
+
61
+
62
+ def text_set(widget: tk.Text, content: str) -> None:
63
+ widget.configure(state="normal")
64
+ widget.delete("1.0", "end")
65
+ widget.insert("1.0", content)
66
+ widget.configure(state="disabled")
67
+
68
+
69
+ def rounded_rect(canvas, x1, y1, x2, y2, radius=22, **kwargs):
70
+ r = min(radius, (x2-x1)/2, (y2-y1)/2)
71
+ return canvas.create_polygon(x1+r,y1,x2-r,y1,x2,y1,x2,y1+r,
72
+ x2,y2-r,x2,y2,x2-r,y2,x1+r,y2,x1,y2,x1,y2-r,x1,y1+r,x1,y1,
73
+ smooth=True, splinesteps=24, **kwargs)
74
+
75
+
76
+ class RoundedPanel(tk.Canvas):
77
+ def __init__(self, parent, height=160, fill=PANEL, border=LINE, **kwargs):
78
+ super().__init__(parent, bg=kwargs.pop("bg", BG), height=height, highlightthickness=0, **kwargs)
79
+ self.fill, self.border = fill, border
80
+ self.inner = tk.Frame(self, bg=fill)
81
+ self.window = self.create_window(18, 16, anchor="nw", window=self.inner)
82
+ self.bind("<Configure>", self.redraw)
83
+
84
+ def redraw(self, event=None):
85
+ w, h = self.winfo_width(), self.winfo_height()
86
+ self.delete("panel")
87
+ rounded_rect(self, 2, 2, w-2, h-2, radius=18, fill=self.fill, outline=self.border, tags="panel")
88
+ self.tag_lower("panel")
89
+ self.itemconfigure(self.window, width=max(1,w-36), height=max(1,h-32))
90
+
91
+
92
+ class ViewStack(ttk.Frame):
93
+ """Notebook without a tab strip: exactly one page is visible at a time."""
94
+
95
+ def __init__(self, parent):
96
+ super().__init__(parent)
97
+ self.current = None
98
+
99
+ def add(self, frame, text=""):
100
+ return frame
101
+
102
+ def select(self, frame):
103
+ if self.current is frame and frame.winfo_manager():
104
+ return
105
+ if self.current is not None:
106
+ self.current.pack_forget()
107
+ self.current = frame
108
+ frame.pack(fill="both", expand=True)
109
+
110
+
111
+ class Tooltip:
112
+ """Hover tooltip; Tk has no built-in one."""
113
+
114
+ def __init__(self, widget, text, delay=400):
115
+ self.widget, self.text, self.delay = widget, text, delay
116
+ self.after_id = None
117
+ self.window = None
118
+ widget.bind("<Enter>", self._schedule)
119
+ widget.bind("<Leave>", self._hide)
120
+ widget.bind("<ButtonPress>", self._hide)
121
+
122
+ def _schedule(self, _=None):
123
+ self._hide()
124
+ self.after_id = self.widget.after(self.delay, self._show)
125
+
126
+ def _show(self):
127
+ if self.window is not None:
128
+ return
129
+ self.window = window = tk.Toplevel(self.widget)
130
+ window.wm_overrideredirect(True)
131
+ x = self.widget.winfo_rootx() + max(0, self.widget.winfo_width() // 2 - 90)
132
+ y = self.widget.winfo_rooty() + self.widget.winfo_height() + 6
133
+ window.wm_geometry(f"+{x}+{y}")
134
+ tk.Label(window, text=self.text, bg="#2b2d31", fg="white", padx=8, pady=4,
135
+ font=("Segoe UI", 9), justify="left").pack()
136
+
137
+ def _hide(self, _=None):
138
+ if self.after_id is not None:
139
+ self.widget.after_cancel(self.after_id)
140
+ self.after_id = None
141
+ if self.window is not None:
142
+ self.window.destroy()
143
+ self.window = None
144
+
145
+
146
+ class AgentWindow:
147
+ def __init__(self, root: tk.Tk, app_dir: Path):
148
+ self.root = root
149
+ self.app_dir = app_dir.resolve()
150
+ overrides.ensure(self.app_dir) # the same first-run file the web window writes
151
+ self.runs = self.app_dir / ".agent-runs"
152
+ self.chats = self.app_dir / ".agent-chats"
153
+ # Plan step ledgers live outside the approved project folder: a step that gates
154
+ # the next step must not be writable by the model working on that step.
155
+ self.plans = self.app_dir / ".agent-plans"
156
+ # Same reason for the user's standing notes: they are instructions the model must
157
+ # not be able to rewrite into something it will obey on the next task.
158
+ self.memory_dir = self.app_dir / ".agent-memory"
159
+ self.ledger_path: Path | None = None
160
+ self.ledger: dict | None = None
161
+ self.session_path: Path | None = None
162
+ self.session: dict | None = None
163
+ self.chat: dict | None = None
164
+ self.recent_paths: list[Path] = []
165
+ self.chat_id = uuid.uuid4().hex
166
+ self.current_project = ""
167
+ self._loading_session = False
168
+ self._reverting = False
169
+ # Raised while this window moves its own Read-only box to match a folder's declaration: the
170
+ # variable has a write trace, and a fact arriving must not read as a click that re-declares it.
171
+ self._syncing_read_only = False
172
+ self._pending_model = ""
173
+ self._session_cache: dict[Path, tuple[tuple, dict | None]] = {}
174
+ self._chat_cache: dict[Path, tuple[tuple, dict | None]] = {}
175
+ self._rendered_width = 0
176
+ self.project_nodes = {}
177
+ self.chat_nodes = {}
178
+ self.projects, self._saved_ui = {}, {}
179
+ try:
180
+ registry = json.loads((self.app_dir / ".agent-projects.json").read_text(encoding="utf-8"))
181
+ self.projects = {project_key(p): str(Path(p).resolve()) for p in registry["projects"] if isinstance(p, str)}
182
+ if isinstance(registry.get("ui"), dict):
183
+ self._saved_ui = registry["ui"]
184
+ except (OSError, ValueError, KeyError, TypeError):
185
+ self.projects = {}
186
+ self.events: queue.Queue = queue.Queue()
187
+ self.cancel_event = threading.Event()
188
+ self.busy = False
189
+ self.cancellable = False
190
+ self.closing = False
191
+ self.done_handler = None
192
+ self._timers: set[str] = set()
193
+ self.job_controls: list[tuple[ttk.Widget, str]] = []
194
+ self.repo = tk.StringVar()
195
+ self.plan_file = tk.StringVar()
196
+ # Chained mode turns an attached plan into an ordered ledger: the tool picks the
197
+ # next unverified step, and a passing command run is what unlocks the one after it.
198
+ self.chained = tk.BooleanVar(value=bool(self._saved_ui.get("plan_chained")))
199
+ # This window has no Chat/Change badge: naming a folder has always meant "work on it". So the
200
+ # axis arrives here as one switch, and it is the strict end of it — Read-only, which refuses
201
+ # even the proposal. The position itself is not stored here any more: it belongs to the folder
202
+ # and lives in `modes`, where the other window and the command line read the same fact. What is
203
+ # loaded is the tick this window was left with, as a starting position.
204
+ self.read_only = tk.BooleanVar(value=bool(self._saved_ui.get("read_only")))
205
+ # The sandbox is a machine preference, never a project one: a repository must not get to name
206
+ # the image the tool builds that repository inside. Both windows keep it, and both grey it out
207
+ # when `runner.sandbox_available()` says there is no Docker to ask.
208
+ self.sandbox_on = tk.BooleanVar(value=bool(self._saved_ui.get("sandbox_on")))
209
+ self.sandbox_image = tk.StringVar(value=str(self._saved_ui.get("sandbox_image") or ""))
210
+ self.sandbox_info = tk.StringVar(value="")
211
+ # The first-run rows, computed once and stored: building them asks a provider over the network,
212
+ # and a window refresh should never be the reason a request left the machine.
213
+ self._setup_rows: list[dict] = []
214
+ self._setup_demo: dict | None = None
215
+ self._setup_window = None
216
+ self.plan_status = tk.StringVar(value="")
217
+ self.memory_info = tk.StringVar(value="Choose a project folder to edit its notes.")
218
+ self.mode = tk.StringVar(value="Ollama")
219
+ self.model = tk.StringVar()
220
+ self.model_filter = tk.StringVar()
221
+ # Both windows keep one endpoint per provider row: a machine can run Ollama and LM Studio at
222
+ # once, and switching between them must not lose where the other one lives.
223
+ self.endpoint = tk.StringVar()
224
+ self.profile_var = tk.StringVar(value="")
225
+ self.search = tk.StringVar()
226
+ self.model_info = tk.StringVar(value="Loading the model list…")
227
+ self.catalogs = {mode: [] for mode in config.MODES}
228
+ self.selections = {}
229
+ self.active_mode = "Ollama"
230
+ # One endpoint per provider row, held in memory and in the registry — never a key, so
231
+ # nothing in here is worth leaking.
232
+ self.endpoints: dict[str, str] = {}
233
+ self.profile = ""
234
+ self.catalog_source: dict[str, str] = {}
235
+ self.key = tk.StringVar()
236
+ self.request_timeout = tk.IntVar(value=config.clamp_request_timeout(
237
+ self._saved_ui.get("request_timeout")))
238
+ self.cloud_ok = tk.BooleanVar(value=False)
239
+ self.status = tk.StringVar(value="Ready — choose your project and describe the change")
240
+ self.state_label = tk.StringVar(value="No task open")
241
+ self.artifact = tk.StringVar(value="No proposal yet")
242
+ self.artifact_detail = tk.StringVar(value="Choose a project to turn a request into reviewed file changes.")
243
+ self.recipe = tk.StringVar()
244
+ self.run_info = tk.StringVar(value="No command has run yet.")
245
+ self.recipes: list[str] = []
246
+ # Which module of a multi-project folder the command runs in. `target_box` is only packed
247
+ # when there is more than one, so a single-project folder keeps the card it had before.
248
+ self.targets: list[dict] = []
249
+ self.target = ""
250
+ self.target_name = tk.StringVar()
251
+ self._targets_root = ""
252
+ self._fix_round = 0
253
+ self._auto_fix = False
254
+ self.root.title("AI Code Engineer")
255
+ width = min(1560, max(1100, self.root.winfo_screenwidth() - 80))
256
+ height = min(880, max(620, self.root.winfo_screenheight() - 100))
257
+ self.root.geometry(f"{width}x{height}")
258
+ self.root.minsize(1000, 650)
259
+ self.root.configure(bg=BG)
260
+ self.root.protocol("WM_DELETE_WINDOW", self.close)
261
+ self._style()
262
+ self._build()
263
+ self.repo.trace_add("write", self.project_changed)
264
+ self.search.trace_add("write", lambda *_: self.refresh_recent())
265
+ saved_mode = self._saved_ui.get("mode")
266
+ saved_endpoints = self._saved_ui.get("endpoints")
267
+ if isinstance(saved_endpoints, dict):
268
+ self.endpoints = {row: str(value) for row, value in saved_endpoints.items()
269
+ if row in config.BY_KEY and isinstance(value, str)}
270
+ saved_profile = self._saved_ui.get("profile")
271
+ self.profile = saved_profile if isinstance(saved_profile, str) else ""
272
+ self.profile_var.set(self.profile)
273
+ self.endpoint.set(self.endpoint_for())
274
+ if isinstance(saved_mode, str) and saved_mode in self.catalogs:
275
+ self.mode.set(saved_mode)
276
+ self.active_mode = saved_mode
277
+ self.endpoint.set(self.endpoint_for())
278
+ # Restore needs the cloud/key UI packed for a restored cloud mode;
279
+ # otherwise the approval prompt appears with no visible checkbox.
280
+ self.model_changed()
281
+ pending = self._saved_ui.get("model")
282
+ if isinstance(pending, str) and pending:
283
+ self._pending_model = pending
284
+ # `last_project` is deliberately not applied here: a folder the previous session happened to
285
+ # have open cannot become this session's workspace, in this window or the web one.
286
+ self.refresh_recent()
287
+ self.update_buttons()
288
+ saved_chat = self._saved_ui.get("last_chat")
289
+ if isinstance(saved_chat, str) and saved_chat and not self.repo.get().strip():
290
+ resumed = self.chats / saved_chat / "chat.json"
291
+ if resumed.exists():
292
+ self.open_chat(resumed)
293
+ self.status.set("Ready — ask directly, or choose a project to work on its files")
294
+ self._schedule(80, self.poll)
295
+ self._schedule(150, self.check_setup)
296
+ # A machine that has never granted a folder gets the checks offered, once — and `setup_seen` is
297
+ # the operator's own answer to that offer, honoured here exactly as the web window honours it.
298
+ if setup.first_run(self.app_dir) and not bool(self._saved_ui.get("setup_seen")):
299
+ self._schedule(600, self.show_setup)
300
+
301
+ def _style(self):
302
+ self.root.option_add("*Font", "{Segoe UI} 10")
303
+ style = ttk.Style(self.root)
304
+ style.theme_use("clam")
305
+ style.configure("TFrame", background=BG)
306
+ style.configure("Card.TFrame", background=PANEL)
307
+ style.configure("TLabel", background=BG, foreground=INK)
308
+ style.configure("Card.TLabel", background=PANEL, foreground=INK)
309
+ style.configure("Muted.TLabel", foreground=MUTED, background=BG)
310
+ style.configure("TButton", padding=(14, 9), background=PANEL, foreground=INK, borderwidth=0,
311
+ relief="flat", focusthickness=0)
312
+ style.map("TButton", background=[("active", HOVER)], foreground=[("disabled", "#98a2b3")])
313
+ style.configure("Accent.TButton", background=TEAL, foreground=TEAL_INK, borderwidth=0)
314
+ style.map("Accent.TButton", background=[("disabled", "#c3cfe8"), ("active", "#4a68e0")],
315
+ foreground=[("disabled", "#f4f6fd")])
316
+ style.configure("TEntry", padding=9, fieldbackground=PANEL, bordercolor=LINE, lightcolor=LINE,
317
+ darkcolor=LINE, relief="flat")
318
+ style.configure("TCombobox", padding=(9, 7), fieldbackground=PANEL, background=PANEL,
319
+ bordercolor=LINE, lightcolor=LINE, darkcolor=LINE, arrowcolor=MUTED, relief="flat")
320
+ style.map("TCombobox", fieldbackground=[("readonly", PANEL)], selectbackground=[("readonly", PANEL)],
321
+ selectforeground=[("readonly", INK)])
322
+ style.configure("TCheckbutton", background=BG, foreground=INK, padding=6)
323
+ style.configure("TNotebook", background=BG, borderwidth=0)
324
+ style.configure("TNotebook.Tab", padding=(16, 9), background="#eaeef4", foreground=MUTED, borderwidth=0)
325
+ style.map("TNotebook.Tab", background=[("selected", PANEL)], foreground=[("selected", TEAL)])
326
+ style.configure("Treeview", rowheight=32, background=PANEL, fieldbackground=PANEL, foreground=INK,
327
+ borderwidth=0)
328
+ style.map("Treeview", background=[("selected", ACCENT_SOFT)], fieldbackground=[("selected", ACCENT_SOFT)],
329
+ foreground=[("selected", INK)])
330
+ style.configure("Treeview.Heading", background="#eef2f8", foreground=MUTED, padding=8, borderwidth=0)
331
+ style.configure("Projects.Treeview", background=NAVY, fieldbackground=NAVY, borderwidth=0,
332
+ relief="flat", rowheight=30)
333
+ style.map("Projects.Treeview", background=[("selected", ACCENT_SOFT)], foreground=[("selected", INK)])
334
+ style.configure("Horizontal.TProgressbar", thickness=3, background=TEAL, troughcolor="#e4e9f0",
335
+ borderwidth=0, lightcolor=TEAL, darkcolor=TEAL)
336
+ for bar in ("Vertical.TScrollbar", "Horizontal.TScrollbar"):
337
+ style.configure(bar, gripcount=0, arrowsize=0, borderwidth=0, relief="flat",
338
+ background="#d5dbe4", troughcolor=BG, activebackground="#c0c8d4")
339
+
340
+ def button(self, parent, text, command, *, accent=False, track=True, tip=None):
341
+ try:
342
+ parent_bg = parent.cget("background")
343
+ except tk.TclError:
344
+ parent_bg = PANEL
345
+ base = TEAL if accent else parent_bg
346
+ hover = "#4a68e0" if accent else HOVER
347
+ button = tk.Button(parent, text=text, command=command, relief="flat", bd=0,
348
+ highlightthickness=0, bg=base, fg=TEAL_INK if accent else INK,
349
+ activebackground=hover, activeforeground=TEAL_INK if accent else INK,
350
+ disabledforeground="#f2f4fa" if accent else "#a8b0bb",
351
+ padx=14, pady=9, cursor="hand2", font=("Segoe UI", 11))
352
+
353
+ def tint(color):
354
+ # Disabled buttons must not light up on hover.
355
+ if str(button["state"]) != "disabled":
356
+ button.configure(bg=color)
357
+
358
+ button.bind("<Enter>", lambda _=None: tint(hover))
359
+ button.bind("<Leave>", lambda _=None: tint(getattr(button, "base_bg", base)))
360
+ if tip:
361
+ Tooltip(button, tip)
362
+ if track:
363
+ self.job_controls.append((button, "normal"))
364
+ return button
365
+
366
+ def _build(self):
367
+ sidebar = tk.Frame(self.root, bg=NAVY, width=268)
368
+ sidebar.pack(side="left", fill="y")
369
+ sidebar.pack_propagate(False)
370
+ tk.Label(sidebar, text="AI Code Engineer", bg=NAVY, fg=INK,
371
+ font=("Segoe UI Semibold", 15)).pack(anchor="w", padx=20, pady=(26, 20))
372
+ self.button(sidebar, "+ New chat", self.new_chat, accent=True).pack(anchor="w", padx=14, pady=(0, 4))
373
+ self.button(sidebar, "↗ Open saved task", self.open_file).pack(anchor="w", padx=14, pady=3)
374
+ self.search_box = ttk.Entry(sidebar, textvariable=self.search)
375
+ self.search_box.pack(fill="x", padx=14, pady=(8, 0))
376
+ self.search_box.insert(0, "Search tasks and chats")
377
+ self.search_box.configure(foreground=MUTED)
378
+ self.search_box.bind("<FocusIn>", self._search_focus)
379
+ self.search_box.bind("<FocusOut>", self._search_focus)
380
+ tk.Frame(sidebar, bg=LINE, height=1).pack(fill="x", pady=16)
381
+ tk.Label(sidebar, text="Workspace", bg=NAVY, fg=MUTED, font=("Segoe UI", 10)).pack(anchor="w", padx=20, pady=(4, 8))
382
+ self.project_name = tk.StringVar(value="No project — just chat")
383
+ project_button = self.button(sidebar, "", self.browse, tip="Choose a project folder to turn requests into reviewed code changes")
384
+ project_button.configure(textvariable=self.project_name, anchor="w", wraplength=214, justify="left")
385
+ project_button.pack(fill="x", padx=14)
386
+ self.clear_button = self.button(sidebar, "Clear project", lambda: self.repo.set(""), track=False)
387
+ self.clear_button.configure(anchor="w", font=("Segoe UI", 10))
388
+ self.clear_button.pack(fill="x", padx=14, pady=(4, 0))
389
+ new_project_button = self.button(sidebar, "New project folder…", self.new_project, track=False,
390
+ tip="Grant access to an empty folder and build a new project there")
391
+ new_project_button.configure(anchor="w", font=("Segoe UI", 10))
392
+ new_project_button.pack(fill="x", padx=14, pady=(2, 0))
393
+ tk.Label(sidebar, text="Tasks", bg=NAVY, fg=MUTED, font=("Segoe UI", 10)).pack(anchor="w", padx=20, pady=(24, 8))
394
+ # Packed from the bottom up so the expanding tree can never push them off screen.
395
+ self.button(sidebar, "Try sample project", self.example).pack(side="bottom", anchor="w", padx=14, pady=10)
396
+ tk.Frame(sidebar, bg=LINE, height=1).pack(side="bottom", fill="x")
397
+ tk.Label(sidebar, text="● You always review first", bg=NAVY, fg=MUTED,
398
+ font=("Segoe UI", 9)).pack(side="bottom", anchor="w", padx=20, pady=(8, 16))
399
+ self.recent = ttk.Treeview(sidebar, show="tree", selectmode="browse", style="Projects.Treeview")
400
+ self.recent.column("#0", width=222, minwidth=100)
401
+ self.recent.pack(side="top", fill="both", expand=True, padx=18)
402
+ self.recent.bind("<<TreeviewSelect>>", self.open_recent)
403
+ main = ttk.Frame(self.root)
404
+ main.pack(side="left", fill="both", expand=True)
405
+ header = ttk.Frame(main, padding=(26, 14))
406
+ header.pack(fill="x")
407
+ self.title = tk.StringVar(value="New chat")
408
+ ttk.Label(header, textvariable=self.title, font=("Segoe UI Semibold", 13)).pack(side="left")
409
+ self.view_buttons = {}
410
+ for label, key, first in (("Activity", "details", True), ("Chat", "task", False)):
411
+ view_button = self.button(header, label, lambda key=key: self.select_view(key), track=False)
412
+ view_button.pack(side="right", padx=(0, 6) if not first else 0)
413
+ self.view_buttons[key] = view_button
414
+ tk.Frame(main, bg=LINE, height=1).pack(fill="x")
415
+ body = ttk.Frame(main)
416
+ body.pack(fill="both", expand=True)
417
+ sources = ttk.Frame(body, width=292, padding=(10, 18, 16, 0))
418
+ sources.pack(side="right", fill="y")
419
+ sources.pack_propagate(False)
420
+ self.sources_panel = sources
421
+ def fit_sources(event):
422
+ if event.width < 1000:
423
+ sources.pack_forget()
424
+ elif not sources.winfo_manager():
425
+ sources.pack(side="right", fill="y", before=center)
426
+ body.bind("<Configure>", fit_sources)
427
+ card = RoundedPanel(sources, height=540)
428
+ card.pack(fill="x")
429
+ ttk.Label(card.inner, text="Artifact", foreground=MUTED, font=("Segoe UI", 10)).pack(anchor="w", pady=(4, 6))
430
+ ttk.Label(card.inner, textvariable=self.artifact, font=("Segoe UI Semibold", 11),
431
+ wraplength=212, justify="left").pack(anchor="w")
432
+ ttk.Label(card.inner, textvariable=self.artifact_detail, foreground=MUTED, font=("Segoe UI", 9),
433
+ wraplength=212, justify="left").pack(anchor="w", pady=(2, 8))
434
+ self.button(card.inner, "Review proposed files ↗", lambda: self.open_review(), track=False).pack(anchor="w")
435
+ tk.Frame(card.inner, bg=LINE, height=1).pack(fill="x", pady=14)
436
+ # Checks: run the project's own build/test command, then optionally ask for a fix.
437
+ self.checks_frame = ttk.Frame(card.inner)
438
+ ttk.Label(self.checks_frame, text="Checks", foreground=MUTED, font=("Segoe UI", 10)).pack(anchor="w")
439
+ # The module first, then the command inside it: in a reactor the folder is the question that
440
+ # changes the answers. Packed only when there is more than one to choose from.
441
+ self.target_row = ttk.Frame(self.checks_frame)
442
+ self.target_box = ttk.Combobox(self.target_row, textvariable=self.target_name, values=[],
443
+ state="readonly", width=24)
444
+ self.target_box.pack(side="left")
445
+ self.target_box.bind("<<ComboboxSelected>>", self.target_changed)
446
+ Tooltip(self.target_box, "Which part of this project the command runs in")
447
+ self.recipe_box = ttk.Combobox(self.checks_frame, textvariable=self.recipe, values=[],
448
+ state="readonly", width=24)
449
+ self.recipe_box.pack(anchor="w", pady=(3, 6))
450
+ Tooltip(self.recipe_box, "Command detected in this project folder")
451
+ # The container choice sits with the command it wraps rather than in Settings: the question is
452
+ # asked at the moment of pressing Run, and the answer changes what a green will mean.
453
+ self.sandbox_row = ttk.Frame(self.checks_frame)
454
+ self.sandbox_box = ttk.Checkbutton(self.sandbox_row, text="Run in Docker",
455
+ variable=self.sandbox_on, command=self.sandbox_changed)
456
+ self.sandbox_box.pack(side="left")
457
+ self.sandbox_entry = ttk.Entry(self.sandbox_row, textvariable=self.sandbox_image, width=22)
458
+ self.sandbox_entry.pack(side="left", padx=(6, 0))
459
+ # A keystroke repaints the sentence under the box; leaving the field is what saves it. The
460
+ # digest either matches the pattern or it does not, and a preference written per character is
461
+ # a disk write for every half-typed name.
462
+ self.sandbox_entry.bind("<KeyRelease>", lambda *_: self.sandbox_info_line())
463
+ self.sandbox_entry.bind("<FocusOut>", self.sandbox_changed)
464
+ self.sandbox_entry.bind("<Return>", self.sandbox_changed)
465
+ self.sandbox_row.pack(anchor="w", pady=(0, 2))
466
+ Tooltip(self.sandbox_box,
467
+ "A copy of the project, no network, nothing written back to your files")
468
+ ttk.Label(self.checks_frame, textvariable=self.sandbox_info, foreground=MUTED,
469
+ font=("Segoe UI", 9), wraplength=212, justify="left").pack(anchor="w", pady=(0, 6))
470
+ run_row = ttk.Frame(self.checks_frame)
471
+ run_row.pack(fill="x")
472
+ self.run_button = self.button(run_row, "▶ Run", lambda: self.run_tests(False), track=False,
473
+ tip="Run the selected command inside this project folder")
474
+ self.run_button.pack(side="left")
475
+ self.fix_button = self.button(run_row, "Run & fix", lambda: self.run_tests(True), track=False,
476
+ tip="Run it, and when it fails ask the model for the next fix. You still approve every write.")
477
+ self.fix_button.pack(side="left", padx=8)
478
+ ttk.Label(self.checks_frame, textvariable=self.run_info, foreground=MUTED, font=("Segoe UI", 9),
479
+ wraplength=212, justify="left").pack(anchor="w", pady=(6, 0))
480
+ self.job_controls.append((self.recipe_box, "readonly"))
481
+ self.job_controls.append((self.target_box, "readonly"))
482
+ # Packed here so it sits between the artifact summary and Sources; hidden
483
+ # again by refresh_recipes() while no project folder is selected.
484
+ self.checks_frame.pack(anchor="w", pady=(0, 12))
485
+ self.sources_label = ttk.Label(card.inner, text="Sources", foreground=MUTED, font=("Segoe UI", 10))
486
+ self.sources_label.pack(anchor="w")
487
+ self.source_name = tk.StringVar(value="Attach a project plan")
488
+ source = self.button(card.inner, "", self.source_action, tip="Attach or view the project plan")
489
+ source.configure(textvariable=self.source_name, anchor="w", wraplength=214, justify="left")
490
+ source.pack(fill="x", pady=8)
491
+ self.button(card.inner, "+ Attach plan", self.browse_plan).pack(anchor="w")
492
+ self.button(card.inner, "Settings ›", self.show_settings,
493
+ tip="API key, model filter and details").pack(anchor="w", pady=(6, 0))
494
+ center = ttk.Frame(body, padding=(24, 0, 24, 10))
495
+ center.pack(side="left", fill="both", expand=True)
496
+ self.tabs = ViewStack(center)
497
+ self.tabs.pack(fill="both", expand=True)
498
+ self.task_tab = ttk.Frame(self.tabs, padding=(14, 12, 14, 0))
499
+ self.details_tab = ttk.Frame(self.tabs, padding=14)
500
+ self.tabs.add(self.task_tab, text="Conversation")
501
+ self.tabs.add(self.details_tab, text="Activity")
502
+ self.select_view("task")
503
+ self._task_page()
504
+ self._review_page()
505
+ log_card = RoundedPanel(self.details_tab)
506
+ log_card.pack(fill="both", expand=True)
507
+ self.log = ScrolledText(log_card.inner, wrap="word", font=("Consolas", 10), bg=PANEL, fg=INK,
508
+ relief="flat", padx=14, pady=14, state="disabled")
509
+ self.log.pack(fill="both", expand=True)
510
+ self.progress = ttk.Progressbar(center, mode="indeterminate")
511
+ self.status_label = ttk.Label(center, textvariable=self.status, style="Muted.TLabel", anchor="center",
512
+ font=("Segoe UI", 10))
513
+ self.status_label.pack(fill="x", pady=(6, 0))
514
+ self.status_label.bind("<Configure>", lambda e: self.status_label.configure(wraplength=max(200,e.width)))
515
+ self.repo.trace_add("write", self._sync_project_chip)
516
+ self.plan_file.trace_add("write", lambda *_: self.refresh_plan_status())
517
+ self._sync_project_chip()
518
+ self.sandbox_info_line()
519
+ self.reset_conversation()
520
+
521
+ def _sync_project_chip(self, *_):
522
+ repo = self.repo.get().strip()
523
+ self.project_name.set("▱ " + Path(repo).name if repo else "No project — just chat")
524
+ self.clear_button.configure(state="normal" if repo else "disabled")
525
+
526
+ def select_view(self, key: str):
527
+ page = getattr(self, key + "_tab")
528
+ self.tabs.select(page)
529
+ for name, widget in self.view_buttons.items():
530
+ active = name == key
531
+ widget.base_bg = ACCENT_SOFT if active else PANEL
532
+ widget.configure(bg=widget.base_bg, fg=TEAL if active else INK)
533
+ return page
534
+
535
+ def open_review(self):
536
+ """Bring the proposal viewer forward, and put in its title what it is holding.
537
+
538
+ The web window's sheet is an overlay inside one page; here the same thing is a second window, so
539
+ the taskbar has to say which of the two windows a proposal belongs to once a chat has moved on.
540
+ """
541
+ window = self.review_window
542
+ window.title("Proposed changes — " + self.state_label.get())
543
+ window.deiconify()
544
+ window.lift()
545
+
546
+ def close_review(self):
547
+ """Hide the viewer, never destroy it: every writer to a proposal addresses these widgets.
548
+
549
+ Escape is the web sheet's way out, and this window's title-bar X is wired to the same door so
550
+ the two answers cannot drift into "closing here loses the diff, closing there does not".
551
+ """
552
+ self.review_window.withdraw()
553
+
554
+ def source_action(self):
555
+ self.view_plan() if self.plan_file.get() else self.browse_plan()
556
+
557
+ def show_settings(self):
558
+ self.settings_window.deiconify()
559
+ self.settings_window.lift()
560
+
561
+ # ------------------------------ first run ------------------------------
562
+ def setup_values(self) -> dict:
563
+ """What the window is pointed at, read on the UI thread.
564
+
565
+ A worker thread touching a `StringVar` raises `main thread is not in main loop`, and the audit
566
+ is built from four of them. The keys are `setup.audit`'s own parameters so a check can read
567
+ them here and hand the same dict to the thread.
568
+ """
569
+ return {"repo": self.repo.get().strip(), "provider": self.active_kind().key,
570
+ "endpoint": self.endpoint_for(), "api_key": self.key.get().strip() or None,
571
+ "model": self.model.get().strip(), "arabic": self.arabic, "demo": self._setup_demo}
572
+
573
+ def setup_rows(self, probe: bool = False) -> list[dict]:
574
+ """The audit for the folder and provider this window is pointed at, in its own language."""
575
+ return setup.audit(probe=probe, **self.setup_values())
576
+
577
+ def show_setup(self):
578
+ """The first-run checks in this window: the same rows the card and the terminal print.
579
+
580
+ A text widget rather than a grid of labels, because the row text is a sentence the shared
581
+ module wrote — restyling it into one label per field would put this window's words on top of
582
+ the answer. The status reaches the reader as a word (`[ok]`, `[!]`, `[x]`) as well as a line.
583
+ """
584
+ win = self._setup_window
585
+ if win is not None and win.winfo_exists():
586
+ win.lift()
587
+ self._paint_setup()
588
+ return
589
+ win = tk.Toplevel(self.root)
590
+ win.title("Setup checks")
591
+ win.configure(bg=BG)
592
+ win.geometry("680x520")
593
+ win.transient(self.root)
594
+ win.protocol("WM_DELETE_WINDOW", win.withdraw)
595
+ self._setup_window = win
596
+ self.setup_body = tk.Text(win, wrap="word", relief="flat", font=("Segoe UI", 10),
597
+ padx=14, pady=12, bg="white", fg=INK, state="disabled")
598
+ self.setup_body.pack(fill="both", expand=True, padx=10, pady=(10, 0))
599
+ bar = ttk.Frame(win, padding=(10, 8))
600
+ bar.pack(fill="x")
601
+ self.button(bar, "Run the checks", self.run_setup_check, track=False,
602
+ tip="Ask this machine what it can reach: the provider, its model list, and the "
603
+ "command the selected folder answers to.").pack(side="left")
604
+ self.button(bar, "Run the offline proof", self.run_setup_demo, track=False,
605
+ tip="A proposal applied, checked and rolled back in a temporary folder. No model is "
606
+ "asked, and none of your files are touched.").pack(side="left", padx=6)
607
+ self.button(bar, "Don't show this again", self.hide_setup, track=False,
608
+ tip="The card stops appearing at launch. The checks stay one click away here.").pack(side="left")
609
+ self.button(bar, "Close", win.destroy, track=False).pack(side="right")
610
+ self._paint_setup()
611
+
612
+ def _paint_setup(self):
613
+ win, body = self._setup_window, getattr(self, "setup_body", None)
614
+ if win is None or not win.winfo_exists() or body is None:
615
+ return
616
+ if not self._setup_rows:
617
+ self._setup_rows = self.setup_rows()
618
+ counts = setup.counts(self._setup_rows)
619
+ win.title("Setup checks — " + setup.tally(counts, arabic=self.arabic))
620
+ body.configure(state="normal")
621
+ body.delete("1.0", "end")
622
+ body.insert("end", setup.render(self._setup_rows) + "\n")
623
+ body.configure(state="disabled")
624
+
625
+ def run_setup_check(self):
626
+ """One click that asks the provider. Nothing in this window probes it by itself."""
627
+ if self.busy:
628
+ return
629
+ # Read on this thread, used on the worker: the job body may not reach for a Tk variable.
630
+ values = self.setup_values()
631
+
632
+ def probe():
633
+ return setup.audit(probe=True, **values)
634
+
635
+ def done(rows):
636
+ self._setup_rows = rows
637
+ self._paint_setup()
638
+ self.say(setup.checks_done(setup.counts(rows), arabic=self.arabic))
639
+
640
+ self.run_job(probe, done, "Checking this machine…")
641
+
642
+ def run_setup_demo(self):
643
+ if self.busy:
644
+ return
645
+
646
+ def done(result):
647
+ self._setup_demo = result
648
+ self._setup_rows = [setup.demo_row(result, arabic=self.arabic) if item["id"] == "demo" else item
649
+ for item in (self._setup_rows or [])] or [setup.demo_row(
650
+ result, arabic=self.arabic)]
651
+ self._paint_setup()
652
+ self.say(setup.proof_done(result.get("proposal_apply_rollback") == "passed",
653
+ result.get("note", ""), arabic=self.arabic))
654
+
655
+ self.run_job(setup.run_demo, done, "Running the offline proof…")
656
+
657
+ def hide_setup(self):
658
+ """`Don't show this again` — a preference like the theme, and the only way the card stops."""
659
+ self._saved_ui["setup_seen"] = True
660
+ self._save_state()
661
+ win = self._setup_window
662
+ if win is not None and win.winfo_exists():
663
+ win.destroy()
664
+ self._setup_window = None
665
+ self.say("The setup card will not open at launch. Setup is still in Settings.")
666
+
667
+ def clear_conversation(self):
668
+ self.messages = []
669
+ self.render_messages()
670
+
671
+ def reset_conversation(self):
672
+ self.clear_conversation()
673
+ self.chat_message("AI Code Engineer", "What would you like to work on?\n\nAsk me anything as it is, or choose a project in the sidebar and I will propose reviewed changes to its files.")
674
+
675
+ @property
676
+ def arabic(self) -> bool:
677
+ """Was the thing in front of this window asked in Arabic?
678
+
679
+ The same rule the web window applies: the session's own task first, then the last message
680
+ the user typed, then English. The Tk window used to have no answer here at all — it read
681
+ `STATES` directly and wrote its own English sentences, which is the whole reason the fallback
682
+ window stayed English under an Arabic task.
683
+ """
684
+ task = (self.session or {}).get("task") or ""
685
+ if not task:
686
+ for author, text in reversed(getattr(self, "messages", [])):
687
+ if author == "You":
688
+ task = text
689
+ break
690
+ return is_arabic(task)
691
+
692
+ def chat_message(self, author, message):
693
+ self.messages.append((author,message))
694
+ self.render_messages()
695
+ self._idle(lambda: self.conversation.yview_moveto(1))
696
+
697
+ # ---------------- the four Host verbs ----------------
698
+ # `host.Host` names the only ways a window may put something on screen. Thin on purpose: Tk's
699
+ # surface is a StringVar and a Text widget, the web window's is a snapshot and an SSE channel, and
700
+ # the contract was never one implementation. It is the name that a sentence can be written under
701
+ # once and be checked for in the other window. `tests/test_host.py` holds both windows to them and
702
+ # ratchets the raw primitives below, so an un-routed sink cannot quietly be added.
703
+ def say(self, text: str) -> None:
704
+ self.status.set(text)
705
+
706
+ def line(self, role: str, author: str, text: str) -> None:
707
+ self.chat_message(author, text)
708
+
709
+ def ask(self, title: str, message: str, warning: str = "", ok_label: str = "Continue",
710
+ alt_label: str = "") -> bool:
711
+ return bool(messagebox.askyesno(title, message + ("\n\n" + warning if warning else ""),
712
+ parent=self.root))
713
+
714
+ def stream(self, event: dict) -> None:
715
+ self.events.put(event)
716
+
717
+ def render_messages(self, event=None):
718
+ if getattr(self, "_rendering_messages", False):
719
+ return
720
+ if event is not None and getattr(self, "_rendered_width", 0) == max(280, self.conversation.winfo_width()):
721
+ return
722
+ self._rendering_messages = True
723
+ try:
724
+ self._render_messages()
725
+ finally:
726
+ self._rendering_messages = False
727
+
728
+ def _wrap_metrics(self, text: str, pixel_width: int, metrics) -> tuple[int, int]:
729
+ """Greedy word wrap with real font metrics: Tk only bundles display lines
730
+ once a widget is mapped, which is too late to lay the canvas out.
731
+ Returns the line count and the widest rendered line."""
732
+ space = metrics.measure(" ")
733
+ lines, widest = 0, 0
734
+ for paragraph in text.split("\n"):
735
+ count, current = 1, 0
736
+ for word in paragraph.split(" "):
737
+ width = metrics.measure(word)
738
+ if not current:
739
+ current = width
740
+ elif current + space + width <= pixel_width:
741
+ current += space + width
742
+ else:
743
+ count += 1
744
+ current = width
745
+ while current > pixel_width:
746
+ count += 1
747
+ current -= pixel_width
748
+ widest = max(widest, min(current, pixel_width))
749
+ lines += count
750
+ return lines, widest
751
+
752
+ def _render_messages(self):
753
+ c = self.conversation
754
+ for child in c.winfo_children():
755
+ child.destroy()
756
+ c.delete("all")
757
+ width = max(280,c.winfo_width())
758
+ pad = 18
759
+ y = 36
760
+ metrics = getattr(self, "_bubble_metrics", None)
761
+ if metrics is None:
762
+ metrics = self._bubble_metrics = tkfont.Font(font=BUBBLE_FONT)
763
+ char_px = max(4, metrics.measure("0"))
764
+ line_px = metrics.metrics("linespace")
765
+ for author,message in getattr(self,"messages",[]):
766
+ user = author == "You"
767
+ limit = max(120, min(620 if user else 760, width-110) - 2*pad)
768
+ lines, used = self._wrap_metrics(message, limit, metrics)
769
+ inner = max(min(60, limit), min(used + 2, limit))
770
+ text_width = inner + 2*pad
771
+ left = width-text_width-44 if user else 26
772
+ if user:
773
+ top = y
774
+ else:
775
+ c.create_text(left+pad,y+2,text=author,anchor="nw",fill=MUTED,font=("Segoe UI Semibold",10))
776
+ top = y+26
777
+ view = tk.Text(c, wrap="word", font=BUBBLE_FONT, bg=ACCENT_SOFT if user else PANEL,
778
+ fg=INK, relief="flat", bd=0, highlightthickness=0, padx=0, pady=0,
779
+ width=max(10, inner//char_px), height=1, cursor="xterm",
780
+ exportselection=False, spacing1=1, spacing3=1,
781
+ selectbackground="#bfd3f7", selectforeground=INK)
782
+ view.insert("1.0", message)
783
+ # `justify` is a Text *tag* option here, not a widget option — measured: the widget
784
+ # rejects -justify. Right-aligning the line is as far as Tk 8.6 goes without a bidi
785
+ # engine, and it is what makes an Arabic paragraph readable in the fallback window.
786
+ if is_arabic(message):
787
+ view.tag_configure("rtl", justify="right")
788
+ view.tag_add("rtl", "1.0", "end")
789
+ view.configure(state="disabled")
790
+ self.text_menu(view, readonly=True)
791
+ height = lines * (line_px + 2) + 4
792
+ c.create_window(left+pad, top+pad, window=view, width=inner, height=height, anchor="nw")
793
+ bottom = top + pad + height + pad
794
+ bubble = rounded_rect(c, left, top-8, left+text_width, bottom, radius=16,
795
+ fill=ACCENT_SOFT if user else PANEL, outline="" if user else LINE)
796
+ c.tag_lower(bubble)
797
+ y = bottom+30
798
+ c.configure(scrollregion=(0,0,width,max(y,c.winfo_height())))
799
+ self._rendered_width = width
800
+
801
+ def text_menu(self, widget, *, readonly=False):
802
+ def copy():
803
+ try:
804
+ value = widget.get("sel.first", "sel.last")
805
+ except tk.TclError:
806
+ return "break"
807
+ widget.clipboard_clear()
808
+ widget.clipboard_append(value)
809
+ return "break"
810
+
811
+ def select_all():
812
+ widget.tag_add("sel", "1.0", "end-1c")
813
+ return "break"
814
+
815
+ menu = tk.Menu(widget, tearoff=False)
816
+ menu.add_command(label="Copy", command=copy, accelerator="Ctrl+C")
817
+ if not readonly:
818
+ menu.add_command(label="Cut", command=lambda: widget.event_generate("<<Cut>>"), accelerator="Ctrl+X")
819
+ menu.add_command(label="Paste", command=lambda: widget.event_generate("<<Paste>>"), accelerator="Ctrl+V")
820
+ menu.add_separator()
821
+ menu.add_command(label="Select all", command=select_all, accelerator="Ctrl+A")
822
+
823
+ def popup(event):
824
+ widget.focus_set()
825
+ menu.entryconfigure("Copy", state="normal" if widget.tag_ranges("sel") else "disabled")
826
+ if not readonly:
827
+ enabled = str(widget.cget("state")) != "disabled"
828
+ menu.entryconfigure("Cut", state="normal" if enabled and widget.tag_ranges("sel") else "disabled")
829
+ menu.entryconfigure("Paste", state="normal" if enabled else "disabled")
830
+ try:
831
+ menu.tk_popup(event.x_root,event.y_root)
832
+ finally:
833
+ menu.grab_release()
834
+ return "break"
835
+
836
+ widget.bind("<Control-c>", lambda _: copy())
837
+ widget.bind("<Control-a>", lambda _: select_all())
838
+ widget.bind("<Button-3>", popup)
839
+ if readonly:
840
+ widget.bind("<Button-1>", lambda _: widget.focus_set(), add=True)
841
+ widget.bind("<MouseWheel>", lambda e: (self.conversation.yview_scroll(-int(e.delta/120),"units"), "break")[1])
842
+
843
+ def _task_page(self):
844
+ composer = RoundedPanel(self.task_tab, height=196)
845
+ composer.pack(fill="x",side="bottom",pady=(14,6))
846
+ self.task = tk.Text(composer.inner, height=2, wrap="word", font=("Segoe UI",12),
847
+ relief="flat", bd=0, bg=PANEL,fg=INK,insertbackground=TEAL,undo=True,
848
+ padx=2, pady=4, spacing1=2, spacing3=2)
849
+ self.task.pack(fill="both",expand=True,padx=2,pady=(4,10))
850
+ self.text_menu(self.task)
851
+ self.task.bind("<Control-Return>", lambda _: (self.start_plan(), "break")[1])
852
+ def enter_sends(event):
853
+ if event.state & 0x0001: # Shift+Enter keeps the newline.
854
+ return None
855
+ self.start_plan()
856
+ return "break"
857
+ self.task.bind("<Return>", enter_sends)
858
+ self.prompt_hint = tk.Label(self.task,text="Ask anything — or choose a project and describe the change",
859
+ bg=PANEL,fg="#a6aeb9",font=("Segoe UI",12),cursor="xterm")
860
+ self.prompt_hint.place(x=6,y=6)
861
+ self.prompt_hint.bind("<Button-1>",lambda _: self.task.focus_set())
862
+ def prompt_hint(_=None):
863
+ self.prompt_hint.place_forget()
864
+ if not self.task.get("1.0","end").strip() and self.root.focus_get() != self.task:
865
+ self.prompt_hint.place(x=6,y=6)
866
+ self.task.bind("<FocusIn>",prompt_hint)
867
+ self.task.bind("<FocusOut>",prompt_hint)
868
+ self.task.bind("<<Modified>>",lambda _: (prompt_hint(), self.task.edit_modified(False)) if self.task.edit_modified() else None)
869
+ tk.Frame(composer.inner, bg=LINE, height=1).pack(fill="x", pady=(0, 8))
870
+ actions = ttk.Frame(composer.inner)
871
+ actions.pack(fill="x")
872
+ self.button(actions, "+ Plan", self.browse_plan,
873
+ tip="Attach a project plan (.md or .txt)").pack(side="left")
874
+ self.mode_box = ttk.Combobox(actions, textvariable=self.mode, values=list(self.catalogs),
875
+ state="readonly", width=18)
876
+ self.mode_box.pack(side="left", padx=(10, 0))
877
+ self.mode_box.bind("<<ComboboxSelected>>", self.mode_changed)
878
+ Tooltip(self.mode_box, "Provider — local Ollama or OpenRouter cloud")
879
+ self.model_box = ttk.Combobox(actions, textvariable=self.model, values=[], state="readonly", width=30)
880
+ self.model_box.pack(side="left", padx=6)
881
+ self.model_box.bind("<<ComboboxSelected>>", self.model_changed)
882
+ Tooltip(self.model_box, "Model — open Settings for the full list and filter")
883
+ self.job_controls.extend([(self.mode_box, "readonly"), (self.model_box, "readonly")])
884
+ self.button(actions, "Settings", self.show_settings, track=False,
885
+ tip="API key, model filter and details").pack(side="left")
886
+ # Next to Send, because this is the one control that decides what Send is allowed to become —
887
+ # the same rule the web window draws as a badge over its own prompt.
888
+ self.read_only_box = ttk.Checkbutton(actions, variable=self.read_only, text="Read-only")
889
+ self.read_only_box.pack(side="left", padx=(10, 0))
890
+ self.job_controls.append((self.read_only_box, "normal"))
891
+ Tooltip(self.read_only_box, intent.switched(intent.READ) + " The folder is still read, searched "
892
+ "and mapped — nothing is written, and the project command asks first.")
893
+ self.read_only.trace_add("write", self.read_only_changed)
894
+ self.start_button = self.button(actions, "Send ↑", self.start_plan, accent=True, tip="Send (Enter)")
895
+ self.start_button.configure(font=("Segoe UI Semibold", 11), padx=18, pady=6)
896
+ self.start_button.pack(side="right")
897
+ self.stop_button = self.button(actions, "Stop", self.stop, track=False,
898
+ tip="Stop after the current model request finishes")
899
+ self.stop_button.pack(side="right", padx=6)
900
+ chat = ttk.Frame(self.task_tab)
901
+ chat.pack(fill="both",expand=True)
902
+ self.conversation=tk.Canvas(chat,bg=BG,highlightthickness=0)
903
+ scroll=ttk.Scrollbar(chat,orient="vertical",command=self.conversation.yview)
904
+ self.conversation.configure(yscrollcommand=scroll.set)
905
+ self.conversation.pack(side="left",fill="both",expand=True)
906
+ scroll.pack(side="right",fill="y")
907
+ self.conversation.bind("<Configure>",self.render_messages)
908
+ self.conversation.bind("<MouseWheel>",lambda e: self.conversation.yview_scroll(-int(e.delta/120),"units"))
909
+ self.settings_window=tk.Toplevel(self.root)
910
+ self.settings_window.title("Project & model settings")
911
+ self.settings_window.geometry("650x740")
912
+ self.settings_window.minsize(580,600)
913
+ self.settings_window.configure(bg="white")
914
+ self.settings_window.transient(self.root)
915
+ self.settings_window.protocol("WM_DELETE_WINDOW",self.settings_window.withdraw)
916
+ self.settings_window.withdraw()
917
+ self.button(self.settings_window,"Done",self.settings_window.withdraw,track=False).pack(side="bottom",anchor="e",padx=20,pady=10)
918
+ self.inspector=ttk.Frame(self.settings_window,padding=20)
919
+ self.inspector.pack(fill="both",expand=True)
920
+ canvas = tk.Canvas(self.inspector, bg=BG, highlightthickness=0, width=240)
921
+ scrollbar = ttk.Scrollbar(self.inspector, orient="vertical", command=canvas.yview)
922
+ scrollbar.pack(side="right", fill="y")
923
+ canvas.pack(side="left", fill="both", expand=True)
924
+ canvas.configure(yscrollcommand=scrollbar.set)
925
+ parent = ttk.Frame(canvas, padding=(0, 0, 6, 8))
926
+ window = canvas.create_window((0, 0), window=parent, anchor="nw")
927
+ parent.bind("<Configure>", lambda _: canvas.configure(scrollregion=canvas.bbox("all")))
928
+ canvas.bind("<Configure>", lambda e: canvas.itemconfigure(window, width=e.width))
929
+ def scroll_sources(event):
930
+ widget = event.widget
931
+ while widget is not None:
932
+ if widget == self.inspector:
933
+ canvas.yview_scroll(-int(event.delta / 120), "units")
934
+ return "break"
935
+ widget = getattr(widget, "master", None)
936
+ self.root.bind("<MouseWheel>", scroll_sources, add=True)
937
+ ttk.Label(parent, text="Project & sources", font=("Segoe UI", 12, "bold")).pack(anchor="w", pady=(0, 18))
938
+ ttk.Label(parent, text="Outputs", foreground=MUTED).pack(anchor="w", pady=(0, 6))
939
+ self.button(parent, "Review proposed files", lambda: self.open_review()).pack(fill="x")
940
+ # The way back after "Don't show this again": the note on that button promises the checks stay
941
+ # reachable, so the promise needs a button on this surface too.
942
+ self.button(parent, "Setup checks", self.show_setup, track=False).pack(fill="x", pady=(6, 0))
943
+ ttk.Separator(parent).pack(fill="x", pady=18)
944
+ ttk.Label(parent, text="Project folder", anchor="w").pack(fill="x", pady=(0, 6))
945
+ row = ttk.Frame(parent)
946
+ row.pack(fill="x")
947
+ entry = ttk.Entry(row, textvariable=self.repo)
948
+ entry.pack(fill="x", pady=(0, 6))
949
+ entry.configure(state="readonly")
950
+ self.job_controls.append((entry, "readonly"))
951
+ self.button(row, "Browse…", self.browse).pack(anchor="w")
952
+ ttk.Label(parent, text="Attached plan", anchor="w").pack(fill="x", pady=(14, 6))
953
+ plan_row = ttk.Frame(parent)
954
+ plan_row.pack(fill="x")
955
+ plan_entry = ttk.Entry(plan_row, textvariable=self.plan_file)
956
+ plan_entry.pack(fill="x", pady=(0, 6))
957
+ self.job_controls.append((plan_entry, "normal"))
958
+ self.button(plan_row, "Attach…", self.browse_plan).pack(side="left", padx=(0, 5))
959
+ self.button(plan_row, "View", self.view_plan).pack(side="left", padx=(0, 5))
960
+ self.button(plan_row, "Clear", self.clear_plan).pack(side="left")
961
+ chained_box = ttk.Checkbutton(parent, variable=self.chained,
962
+ text="Step-by-step plan mode: work one step at a time")
963
+ chained_box.pack(anchor="w", pady=(6, 0))
964
+ self.job_controls.append((chained_box, "normal"))
965
+ Tooltip(chained_box, "The task is taken from the plan's next unfinished step, and the step is only "
966
+ "marked done when a command run proves its tests passed. Proposals still need "
967
+ "your approval. The message box then holds an optional note for that step.")
968
+ self.chained.trace_add("write", lambda *_: (self.refresh_plan_status(), self._save_state()))
969
+ ttk.Label(parent, textvariable=self.plan_status, style="Muted.TLabel", wraplength=500,
970
+ justify="left").pack(fill="x", pady=(4, 0))
971
+ ttk.Separator(parent).pack(fill="x", pady=18)
972
+ ttk.Label(parent, text="Project notes (memory)", anchor="w").pack(fill="x", pady=(0, 6))
973
+ self.memory_box = ScrolledText(parent, height=7, wrap="word", relief="flat",
974
+ font=("Segoe UI", 10), padx=8, pady=6, bg="white", fg=INK)
975
+ self.memory_box.pack(fill="x")
976
+ self.memory_box.bind("<KeyRelease>", lambda *_: self.refresh_memory_info())
977
+ self.job_controls.append((self.memory_box, "normal"))
978
+ Tooltip(self.memory_box, "Standing decisions for this folder — package names, language level, "
979
+ "naming rules, what not to touch. Sent with every task here, and stored "
980
+ "outside the project so a proposal can never rewrite the notes it is held to.")
981
+ memory_row = ttk.Frame(parent)
982
+ memory_row.pack(fill="x", pady=(6, 0))
983
+ self.button(memory_row, "Save notes", self.save_memory).pack(side="left")
984
+ ttk.Label(memory_row, textvariable=self.memory_info, style="Muted.TLabel", wraplength=330,
985
+ justify="left").pack(side="left", padx=(12, 0))
986
+ ttk.Label(parent, text="Provider and model are selected next to the message box.",
987
+ style="Muted.TLabel", wraplength=500, justify="left").pack(fill="x", pady=(20, 6))
988
+ ttk.Label(parent, text="Filter models by name", anchor="w").pack(fill="x", pady=(5, 3))
989
+ model_filter = ttk.Entry(parent, textvariable=self.model_filter)
990
+ model_filter.pack(fill="x")
991
+ self.job_controls.append((model_filter, "normal"))
992
+ self.model_filter.trace_add("write", lambda *_: self.filter_models())
993
+ ttk.Label(parent, textvariable=self.model_info, style="Muted.TLabel", wraplength=500).pack(fill="x", pady=(6, 4))
994
+ timeout_row = ttk.Frame(parent)
995
+ timeout_row.pack(fill="x", pady=(4, 0))
996
+ ttk.Label(timeout_row, text="Request timeout (seconds)", anchor="w").pack(side="left")
997
+ timeout_box = ttk.Spinbox(timeout_row, from_=config.REQUEST_TIMEOUT_LOW,
998
+ to=config.REQUEST_TIMEOUT_HIGH, increment=30, width=6,
999
+ textvariable=self.request_timeout)
1000
+ timeout_box.pack(side="right")
1001
+ Tooltip(timeout_box, "Small local models need minutes for a large proposal; raise this if requests time out.")
1002
+ self.job_controls.append((timeout_box, "normal"))
1003
+ self.request_timeout.trace_add("write", lambda *_: self._save_state())
1004
+ # Connection: where the selected provider row actually is. An endpoint used to be reachable
1005
+ # only from a TOML file and the command line, so a relocated Ollama or an LM Studio server on
1006
+ # another port could not be pointed at from the window at all.
1007
+ self.conn_frame = ttk.Frame(parent)
1008
+ self.conn_frame.pack(fill="x", pady=(10, 0))
1009
+ ttk.Label(self.conn_frame, text="Profile (a file in profiles/)", anchor="w").pack(fill="x")
1010
+ self.profile_box = ttk.Combobox(self.conn_frame, textvariable=self.profile_var,
1011
+ values=[""] + config.profile_names(), state="readonly")
1012
+ self.profile_box.pack(fill="x")
1013
+ self.profile_box.bind("<<ComboboxSelected>>",
1014
+ lambda _e: self.set_profile(self.profile_var.get()))
1015
+ Tooltip(self.profile_box, "A profile sets provider, model and endpoint. It names the key's "
1016
+ "environment variable and never holds a key.")
1017
+ ttk.Label(self.conn_frame, text="Endpoint (where requests go)", anchor="w").pack(fill="x", pady=(8, 3))
1018
+ endpoint_entry = ttk.Entry(self.conn_frame, textvariable=self.endpoint)
1019
+ endpoint_entry.pack(fill="x")
1020
+ for event in ("<Return>", "<FocusOut>"):
1021
+ endpoint_entry.bind(event, lambda _e: self.set_endpoint(self.endpoint.get()))
1022
+ Tooltip(endpoint_entry, "A local row must stay on this device; a remote address needs https "
1023
+ "and the approval below.")
1024
+ self.override_frame = self._override_page(parent)
1025
+ self.cloud_frame = ttk.Frame(parent)
1026
+ self.key_frame = ttk.Frame(self.cloud_frame)
1027
+ self.key_frame.pack(fill="x")
1028
+ # The label names the variable the row actually reads, because a Groq key is not an
1029
+ # OpenRouter key and one box had better say which it is.
1030
+ self.key_label = ttk.Label(self.key_frame, text="API key (not saved)", anchor="w")
1031
+ self.key_label.pack(fill="x", pady=(8, 3))
1032
+ key_entry = ttk.Entry(self.key_frame, textvariable=self.key, show="•")
1033
+ key_entry.pack(fill="x")
1034
+ check = ttk.Checkbutton(self.cloud_frame, variable=self.cloud_ok,
1035
+ text="Allow this public / synthetic code\nto be sent to the cloud service.")
1036
+ check.pack(anchor="w", pady=4)
1037
+ self.cloud_consent = check
1038
+ self.job_controls.extend([(key_entry, "normal"), (check, "normal")])
1039
+ self.footer_actions = ttk.Frame(parent)
1040
+ self.footer_actions.pack(fill="x", pady=(15, 0))
1041
+ self.button(self.footer_actions, "Refresh models", self.check_setup).pack(side="left")
1042
+ # The run buttons live in the narrow sidebar card, so the sentence about what they do goes
1043
+ # here, where there is room to read it before pressing one.
1044
+ ttk.Label(parent, text=run_warning(arabic=self.arabic), foreground=MUTED,
1045
+ font=("Segoe UI", 9), wraplength=500, justify="left").pack(fill="x", pady=(10, 0))
1046
+ self.hint = ttk.Label(parent, text="You will review a proposal first. Project files are not changed automatically.", style="Muted.TLabel", anchor="w", wraplength=500, justify="left")
1047
+ self.hint.pack(fill="x", pady=(12, 0))
1048
+
1049
+ def _override_page(self, parent):
1050
+ """The signed rows, listed and editable, in the same frame the endpoint is set from.
1051
+
1052
+ One list of rows, one form, and the file named where it lives: the desktop window and the web
1053
+ drawer write the same file, so a row added here has to be readable there without a restart.
1054
+ """
1055
+ frame = ttk.Frame(parent)
1056
+ frame.pack(fill="x", pady=(10, 0))
1057
+ ttk.Label(frame, text="Overrides (rows only this program can write)", anchor="w").pack(fill="x")
1058
+ self.override_note = ttk.Label(frame, text="", style="Muted.TLabel", anchor="w",
1059
+ wraplength=500, justify="left")
1060
+ self.override_note.pack(fill="x", pady=(3, 0))
1061
+ self.override_rows = ttk.Treeview(frame, columns=("target", "state"), show="tree headings",
1062
+ height=5, selectmode="browse")
1063
+ self.override_rows.heading("#0", text="Field = value")
1064
+ self.override_rows.heading("target", text="For")
1065
+ self.override_rows.heading("state", text="State")
1066
+ self.override_rows.column("#0", width=210, stretch=True)
1067
+ self.override_rows.column("target", width=80, stretch=False)
1068
+ self.override_rows.column("state", width=150, stretch=False)
1069
+ self.override_rows.pack(fill="x", pady=(4, 0))
1070
+ self.override_key = ttk.Combobox(frame, values=[item["key"] for item in overrides.fields()],
1071
+ state="readonly")
1072
+ self.override_key.set("timeout_seconds")
1073
+ self.override_key.pack(fill="x", pady=(8, 3))
1074
+ # The row's own target, so "every provider" is reachable from here too. The provider shown on
1075
+ # screen is the sensible default, and the two are only ever different on purpose.
1076
+ self.override_target = ttk.Combobox(frame, state="readonly",
1077
+ values=[overrides.EVERY] + [kind.key for kind in config.KINDS])
1078
+ self.override_target.set(self.active_kind().key)
1079
+ self.override_target.pack(fill="x", pady=(3, 3))
1080
+ self.override_values = {item["key"]: item for item in overrides.fields()}
1081
+ self.override_hint = ttk.Label(frame, text="", style="Muted.TLabel", anchor="w", wraplength=500,
1082
+ justify="left")
1083
+ self.override_hint.pack(fill="x", pady=(0, 2))
1084
+ self.override_key.bind("<<ComboboxSelected>>", lambda _e: self.show_override_range())
1085
+ self.show_override_range()
1086
+ self.override_value = tk.StringVar()
1087
+ value_row = ttk.Frame(frame)
1088
+ value_row.pack(fill="x")
1089
+ entry = ttk.Entry(value_row, textvariable=self.override_value)
1090
+ entry.pack(side="left", fill="x", expand=True)
1091
+ self.button(value_row, "Save", self.save_override, tip="Judged by the same rules a profile is "
1092
+ "judged by, then signed and stored.").pack(
1093
+ side="right", padx=(6, 0))
1094
+ self.button(value_row, "Remove", self.remove_override).pack(side="right")
1095
+ self.job_controls.extend([(entry, "normal"), (self.override_key, "normal"),
1096
+ (self.override_target, "normal")])
1097
+ self.refresh_overrides()
1098
+ return frame
1099
+
1100
+ def show_override_range(self) -> None:
1101
+ """Put the numbers this field is allowed to take under the box that asks for one."""
1102
+ field = self.override_values.get(str(self.override_key.get() or ""), {})
1103
+ if field.get("number"):
1104
+ self.override_hint.configure(text=f"{field.get('key')}: {field['low']} to {field['high']}")
1105
+ else:
1106
+ self.override_hint.configure(text=str(field.get("key") or ""))
1107
+
1108
+ def refresh_overrides(self) -> None:
1109
+ """Redraw the list from the file, and say what the file is."""
1110
+ tree = self.override_rows
1111
+ for row in tree.get_children():
1112
+ tree.delete(row)
1113
+ for item in overrides.reported(self.app_dir, arabic=self.arabic):
1114
+ # The row id is its own target and key, so Remove deletes the row that is on screen rather
1115
+ # than the one this window happens to be pointed at.
1116
+ tree.insert("", "end", iid=f"{item['target']}|{item['key']}", text=item["display"],
1117
+ values=(item["target"], item["state"],
1118
+ "" if item["state"] == "in force" else item["why"]))
1119
+ self.override_note.configure(text=overrides.scope(arabic=self.arabic))
1120
+
1121
+ def save_override(self) -> None:
1122
+ """Write one row through the program, so it is signed and every surface reads it back.
1123
+
1124
+ The provider it speaks for is chosen here, not assumed from the row on screen: the first thing
1125
+ most people set is an address for a provider they are not currently using.
1126
+ """
1127
+ target = str(self.override_target.get() or overrides.EVERY)
1128
+ key = str(self.override_key.get() or "")
1129
+ try:
1130
+ row = overrides.put(self.app_dir, target, key, self.override_value.get(), by=modes.DESKTOP,
1131
+ arabic=self.arabic)
1132
+ except AgentError as exc:
1133
+ self.say(friendly_error(exc))
1134
+ return
1135
+ self.override_value.set("")
1136
+ self.refresh_overrides()
1137
+ self.say(overrides.written(row, arabic=self.arabic))
1138
+
1139
+ def remove_override(self) -> None:
1140
+ """Drop the selected row. The provider it was written for comes from the row, not the screen."""
1141
+ selected = self.override_rows.selection()
1142
+ if not selected:
1143
+ self.say(overrides.absent(str(self.override_key.get() or ""), arabic=self.arabic))
1144
+ return
1145
+ target, key = str(selected[0]).split("|", 1)
1146
+ self.say(overrides.removed(key, arabic=self.arabic) if overrides.delete(self.app_dir, target, key)
1147
+ else overrides.absent(key, arabic=self.arabic))
1148
+ self.refresh_overrides()
1149
+
1150
+ def _review_page(self):
1151
+ """The proposal viewer: its own window, withdrawn until something is worth reviewing.
1152
+
1153
+ UI 4.0 took the web window off its Changes pane and onto rail + sheet; this is the same
1154
+ decision on the desktop, where a sheet has to be a Toplevel because Tk has no overlay. It is
1155
+ built once and hidden, so every writer — `display_session`, `clear_review`, `show_change`,
1156
+ `update_buttons` — keeps addressing the same widgets whether or not anyone is looking at them.
1157
+
1158
+ It is not `transient()`, unlike a modal dialog: measured on this machine, a transient child of a
1159
+ withdrawn master cannot be mapped at all, and on a live desktop an owned window disappears when
1160
+ the main window is minimised. A proposal you keep open while reading the pom next to it has to
1161
+ stay put.
1162
+ """
1163
+ window = self.review_window = tk.Toplevel(self.root)
1164
+ window.title("Proposed changes")
1165
+ window.geometry("860x620")
1166
+ window.configure(bg=BG)
1167
+ window.protocol("WM_DELETE_WINDOW", self.close_review)
1168
+ window.bind("<Escape>", lambda *_: self.close_review())
1169
+ window.withdraw()
1170
+ parent = ttk.Frame(window, padding=14)
1171
+ parent.pack(fill="both", expand=True)
1172
+ self.headline = ttk.Label(parent, textvariable=self.state_label,
1173
+ font=("Segoe UI", 13, "bold"), anchor="w")
1174
+ self.headline.pack(fill="x", pady=(0, 8))
1175
+ # Tk 8.6 has no bidi engine — measured on this machine: a label takes `justify` and `anchor`
1176
+ # but no `-direction` — so alignment is the honest ceiling for the fallback window. The
1177
+ # headline follows the language of the sentence that is currently in it.
1178
+ self.state_label.trace_add("write", lambda *_: self.headline.configure(
1179
+ anchor="e" if is_arabic(self.state_label.get()) else "w"))
1180
+ # The viewer is a separate window, so the state has to be written on it as well as in it:
1181
+ # a taskbar entry saying "Proposed changes" over a task that was applied an hour ago is a lie
1182
+ # the reader only finds out by clicking.
1183
+ self.state_label.trace_add("write", lambda *_: self.review_window.title(
1184
+ "Proposed changes — " + self.state_label.get()) if self.review_window.winfo_viewable() else None)
1185
+ self.summary = ScrolledText(parent, height=3, wrap="word", relief="flat", font=("Segoe UI", 10), padx=8, pady=6, state="disabled")
1186
+ self.summary.pack(fill="x", pady=(0, 8))
1187
+ self.files = ttk.Treeview(parent, columns=("kind",), show="tree headings", height=3, selectmode="browse")
1188
+ self.files.heading("#0", text="File")
1189
+ self.files.heading("kind", text="Change")
1190
+ self.files.column("#0", width=300, stretch=True)
1191
+ self.files.column("kind", width=110, stretch=False, anchor="center")
1192
+ self.files.pack(fill="x", pady=(0, 8))
1193
+ self.files.bind("<<TreeviewSelect>>", self.show_change)
1194
+ code_tabs = ttk.Notebook(parent)
1195
+ code_tabs.pack(fill="both", expand=True)
1196
+ self.code_views = {}
1197
+ for key, title in (("diff", "Diff"), ("before", "Before"), ("after", "After"), ("checks", "Checks")):
1198
+ frame = ttk.Frame(code_tabs)
1199
+ code_tabs.add(frame, text=title)
1200
+ view = ScrolledText(frame, wrap="none", height=8, font=("Consolas", 10), relief="flat", padx=8, pady=8,
1201
+ bg="white", fg=INK, state="disabled")
1202
+ horizontal = ttk.Scrollbar(frame, orient="horizontal", command=view.xview)
1203
+ horizontal.pack(side="bottom", fill="x")
1204
+ view.configure(xscrollcommand=horizontal.set)
1205
+ view.pack(fill="both", expand=True)
1206
+ self.code_views[key] = view
1207
+ diff = self.code_views["diff"]
1208
+ diff.tag_configure("add", foreground="#11613c", background="#e3f4ea")
1209
+ diff.tag_configure("remove", foreground="#9f2424", background="#ffebeb")
1210
+ diff.tag_configure("header", foreground="#1c5596", background="#edf3fc")
1211
+ row = ttk.Frame(parent)
1212
+ row.pack(fill="x")
1213
+ self.apply_button = self.button(row, "Apply changes", self.apply, accent=True, track=False)
1214
+ self.apply_button.pack(side="right")
1215
+ self.verify_button = self.button(row, "Check syntax", self.check_changes, track=False)
1216
+ self.verify_button.pack(side="right", padx=8)
1217
+ self.rollback_button = self.button(row, "Roll back changes", self.undo, track=False)
1218
+ self.rollback_button.pack(side="left")
1219
+
1220
+ def append_log(self, value: str):
1221
+ self.log.configure(state="normal")
1222
+ self.log.insert("end", value + "\n")
1223
+ self.log.see("end")
1224
+ self.log.configure(state="disabled")
1225
+
1226
+ def read_only_changed(self, *_):
1227
+ """The switch answers its own click: what it just promised, and which buttons that changes."""
1228
+ if self._syncing_read_only:
1229
+ # Opening a folder whose declaration says read moves this box. That is the fact arriving,
1230
+ # not a decision being made, and treating it as one would both speak a sentence nobody
1231
+ # clicked for and overwrite the folder's row on every project switch.
1232
+ return
1233
+ folder = self.repo.get().strip()
1234
+ if folder:
1235
+ # Choosing a position for a folder is a fact about the folder, not about this window: it is
1236
+ # written down where the other window and the command line will read it back.
1237
+ modes.declare(self.app_dir, folder,
1238
+ intent.READ if self.read_only.get() else intent.CHANGE, by=modes.DESKTOP)
1239
+ self.update_buttons()
1240
+ name = Path(folder).name if folder else ""
1241
+ if self.read_only.get():
1242
+ self.say(intent.switched(intent.READ, arabic=self.arabic, project=name))
1243
+ else:
1244
+ self.say(intent.unchecked(arabic=self.arabic))
1245
+ self._save_state()
1246
+
1247
+ def sandbox_info_line(self):
1248
+ """The same four answers the web card gives, decided in one place."""
1249
+ self.sandbox_info.set(shared_note(
1250
+ runner.sandbox_state(self.sandbox_on.get(), self.sandbox_image.get(),
1251
+ runner.sandbox_available()), arabic=self.arabic))
1252
+
1253
+ def sandbox_changed(self, *_):
1254
+ """The container switch says what it just promised, in the card it sits in.
1255
+
1256
+ Four answers, and the image field is only enabled when the box is ticked: an unusable text box
1257
+ next to an unchecked box invites a typed digest that nothing will read.
1258
+ """
1259
+ self.sandbox_info_line()
1260
+ self.update_buttons()
1261
+ self._save_state()
1262
+
1263
+ @property
1264
+ def reading_only(self):
1265
+ """The one question every write gate and every run gate asks.
1266
+
1267
+ The web window holds the same position as a third value of its badge and reads it through
1268
+ `intent.read_only`; this window has never had a mode badge at all, so it is a switch. What both
1269
+ refuse, and in what words, comes from `intent` — that is the part the drift test pins.
1270
+
1271
+ The switch is this window's own click; the declaration is the folder's, written by whichever
1272
+ surface got there first — the other window or the command line. Either one holds, so a folder
1273
+ sealed in a terminal cannot be written from here without somebody saying so out loud.
1274
+ """
1275
+ return bool(self.read_only.get()) or modes.sealed(self.app_dir, self.repo.get().strip())
1276
+
1277
+ def write_refusal(self, what: str) -> str:
1278
+ """The sentence a gate prints, naming where the promise came from — one rule with the web
1279
+ window, and it lives in `modes.refusal` so neither window can reword it."""
1280
+ return modes.refusal(self.app_dir, self.repo.get().strip(), what, arabic=self.arabic,
1281
+ badge=intent.READ if self.read_only.get() else intent.CHANGE)
1282
+
1283
+ def sandbox_state(self):
1284
+ """The container switch, and the image box that only means something beside a ticked one.
1285
+
1286
+ `update_buttons` runs on every state change, so the machine's answer about Docker is read here
1287
+ rather than cached: a window left open across an install should stop refusing.
1288
+ """
1289
+ docker, busy = runner.sandbox_available(), self.busy
1290
+ self.sandbox_box.configure(state="disabled" if busy or not docker else "normal")
1291
+ self.sandbox_entry.configure(
1292
+ state="normal" if docker and self.sandbox_on.get() and not busy else "readonly")
1293
+
1294
+ def update_buttons(self):
1295
+ for widget, enabled in self.job_controls:
1296
+ widget.configure(state="disabled" if self.busy else enabled)
1297
+ self.task.configure(state="disabled" if self.busy else "normal")
1298
+ self.sandbox_state()
1299
+ state = self.session.get("state") if self.session else None
1300
+ # A control that is greyed out is a promise the window keeps before the click, not an
1301
+ # invitation to press something that will answer with a refusal.
1302
+ reading = self.read_only.get()
1303
+ self.apply_button.configure(state="normal" if not self.busy and not reading
1304
+ and state == "WAITING_APPROVAL" else "disabled")
1305
+ self.verify_button.configure(state="normal" if not self.busy and state in MUTABLE_STATES else "disabled")
1306
+ self.rollback_button.configure(state="normal" if not self.busy and not reading
1307
+ and state in MUTABLE_STATES | {"PARTIAL_APPLY", "APPLYING"} else "disabled")
1308
+ runnable = not self.busy and state in MUTABLE_STATES and bool(self.recipes)
1309
+ self.run_button.configure(state="normal" if runnable else "disabled")
1310
+ # "Run & fix" spends its round on a proposal, which is the one thing this switch refuses.
1311
+ self.fix_button.configure(state="normal" if runnable and not reading else "disabled")
1312
+ self.stop_button.configure(state="normal" if self.busy and self.cancellable else "disabled")
1313
+ if self.busy and self.cancellable:
1314
+ self.stop_button.pack(side="right", before=self.start_button, padx=3)
1315
+ else:
1316
+ self.stop_button.pack_forget()
1317
+ self.recent.state(["disabled"] if self.busy else ["!disabled"])
1318
+
1319
+ def run_job(self, operation, on_done, status: str, *, cancellable=False):
1320
+ if self.busy:
1321
+ return
1322
+ self.busy, self.cancellable = True, cancellable
1323
+ self.cancel_event.clear()
1324
+ self.done_handler = on_done
1325
+ self.status.set(status)
1326
+ self.append_log(status)
1327
+ self.progress.pack(fill="x", before=self.status_label, pady=(6, 0))
1328
+ self.progress.start(12)
1329
+ self.update_buttons()
1330
+
1331
+ def worker():
1332
+ try:
1333
+ self.events.put(("done", operation()))
1334
+ except Exception as exc:
1335
+ self.events.put(("error", friendly_error(exc)))
1336
+
1337
+ threading.Thread(target=worker, name="agent-desktop-worker", daemon=True).start()
1338
+
1339
+ def _schedule(self, delay: int, func) -> None:
1340
+ """Every idle callback goes through here, so a pending one can be cancelled.
1341
+
1342
+ A timer that fires after ``root.destroy()`` makes Tk print
1343
+ ``invalid command name "…poll"`` on stderr — which is exactly what filled the test log.
1344
+ """
1345
+ def fire():
1346
+ self._timers.discard(handle)
1347
+ func()
1348
+ handle = self.root.after(delay, fire)
1349
+ self._timers.add(handle)
1350
+
1351
+ def _idle(self, func) -> None:
1352
+ """An after_idle request that a closing window can still take back."""
1353
+ def run():
1354
+ self._timers.discard(handle)
1355
+ func()
1356
+ handle = self.root.after_idle(run)
1357
+ self._timers.add(handle)
1358
+
1359
+ def cancel_timers(self) -> int:
1360
+ """Drop every scheduled callback; returns how many were still pending."""
1361
+ pending, self._timers = set(self._timers), set()
1362
+ for handle in pending:
1363
+ try:
1364
+ self.root.after_cancel(handle)
1365
+ except (tk.TclError, ValueError):
1366
+ pass
1367
+ return len(pending)
1368
+
1369
+ def poll(self):
1370
+ try:
1371
+ while True:
1372
+ kind, value = self.events.get_nowait()
1373
+ if kind == "progress":
1374
+ # Every runner line arrives here, so this is the one place that has to scrub
1375
+ # them: a build that prints its JDBC URL must not show the password whole.
1376
+ value = redact(value).strip()[:500]
1377
+ self.append_log(value)
1378
+ self.status.set(value)
1379
+ continue
1380
+ self.busy = self.cancellable = False
1381
+ self.progress.stop()
1382
+ self.progress.pack_forget()
1383
+ callback = self.done_handler
1384
+ self.done_handler = None
1385
+ if kind == "done":
1386
+ try:
1387
+ callback(value)
1388
+ except (AgentError, OSError, ValueError) as exc:
1389
+ self.status.set(friendly_error(exc))
1390
+ else:
1391
+ self.status.set(value)
1392
+ self.append_log(value)
1393
+ self.chat_message("AI Code Engineer", value)
1394
+ if not self.catalogs.get(self.mode.get()):
1395
+ self.model_info.set("No model list loaded. Click Refresh models to try again.")
1396
+ # A partially applied mutation must update available recovery actions.
1397
+ if self.session_path:
1398
+ try:
1399
+ self.display_session(self.session_path, select=False)
1400
+ except (AgentError, OSError):
1401
+ pass
1402
+ self.refresh_recent()
1403
+ self.update_buttons()
1404
+ if self.closing:
1405
+ self.key.set("")
1406
+ self.root.destroy()
1407
+ return
1408
+ except queue.Empty:
1409
+ pass
1410
+ self._schedule(80, self.poll)
1411
+
1412
+ def browse(self):
1413
+ path = filedialog.askdirectory(title="Select project folder", parent=self.root)
1414
+ if path:
1415
+ self.repo.set(path)
1416
+
1417
+ def _save_state(self):
1418
+ ui = {"mode": self.mode.get(), "last_project": self.repo.get().strip(),
1419
+ "last_chat": self.chat_id, "request_timeout": self.request_timeout_seconds(),
1420
+ "endpoints": dict(self.endpoints), "profile": self.profile,
1421
+ "plan_chained": bool(self.chained.get()),
1422
+ # This window's starting position, not the folder's promise: the tick says what the next
1423
+ # folder this window is given should be told, and it is written into `modes` when that
1424
+ # folder is chosen. Which position a folder already carries is read from there, so the
1425
+ # other window's save cannot erase it and this one cannot forget it.
1426
+ "read_only": bool(self.read_only.get()),
1427
+ "setup_seen": bool(self._saved_ui.get("setup_seen")),
1428
+ # Named here or they are not kept: this rebuilds the block from a list of keys rather
1429
+ # than writing back whatever was in it, so a new preference is invisible until it is
1430
+ # added to this line.
1431
+ "sandbox_on": bool(self.sandbox_on.get()),
1432
+ "sandbox_image": self.sandbox_image.get().strip()}
1433
+ model = self.model.get() or self._pending_model
1434
+ if model:
1435
+ ui["model"] = model
1436
+ try:
1437
+ atomic_json(self.app_dir / ".agent-projects.json",
1438
+ {"projects": list(self.projects.values()), "ui": ui})
1439
+ except OSError:
1440
+ self.status.set(status_text("registry_unsaved", arabic=self.arabic))
1441
+
1442
+ def _has_draft(self):
1443
+ # Sent turns are already saved, so only an unsent message is worth confirming.
1444
+ return bool(self.task.get("1.0", "end").strip())
1445
+
1446
+ def project_changed(self, *_):
1447
+ if self._reverting:
1448
+ return
1449
+ raw = self.repo.get().strip()
1450
+ identity = project_key(raw) if raw else ""
1451
+ if identity == self.current_project:
1452
+ return
1453
+ if not self._loading_session and self._has_draft():
1454
+ previous = self.projects.get(self.current_project, "") if self.current_project else ""
1455
+ if not messagebox.askyesno("Switch project",
1456
+ "Switching projects starts a new chat and clears your draft. Continue?",
1457
+ parent=self.root):
1458
+ self._reverting = True
1459
+ try:
1460
+ self.repo.set(previous)
1461
+ finally:
1462
+ self._reverting = False
1463
+ return
1464
+ self.current_project = identity
1465
+ self.cloud_ok.set(False)
1466
+ if raw:
1467
+ # The tick used to be one switch for every folder, so it survives a restart as this window's
1468
+ # starting position rather than as a per-folder fact — and the first folder opened under it
1469
+ # gets told, out loud, in the file every surface reads. A folder that already carries a
1470
+ # declaration keeps it: the switch never overrules what the terminal or the other window set.
1471
+ if self.read_only.get() and not modes.mode_for(self.app_dir, raw):
1472
+ modes.declare(self.app_dir, raw, intent.READ, by=modes.DESKTOP)
1473
+ self._syncing_read_only = True
1474
+ try:
1475
+ self.read_only.set(modes.sealed(self.app_dir, raw))
1476
+ finally:
1477
+ self._syncing_read_only = False
1478
+ self.update_buttons()
1479
+ self.load_memory()
1480
+ if raw:
1481
+ self.projects[identity] = str(Path(raw).resolve())
1482
+ self._save_state()
1483
+ if not self._loading_session:
1484
+ self.new_task()
1485
+ self.refresh_recent()
1486
+
1487
+ def browse_plan(self):
1488
+ path = filedialog.askopenfilename(title="Attach a project plan", initialdir=self.repo.get() or str(self.app_dir),
1489
+ filetypes=[("Plan files", "*.md *.txt")], parent=self.root)
1490
+ if not path:
1491
+ return
1492
+ if not self.repo.get().strip():
1493
+ self.repo.set(str(Path(path).parent))
1494
+ try:
1495
+ reference = read_plan_reference(Workspace(Path(self.repo.get().strip())), path, Settings())
1496
+ except (AgentError, OSError) as exc:
1497
+ self.status.set(friendly_error(exc))
1498
+ return
1499
+ self.plan_file.set(str(Path(self.repo.get()) / reference["path"]))
1500
+ if not self.chained.get() and not self.task.get("1.0", "end").strip():
1501
+ self.task.insert("1.0", "Read the attached plan and inspect the current project. Implement the next incomplete phase only. Preserve completed work. Propose at most 8 files. Do not run builds, tests, or the application.")
1502
+ self.status.set(shared_note("plan_attached_chained" if self.chained.get()
1503
+ else "plan_attached_plain", arabic=self.arabic))
1504
+
1505
+ def clear_plan(self):
1506
+ self.plan_file.set("")
1507
+ self.ledger_path = self.ledger = None
1508
+ self.plan_status.set("")
1509
+
1510
+ def project_notes(self) -> str:
1511
+ """The saved notes for the folder in the project box, empty when there is none."""
1512
+ repo = self.repo.get().strip()
1513
+ if not repo:
1514
+ return ""
1515
+ try:
1516
+ return memory_store.read(self.memory_dir, repo)
1517
+ except AgentError:
1518
+ return ""
1519
+
1520
+ def load_memory(self):
1521
+ """Show the selected project's notes in the Settings editor."""
1522
+ box = getattr(self, "memory_box", None)
1523
+ if box is None:
1524
+ return
1525
+ repo = self.repo.get().strip()
1526
+ box.configure(state="normal" if repo else "disabled")
1527
+ box.delete("1.0", "end")
1528
+ notes = self.project_notes()
1529
+ if notes:
1530
+ box.insert("1.0", notes)
1531
+ box.edit_reset()
1532
+ self.refresh_memory_info()
1533
+
1534
+ def refresh_memory_info(self):
1535
+ repo = self.repo.get().strip()
1536
+ if not repo:
1537
+ self.memory_info.set("Choose a project folder to edit its notes.")
1538
+ return
1539
+ saved = self.project_notes()
1540
+ typed = self.memory_box.get("1.0", "end").rstrip("\n")
1541
+ state = "unsaved" if typed.strip() != saved.strip() else \
1542
+ (f"{len(saved)} characters saved" if saved else "no notes yet")
1543
+ self.memory_info.set(state + f" · {memory_store.key_for(repo)}.md · sent with every task here")
1544
+
1545
+ def save_memory(self):
1546
+ repo = self.repo.get().strip()
1547
+ if not repo:
1548
+ self.status.set(status_text("need_folder_notes", arabic=self.arabic))
1549
+ return
1550
+ try:
1551
+ memory_store.write(self.memory_dir, repo, self.memory_box.get("1.0", "end").rstrip("\n"))
1552
+ except (AgentError, OSError) as exc:
1553
+ self.status.set(friendly_error(exc))
1554
+ return
1555
+ self.refresh_memory_info()
1556
+ self.status.set(shared_note("notes_saved", arabic=self.arabic))
1557
+
1558
+ def refresh_plan_status(self):
1559
+ """Mirror the attached plan's ledger into the header chip and the Settings panel."""
1560
+ repo, plan_file = self.repo.get().strip(), self.plan_file.get().strip()
1561
+ chip = "▤ " + Path(plan_file).name if plan_file else "Attach a project plan"
1562
+ self.source_name.set(chip)
1563
+ if not repo or not plan_file or not Path(repo).is_dir():
1564
+ self.ledger_path = self.ledger = None
1565
+ self.plan_status.set("")
1566
+ return
1567
+ try:
1568
+ path, book = planbook.open_book(self.plans, Workspace(Path(repo)), plan_file)
1569
+ except (AgentError, OSError) as exc:
1570
+ self.ledger_path = self.ledger = None
1571
+ self.plan_status.set("Plan steps: " + friendly_error(exc))
1572
+ return
1573
+ self.ledger_path, self.ledger = path, book
1574
+ row = planbook.current(book)
1575
+ # The chip is the one place the step count stays visible while the user works in
1576
+ # the main window; the Settings panel holds the whole ledger line.
1577
+ self.source_name.set(chip + " — " + (f"step {row['id']}/{len(book['steps'])}" if row
1578
+ else "all steps verified"))
1579
+ line = planbook.progress_line(book)
1580
+ if self.chained.get() and row is not None:
1581
+ line += " — Send works on it."
1582
+ self.plan_status.set(line)
1583
+
1584
+ def ledger_for(self, session: dict) -> tuple[Path, dict] | None:
1585
+ """The ledger a stored session belongs to, or None when it is not a plan step."""
1586
+ reference, step_id = session.get("plan_reference"), session.get("plan_step")
1587
+ if not reference or step_id is None:
1588
+ return None
1589
+ try:
1590
+ path, book = planbook.open_book(self.plans, Workspace(Path(session["root"])),
1591
+ reference["path"])
1592
+ except (AgentError, OSError):
1593
+ return None
1594
+ if book.get("plan_sha256") != reference["sha256"]:
1595
+ return None
1596
+ self.ledger_path, self.ledger = path, book
1597
+ return path, book
1598
+
1599
+ def advance_plan(self, result) -> None:
1600
+ """Close a step on proven evidence and move the ledger to the next one."""
1601
+ session = self.session
1602
+ if session is None:
1603
+ return
1604
+ pair = self.ledger_for(session)
1605
+ if pair is None:
1606
+ return
1607
+ path, book = pair
1608
+ step_id = session["plan_step"]
1609
+ row = planbook.step(book, step_id)
1610
+ if row is None or row["status"] == "verified":
1611
+ return
1612
+ if result["status"] != "passed":
1613
+ self.status.set(planbook.progress_line(book) + " — step " + str(step_id) +
1614
+ " stays open until a command run proves it.")
1615
+ return
1616
+ try:
1617
+ planbook.complete(path, book, step_id, session)
1618
+ except (AgentError, OSError) as exc:
1619
+ self.chat_message("Tool", shared_note("step_open_detail", arabic=self.arabic, step=step_id,
1620
+ reason=friendly_error(exc)))
1621
+ self.status.set(shared_note("step_still_open", arabic=self.arabic, step=step_id))
1622
+ return
1623
+ self.refresh_plan_status()
1624
+ nxt = planbook.current(book)
1625
+ self.chat_message("Tool", f"Plan step {step_id}/{len(book['steps'])} verified. " + runner.summarize(result))
1626
+ if nxt is None:
1627
+ self.status.set(f"Plan complete — {len(book['steps'])} steps verified.")
1628
+ return
1629
+ if self.chained.get():
1630
+ self.task.delete("1.0", "end")
1631
+ self.status.set(f"Step {step_id} verified. Starting step {nxt['id']}: {nxt['title']}")
1632
+ self.start_plan()
1633
+ else:
1634
+ self.task.delete("1.0", "end")
1635
+ self.task.insert("1.0", planbook.task_for(book, nxt))
1636
+ self.status.set(f"Step {step_id} verified. The next step is in the message box — press Send.")
1637
+
1638
+ def view_plan(self):
1639
+ try:
1640
+ if not self.plan_file.get().strip():
1641
+ raise AgentError("Attach a plan file first.")
1642
+ reference = read_plan_reference(Workspace(Path(self.repo.get().strip())), self.plan_file.get().strip(), Settings())
1643
+ except (AgentError, OSError) as exc:
1644
+ self.status.set(friendly_error(exc))
1645
+ return
1646
+ window = tk.Toplevel(self.root)
1647
+ window.title("Attached plan — " + reference["path"])
1648
+ window.geometry("800x600")
1649
+ view = ScrolledText(window, wrap="word", font=("Segoe UI", 11), padx=14, pady=14)
1650
+ view.pack(fill="both", expand=True)
1651
+ text_set(view, reference["content"])
1652
+
1653
+ def new_task(self):
1654
+ if self.busy:
1655
+ return
1656
+ self.chat_id = uuid.uuid4().hex
1657
+ self.plan_file.set("")
1658
+ self.cloud_ok.set(False)
1659
+ self._auto_fix = False
1660
+ self._fix_round = 0
1661
+ text_set(self.log, "")
1662
+ self.session = self.session_path = None
1663
+ self.chat = None
1664
+ self.title.set("New chat")
1665
+ self.reset_conversation()
1666
+ self.task.delete("1.0", "end")
1667
+ self.clear_review()
1668
+ self.select_view("task")
1669
+ self.status.set(shared_note("new_chat_plain", arabic=self.arabic))
1670
+ self.refresh_recent()
1671
+ self.refresh_recipes()
1672
+ self.update_buttons()
1673
+
1674
+ def new_chat(self):
1675
+ """The sidebar's New chat: a conversation of its own, with no folder carried over."""
1676
+ if self.repo.get().strip():
1677
+ self.repo.set("") # project_changed resets the conversation on the way through
1678
+ else:
1679
+ self.new_task()
1680
+
1681
+ def new_project(self):
1682
+ """Grant access to a folder where a not-yet-existing project will be built."""
1683
+ if self.busy:
1684
+ return
1685
+ path = filedialog.askdirectory(title="Choose a folder for the new project",
1686
+ parent=self.root, mustexist=False)
1687
+ if not path:
1688
+ return
1689
+ try:
1690
+ folder = ensure_project_dir(Path(path))
1691
+ except (AgentError, OSError) as exc:
1692
+ self.status.set(friendly_error(exc))
1693
+ return
1694
+ self.new_task()
1695
+ self.repo.set(str(folder))
1696
+ self.status.set(status_text("granted", arabic=self.arabic) + str(folder) +
1697
+ "\nDescribe what to build. Nothing is written until you approve the proposal.")
1698
+
1699
+ def example(self):
1700
+ self.new_task()
1701
+ self.plan_file.set("")
1702
+ self.repo.set(str(self.app_dir / "examples" / "demo_repo"))
1703
+ self.task.insert("1.0", "Fix the add function in calculator.py so it adds the two numbers instead of subtracting them.")
1704
+ self.mode.set("Ollama")
1705
+ self.mode_changed()
1706
+ self.status.set(shared_note("sample_ready", arabic=self.arabic))
1707
+
1708
+ def mode_changed(self, _event=None):
1709
+ self.selections[self.active_mode] = self.model.get()
1710
+ self.active_mode = self.mode.get()
1711
+ self.model.set(self.selections.get(self.active_mode, ""))
1712
+ # The entry follows the row: LM Studio's address is not Ollama's, and showing the previous
1713
+ # row's endpoint under the new name is how a key ends up somewhere it was never meant to go.
1714
+ self.endpoint.set(self.endpoint_for())
1715
+ self.cloud_ok.set(False)
1716
+ self.model_filter.set("")
1717
+ self.filter_models()
1718
+ if not self.catalogs.get(self.active_mode):
1719
+ self.check_setup()
1720
+
1721
+ def selected_model(self):
1722
+ return next((entry for entry in self.catalogs.get(self.mode.get(), [])
1723
+ if entry["id"] == self.model.get()), None)
1724
+
1725
+ def filter_models(self):
1726
+ query = self.model_filter.get().strip().casefold()
1727
+ entries = self.catalogs.get(self.mode.get(), [])
1728
+ values = [entry["id"] for entry in entries
1729
+ if query in (entry["id"] + " " + entry["name"]).casefold()]
1730
+ values.sort(key=lambda name: name != DEFAULT_MODEL)
1731
+ self.model_box.configure(values=values)
1732
+ if self.model.get() and not any(entry["id"] == self.model.get() for entry in entries):
1733
+ self.model.set("")
1734
+ self.model_changed()
1735
+
1736
+ def model_changed(self, _event=None):
1737
+ entry = self.selected_model()
1738
+ self.selections[self.mode.get()] = self.model.get()
1739
+ info = (entry["name"] + "\n" + entry["description"]) if entry else \
1740
+ f"{len(self.model_box.cget('values'))} models available at {self.endpoint_for()}. " \
1741
+ "Select one from the list."
1742
+ if entry and entry["id"] in RECOMMENDED:
1743
+ info += "\n★ " + RECOMMENDED[entry["id"]]
1744
+ self.model_info.set(info)
1745
+ cloud, paid = self.cloud_choice()
1746
+ kind = self.active_kind()
1747
+ # The key field belongs to any row that needs one, not to OpenRouter alone: LM Studio has no
1748
+ # key box at all and Groq has two, and both are the same control.
1749
+ if cloud or kind.needs_key:
1750
+ self.cloud_frame.pack(fill="x", before=self.footer_actions)
1751
+ if kind.needs_key:
1752
+ self.key_frame.pack(fill="x", before=self.cloud_consent)
1753
+ self.key_label.configure(text="%s key (not saved)" % (kind.key_env or "API"))
1754
+ else:
1755
+ self.key_frame.pack_forget()
1756
+ else:
1757
+ self.cloud_frame.pack_forget()
1758
+ self.cloud_ok.set(False)
1759
+ self.hint.configure(text=("Paid requests use your %s balance. Prices are catalog estimates; "
1760
+ "set a spending limit on your API key." % kind.label) if paid else
1761
+ "You will review a proposal first. Project files are not changed automatically.")
1762
+ self._save_state()
1763
+
1764
+ def check_setup(self):
1765
+ if self.busy:
1766
+ return
1767
+ selected_mode = self.mode.get()
1768
+ kind = self.active_kind()
1769
+ api_key = self.key.get().strip() or None
1770
+ endpoint = self.endpoint_for(selected_mode)
1771
+
1772
+ def done(result):
1773
+ entries, source = result
1774
+ self.catalog_source[selected_mode] = source
1775
+ if kind.free_only:
1776
+ self.catalogs[config.free_mode(kind)] = [row for row in entries if row.get("free")]
1777
+ self.catalogs[config.paid_mode(kind)] = [row for row in entries if not row.get("free")]
1778
+ else:
1779
+ self.catalogs[selected_mode] = entries
1780
+ self.filter_models()
1781
+ pending = self._pending_model
1782
+ self._pending_model = ""
1783
+ if pending and not self.model.get() and any(entry["id"] == pending for entry in self.catalogs.get(self.mode.get(), [])):
1784
+ self.model.set(pending)
1785
+ if not self.model.get() and any(entry["id"] == DEFAULT_MODEL for entry in self.catalogs.get(self.mode.get(), [])):
1786
+ self.model.set(DEFAULT_MODEL)
1787
+ self.model_changed()
1788
+ count = len(self.catalogs[self.mode.get()])
1789
+ self.status.set(catalog_status_line(arabic=self.arabic, count=count,
1790
+ model=self.model.get(), label=selected_mode,
1791
+ live=source == LIVE))
1792
+ self.append_log("Refreshed %s: %d models (%s). No generation request was sent."
1793
+ % (selected_mode, count, source))
1794
+ operation = lambda: models_for(kind, endpoint, api_key)
1795
+ self.run_job(operation, done, "Refreshing available models…")
1796
+
1797
+ def unverified_prior_task(self, chat_id: str, repo: str) -> dict | None:
1798
+ """The last task in this chat whose files were applied without a passing run."""
1799
+ try:
1800
+ turns = chat_sessions(self.runs, repo, chat_id)
1801
+ except (AgentError, OSError):
1802
+ return None
1803
+ for _, item in reversed(turns):
1804
+ if item.get("state") in UNVERIFIED_STATES:
1805
+ return item
1806
+ return None
1807
+
1808
+ def start_plan(self):
1809
+ if self.busy:
1810
+ return
1811
+ repo = self.repo.get().strip()
1812
+ task = self.task.get("1.0", "end").strip()
1813
+ plan_file = self.plan_file.get().strip() or None
1814
+ model = self.model.get().strip()
1815
+ entry = self.selected_model()
1816
+ cloud, paid = self.cloud_choice()
1817
+ if repo and not Path(repo).is_dir():
1818
+ self.status.set(status_text("need_folder_exists", arabic=self.arabic))
1819
+ return
1820
+ # In step-by-step mode the ledger supplies the task, so an empty message box is a
1821
+ # note-free run rather than a missing request.
1822
+ chained_plan = bool(repo and plan_file and self.chained.get())
1823
+ if (not task and not chained_plan) or len(task) > MAX_TASK_CHARS or not model:
1824
+ self.status.set(status_text("too_long", arabic=self.arabic))
1825
+ return
1826
+ if entry is None:
1827
+ self.status.set(status_text("pick_model", arabic=self.arabic))
1828
+ return
1829
+ if cloud and not self.cloud_ok.get():
1830
+ self.status.set(status_text("consent_message", arabic=self.arabic))
1831
+ return
1832
+ settings = self.task_settings(cloud)
1833
+ if settings is None:
1834
+ return
1835
+ key = self.key.get().strip() or None
1836
+ if not repo:
1837
+ self.start_chat(task, settings, cloud, paid, key)
1838
+ return
1839
+ if self.reading_only:
1840
+ # The position is decided before the message is read: the only route from this box to a
1841
+ # proposal is `plan()`, and building one is exactly what the switch refuses.
1842
+ self.start_chat(task, settings, cloud, paid, key, intent.no_proposal(arabic=self.arabic))
1843
+ return
1844
+ if plan_file:
1845
+ try:
1846
+ read_plan_reference(Workspace(Path(repo)), plan_file, settings)
1847
+ except (AgentError, OSError) as exc:
1848
+ self.status.set(friendly_error(exc))
1849
+ return
1850
+ step_id = None
1851
+ if chained_plan:
1852
+ try:
1853
+ ledger_path, book = planbook.open_book(self.plans, Workspace(Path(repo)), plan_file)
1854
+ row = planbook.current(book)
1855
+ if row is None:
1856
+ raise PolicyError("Every step of this plan is already verified by a passing run.")
1857
+ step_id = row["id"]
1858
+ task = planbook.task_for(book, row, task)
1859
+ except (AgentError, OSError) as exc:
1860
+ self.status.set(friendly_error(exc))
1861
+ return
1862
+ self.ledger_path, self.ledger = ledger_path, book
1863
+ chat_id = self.chat_id
1864
+ prior = self.unverified_prior_task(chat_id, repo)
1865
+ if prior:
1866
+ note = shared_note("prior_write", arabic=self.arabic,
1867
+ task=str(prior.get("task", ""))[:60], state=prior["state"])
1868
+ if prior["state"] in INTERRUPTED_STATES:
1869
+ if not messagebox.askyesno("Unfinished task",
1870
+ note + shared_note("prior_continue", arabic=self.arabic),
1871
+ parent=self.root):
1872
+ self.status.set(note + shared_note("prior_blocked", arabic=self.arabic))
1873
+ return
1874
+ self.chat_message("Tool", note + shared_note("prior_continued", arabic=self.arabic))
1875
+ else:
1876
+ self.chat_message("Tool", note + shared_note("prior_stacked", arabic=self.arabic))
1877
+ self.status.set(status_text("prior_unverified", arabic=self.arabic))
1878
+ self.title.set(task.replace("\n", " ")[:45])
1879
+ self.chat_message("You", task)
1880
+ self.session = self.session_path = None
1881
+ self.clear_review()
1882
+ notes = self.project_notes()
1883
+ if notes:
1884
+ self.chat_message("Tool", shared_note("notes_in_request", arabic=self.arabic,
1885
+ count=len(notes)))
1886
+
1887
+ def work():
1888
+ provider = make_provider(settings, allow_cloud=cloud, data_class="public" if cloud else "restricted", api_key=key,
1889
+ allow_paid=paid)
1890
+ return plan(Workspace(Path(repo)), task, provider, settings, self.runs,
1891
+ progress=lambda line: self.events.put(("progress", line)), cancelled=self.cancel_event.is_set,
1892
+ plan_file=plan_file, chat_id=chat_id, plan_step=step_id, memory=notes)
1893
+
1894
+ def done(path):
1895
+ if step_id is not None:
1896
+ try:
1897
+ planbook.record_session(self.ledger_path, self.ledger, step_id, path.parent.name)
1898
+ except (AgentError, OSError) as exc:
1899
+ self.chat_message("Tool", "The plan ledger was not updated: " + friendly_error(exc))
1900
+ self.display_session(path)
1901
+ if self.session and self.session.get("changes"):
1902
+ self.open_review()
1903
+ self.status.set(status_text("proposal_ready", arabic=self.arabic))
1904
+ else:
1905
+ self.status.set(status_text("no_proposal", arabic=self.arabic))
1906
+ self.run_job(work, done, "Connecting to the model and preparing changes…", cancellable=True)
1907
+
1908
+ def current_chat(self):
1909
+ if self.chat is None or self.chat.get("id") != self.chat_id:
1910
+ self.chat = create_chat(self.model.get() or Settings().model, self.chat_id)
1911
+ return self.chat
1912
+
1913
+ def start_chat(self, task: str, settings, cloud: bool, paid: bool, key: str | None,
1914
+ note: str = ""):
1915
+ """Plain question answering, no proposal, no write.
1916
+
1917
+ With no folder selected it never touches the disk. With Read-only on it reads that folder's map
1918
+ first: an analysis of a project the window cannot see is the one thing the switch was asked
1919
+ for, and `repo_map()` is the same index the web window hands the model.
1920
+ """
1921
+ chat = self.current_chat()
1922
+ self.session = self.session_path = None
1923
+ self.clear_review()
1924
+ self.title.set(chat.get("title") or task.replace("\n", " ")[:45])
1925
+ self.chat_message("You", task)
1926
+ if note:
1927
+ self.line("tool", "Tool", note)
1928
+ self.task.delete("1.0", "end")
1929
+ reading = self.reading_only
1930
+ repo = self.repo.get().strip() if reading else ""
1931
+
1932
+ def work():
1933
+ provider = make_provider(settings, allow_cloud=cloud, data_class="public" if cloud else "restricted",
1934
+ api_key=key, allow_paid=paid)
1935
+ return respond(chat, provider, task, settings, self.chats,
1936
+ context=self._chat_context(repo))
1937
+
1938
+ def done(reply):
1939
+ self.chat_message("AI Code Engineer", reply)
1940
+ self.title.set(title_for(chat))
1941
+ self.say(intent.answered(arabic=self.arabic) if reading else
1942
+ "Answered. Choose a project when you want reviewed changes to real files.")
1943
+ self.refresh_recent()
1944
+ self.run_job(work, done, "Thinking…")
1945
+
1946
+ def _chat_context(self, repo: str) -> str:
1947
+ """The repository map and the standing notes, or nothing at all if the folder cannot be read.
1948
+
1949
+ A question about a project is not a reason to fail the question, so a folder that moves or
1950
+ cannot be walked answers with no context rather than with an error.
1951
+ """
1952
+ if not repo or not Path(repo).is_dir():
1953
+ return ""
1954
+ try:
1955
+ return context_block(Workspace(Path(repo)).repo_map(),
1956
+ memory_store.read(self.memory_dir, repo))
1957
+ except (AgentError, OSError):
1958
+ return ""
1959
+
1960
+ def stop(self):
1961
+ if self.busy and self.cancellable:
1962
+ self.cancel_event.set()
1963
+ self.stop_button.configure(state="disabled")
1964
+ self.status.set(shared_note("stop_requested", arabic=self.arabic))
1965
+
1966
+ def _load_session_cached(self, path: Path):
1967
+ try:
1968
+ info = path.stat()
1969
+ except OSError:
1970
+ return None
1971
+ key = (info.st_mtime_ns, info.st_size)
1972
+ cached = self._session_cache.get(path)
1973
+ if cached and cached[0] == key:
1974
+ return cached[1]
1975
+ try:
1976
+ session = load_session(path)
1977
+ except (AgentError, OSError):
1978
+ session = None
1979
+ self._session_cache[path] = (key, session)
1980
+ return session
1981
+
1982
+ def _load_chat_cached(self, path: Path):
1983
+ try:
1984
+ info = path.stat()
1985
+ except OSError:
1986
+ return None
1987
+ key = (info.st_mtime_ns, info.st_size)
1988
+ cached = self._chat_cache.get(path)
1989
+ if cached and cached[0] == key:
1990
+ return cached[1]
1991
+ try:
1992
+ chat = load_chat(path)
1993
+ except (AgentError, OSError):
1994
+ chat = None
1995
+ self._chat_cache[path] = (key, chat)
1996
+ return chat
1997
+
1998
+ def _search_focus(self, _event=None):
1999
+ focused = self.root.focus_get() is self.search_box
2000
+ if focused and self.search.get() == SEARCH_PLACEHOLDER:
2001
+ self.search.set("")
2002
+ self.search_box.configure(foreground=INK)
2003
+ elif not focused and not self.search.get().strip():
2004
+ self.search.set(SEARCH_PLACEHOLDER)
2005
+ self.search_box.configure(foreground=MUTED)
2006
+
2007
+ def _search_query(self) -> str:
2008
+ value = self.search.get().strip().casefold()
2009
+ return "" if value == SEARCH_PLACEHOLDER.casefold() else value
2010
+
2011
+ def refresh_recent(self):
2012
+ query = self._search_query()
2013
+ expanded = {self.project_nodes[node] for node in self.recent.get_children()
2014
+ if self.recent.item(node, "open") and node in self.project_nodes}
2015
+ self.recent.delete(*self.recent.get_children())
2016
+ self.project_nodes, self.chat_nodes = {}, {}
2017
+ groups = {key: {} for key in self.projects}
2018
+ for path in self.runs.glob("*/session.json"):
2019
+ session = self._load_session_cached(path)
2020
+ if session is None:
2021
+ continue
2022
+ try:
2023
+ root = project_key(session["root"])
2024
+ self.projects[root] = session["root"]
2025
+ chat = session.get("chat_id", session["id"])
2026
+ groups.setdefault(root, {}).setdefault(chat, []).append((path, session))
2027
+ except (KeyError, TypeError, ValueError, OSError):
2028
+ continue
2029
+ loose = []
2030
+ for path in self.chats.glob("*/chat.json"):
2031
+ chat = self._load_chat_cached(path)
2032
+ if chat:
2033
+ loose.append((path, chat))
2034
+ loose.sort(key=lambda pair: pair[1].get("created", ""), reverse=True)
2035
+ visible_chats = [pair for pair in loose
2036
+ if not query or query in title_for(pair[1]).casefold()]
2037
+ if visible_chats:
2038
+ self.recent.insert("", "end", iid=CHAT_GROUP, text="Chats · no project",
2039
+ open=CHAT_GROUP in expanded or not self.current_project)
2040
+ self.project_nodes[CHAT_GROUP] = CHAT_GROUP
2041
+ for index, (path, chat) in enumerate(visible_chats):
2042
+ child = CHAT_GROUP + "-chat-" + str(index)
2043
+ self.recent.insert(CHAT_GROUP, "end", iid=child, text=title_for(chat))
2044
+ self.chat_nodes[child] = ("chat", path)
2045
+ for number, (root, chats) in enumerate(groups.items()):
2046
+ folder = Path(self.projects[root])
2047
+ label = folder.name + " · " + str(folder.parent)
2048
+ ordered = sorted(chats.values(), key=lambda turns: max(t[1].get("created", "") for t in turns), reverse=True)
2049
+ turns_by_chat = []
2050
+ for turns in ordered:
2051
+ turns.sort(key=lambda pair: (pair[1].get("created", ""), pair[1]["id"]))
2052
+ title = turns[0][1].get("task", "Chat").replace("\n", " ")[:60]
2053
+ if not query or query in title.casefold() or query in label.casefold():
2054
+ turns_by_chat.append((turns[-1][0], title))
2055
+ if query and not turns_by_chat and query not in label.casefold():
2056
+ continue
2057
+ node = "project-" + str(number)
2058
+ self.recent.insert("", "end", iid=node, text=label,
2059
+ open=root in expanded or root == self.current_project or bool(query))
2060
+ self.project_nodes[node] = root
2061
+ for index, (path, title) in enumerate(turns_by_chat):
2062
+ child = node + "-chat-" + str(index)
2063
+ self.recent.insert(node, "end", iid=child, text=title)
2064
+ self.chat_nodes[child] = ("session", path)
2065
+
2066
+ def open_recent(self, _event=None):
2067
+ if self.busy or not self.recent.selection():
2068
+ return
2069
+ node = self.recent.selection()[0]
2070
+ if node in self.chat_nodes:
2071
+ kind, path = self.chat_nodes[node]
2072
+ self.open_chat(path) if kind == "chat" else self.open_session(path)
2073
+ elif node in self.project_nodes:
2074
+ root = self.project_nodes[node]
2075
+ if root != CHAT_GROUP:
2076
+ self.repo.set(self.projects[root])
2077
+
2078
+ def open_file(self):
2079
+ path = filedialog.askopenfilename(title="Open saved task", initialdir=str(self.runs),
2080
+ filetypes=[("Agent session", "session.json")], parent=self.root)
2081
+ if path:
2082
+ self.open_session(Path(path))
2083
+
2084
+ def open_chat(self, path: Path):
2085
+ """Reopen a project-free conversation."""
2086
+ try:
2087
+ chat = load_chat(path)
2088
+ except (AgentError, OSError) as exc:
2089
+ self.status.set(friendly_error(exc))
2090
+ return
2091
+ self.chat = chat
2092
+ self.chat_id = chat["id"]
2093
+ self.session = self.session_path = None
2094
+ if self.repo.get().strip():
2095
+ # A project-free chat must not keep editing the previously selected project.
2096
+ self._loading_session = True
2097
+ try:
2098
+ self.repo.set("")
2099
+ finally:
2100
+ self._loading_session = False
2101
+ self.clear_review()
2102
+ self.title.set(title_for(chat))
2103
+ self.clear_conversation()
2104
+ for turn in chat["turns"]:
2105
+ if turn.get("role") == "user":
2106
+ self.messages.append(("You", turn.get("content", "")))
2107
+ elif turn.get("role") == "assistant":
2108
+ self.messages.append(("AI Code Engineer", turn.get("content", "")))
2109
+ self.render_messages()
2110
+ self._idle(lambda: self.conversation.yview_moveto(1))
2111
+ self.select_view("task")
2112
+ self.status.set(shared_note("chat_reopened_plain", arabic=self.arabic))
2113
+
2114
+ def open_session(self, path: Path):
2115
+ try:
2116
+ self.display_session(path)
2117
+ self.status.set(status_text("task_opened", arabic=self.arabic))
2118
+ except (AgentError, OSError, ValueError) as exc:
2119
+ self.status.set(friendly_error(exc))
2120
+
2121
+ def clear_review(self):
2122
+ self.state_label.set("No proposal yet")
2123
+ self.artifact.set("No proposal yet")
2124
+ self.run_info.set("No command has run yet.")
2125
+ self.artifact_detail.set("This chat has no project attached, so nothing is proposed."
2126
+ if not self.repo.get().strip() else
2127
+ "Choose a project to turn a request into reviewed file changes.")
2128
+ text_set(self.summary, "Start a task to see proposed changes here.")
2129
+ self.files.delete(*self.files.get_children())
2130
+ for widget in self.code_views.values():
2131
+ text_set(widget, "")
2132
+
2133
+ def display_session(self, path: Path, *, select=True):
2134
+ session = load_session(path)
2135
+ self.session, self.session_path = session, path
2136
+ self.state_label.set(state_label(session["state"], arabic=self.arabic))
2137
+ changes = session.get("changes", [])
2138
+ self.artifact.set(state_label(session["state"], arabic=self.arabic))
2139
+ self.artifact_detail.set(
2140
+ f"{len(changes)} proposed file(s) in {Path(session['root']).name}" if changes
2141
+ else "No applicable proposal was created for this task.")
2142
+ summary = session.get("summary", "No applicable proposal was created.")
2143
+ if session.get("plan_reference"):
2144
+ summary += "\nAttached plan: " + session["plan_reference"]["path"]
2145
+ if session.get("plan_step") is not None:
2146
+ summary += f" — step {session['plan_step']}, gated on a passing command run"
2147
+ text_set(self.summary, summary + "\n\nProject: " + session["root"])
2148
+ self.files.delete(*self.files.get_children())
2149
+ for index, change in enumerate(session.get("changes", [])):
2150
+ self.files.insert("", "end", iid=str(index), text=change["path"],
2151
+ values=("Removed" if change.get("delete")
2152
+ else "Added" if change["before"] is None else "Modified",))
2153
+ checks = "Proposed checks (not execution results):\n" + "\n".join("• " + c for c in session.get("checks", []))
2154
+ result = session.get("verification")
2155
+ if result:
2156
+ checks += "\n\nLatest check: " + result.get("status", "unknown")
2157
+ for item in result.get("static", []):
2158
+ checks += "\n" + item["path"] + ": " + item["status"]
2159
+ if result.get("reason"):
2160
+ checks += "\n" + result["reason"]
2161
+ runs = session.get("runs") or []
2162
+ if runs:
2163
+ last = runs[-1]
2164
+ checks += (f"\n\nCommand runs ({len(runs)}): last was {last.get('command', '')}\n"
2165
+ f"→ {last.get('status')} · exit {last.get('exit_code')} · {last.get('seconds')}s")
2166
+ checks += "\n" + "\n".join(" " + str(row)[:200] for row in (last.get("failures") or [])[:8])
2167
+ self.run_info.set(f"{len(runs)} run(s). Last: {last.get('label')} — {last.get('status')} "
2168
+ f"(exit {last.get('exit_code')}, {last.get('seconds')}s)")
2169
+ else:
2170
+ self.run_info.set("No command has run yet.")
2171
+ text_set(self.code_views["checks"], checks)
2172
+ if session.get("changes"):
2173
+ self.files.selection_set("0")
2174
+ self.show_change()
2175
+ else:
2176
+ for key in ("diff", "before", "after"):
2177
+ text_set(self.code_views[key], "No changes.")
2178
+ if select:
2179
+ self.title.set(session.get("task", "Saved task").replace("\n", " ")[:45])
2180
+ self.chat_id = session.get("chat_id", session["id"])
2181
+ self._loading_session = True
2182
+ try:
2183
+ self.repo.set(session["root"])
2184
+ finally:
2185
+ self._loading_session = False
2186
+ self.chat_id = session.get("chat_id", session["id"])
2187
+ self.cloud_ok.set(False)
2188
+ text_set(self.log, "")
2189
+ for entry in session.get("events", []):
2190
+ self.append_log(entry.get("at", "") + " " + entry.get("kind", ""))
2191
+ reference = session.get("plan_reference")
2192
+ self.plan_file.set(str(Path(session["root"]) / reference["path"]) if reference else "")
2193
+ self.clear_conversation()
2194
+ turns = chat_sessions(self.runs, session["root"], self.chat_id)
2195
+ if not any(turn["id"] == session["id"] for _, turn in turns):
2196
+ turns.append((path, session))
2197
+ for _, turn in turns:
2198
+ asked = turn.get("task", "Saved task")
2199
+ # Per row, not per window: a reopened history can hold an Arabic task and an English
2200
+ # one, and each line was written in the language its own task asked in.
2201
+ asked_in_arabic = is_arabic(asked)
2202
+ body = (turn.get("summary") or turn.get("error")
2203
+ or status_text("no_proposal", arabic=asked_in_arabic))
2204
+ self.messages.append(("You", asked))
2205
+ self.messages.append(("AI Code Engineer",
2206
+ state_label(turn["state"], arabic=asked_in_arabic) +
2207
+ "\n\n" + body))
2208
+ self.render_messages()
2209
+ if session.get("changes"):
2210
+ self.chat_message("Changes", self.approval_notice(session)
2211
+ + "\n".join(change["path"] for change in session["changes"])
2212
+ + "\n\n" + shared_note("review_here", arabic=asked_in_arabic))
2213
+ self.task.configure(state="normal")
2214
+ self.task.delete("1.0", "end")
2215
+ self.select_view("task")
2216
+ self.refresh_recent()
2217
+ self.refresh_recipes()
2218
+ self.update_buttons()
2219
+
2220
+ def show_change(self, _event=None):
2221
+ if not self.session or not self.files.selection():
2222
+ return
2223
+ change = self.session["changes"][int(self.files.selection()[0])]
2224
+ # A delete has no `after`, and the "after" pane showing nothing is the true picture.
2225
+ before, after = change["before"] or "", change["after"] or ""
2226
+ text_set(self.code_views["before"], before)
2227
+ text_set(self.code_views["after"], after)
2228
+ lines = list(difflib.unified_diff(before.splitlines(), after.splitlines(), fromfile="before/" + change["path"],
2229
+ tofile="after/" + change["path"], lineterm=""))
2230
+ view = self.code_views["diff"]
2231
+ text_set(view, "\n".join(lines))
2232
+ for line, value in enumerate(lines, 1):
2233
+ tag = "header" if value.startswith(("---", "+++", "@@")) else "add" if value.startswith("+") else "remove" if value.startswith("-") else None
2234
+ if tag:
2235
+ view.tag_add(tag, f"{line}.0", f"{line}.end")
2236
+
2237
+ def approval_notice(self, session: dict | None = None) -> str:
2238
+ """Everything to read before approving — the removals and the files nobody asked for.
2239
+
2240
+ The copy lives in `engine`/`repair`, not here: the web window shows the same dialog, and two
2241
+ hand-written sentences drift the moment one of them is edited.
2242
+ """
2243
+ return repair.approval_advisories(session if session is not None else self.session)
2244
+
2245
+ def apply(self):
2246
+ if self.busy or not self.session or self.session.get("state") != "WAITING_APPROVAL":
2247
+ return
2248
+ if self.reading_only:
2249
+ # The button is already greyed out. This is the answer for everything that reaches the
2250
+ # method another way: a proposal left on screen when the switch moved, a key binding, a
2251
+ # queued step. The mode is a promise about the folder, not about which widget was clicked.
2252
+ self.say(self.write_refusal("Apply"))
2253
+ return
2254
+ again = ""
2255
+ if self._auto_fix and self.selected_recipe():
2256
+ again = shared_note("apply_rerun_warning", arabic=self.arabic,
2257
+ label=runner.RECIPES[self.selected_recipe()]["label"])
2258
+ # The same dialog the web window shows, from the same builder. This window used to assemble
2259
+ # its own and had drifted twice over: it never passed `must_ask`'s reason in, and it then
2260
+ # dropped the `warning` the builder returned, so a proposal that emptied or deleted a file
2261
+ # warned about it in the web window only. Both halves are the verb's job now.
2262
+ prior = self.unverified_prior_task(self.chat_id, self.repo.get().strip())
2263
+ prompt = host.apply_prompt(self.session, notice=self.approval_notice(),
2264
+ reason=repair.must_ask(self.session, prior), again=again)
2265
+ if not self.ask(prompt["title"], prompt["message"], prompt["warning"], prompt["ok_label"]):
2266
+ return
2267
+ path, approved = self.session_path, self.session["proposal_hash"]
2268
+ self.run_job(lambda: apply_proposal(path, approved), lambda _: self.applied(path), "Applying the changes you reviewed…")
2269
+
2270
+ def applied(self, path):
2271
+ self.display_session(path)
2272
+ if self._auto_fix:
2273
+ self.status.set(status_text("applied_rerun", arabic=self.arabic))
2274
+ self.run_tests(False)
2275
+ return
2276
+ self.status.set(status_text("applied_idle", arabic=self.arabic))
2277
+
2278
+ def refresh_recipes(self):
2279
+ """Detect the folders this project answers to, and the commands each one has.
2280
+
2281
+ The opened folder used to be the only place searched, so a reactor opened at its root showed
2282
+ no command at all — and the module list is the same one the web window draws.
2283
+ """
2284
+ repo = self.repo.get().strip()
2285
+ try:
2286
+ self.targets = runner.targets(Path(repo)) if repo and Path(repo).is_dir() else []
2287
+ self._targets_root = str(Path(repo).resolve()) if self.targets else ""
2288
+ except OSError:
2289
+ self.targets, self._targets_root = [], ""
2290
+ if not any(row["path"] == self.target for row in self.targets):
2291
+ self.target = self.targets[0]["path"] if self.targets else ""
2292
+ self.recipes = next((row["recipes"] for row in self.targets
2293
+ if row["path"] == self.target), [])
2294
+ labels = [runner.RECIPES[name]["label"] for name in self.recipes]
2295
+ self.recipe_box.configure(values=labels)
2296
+ if self.recipe.get() not in labels:
2297
+ self.recipe.set(labels[0] if labels else "")
2298
+ names = [row["label"] for row in self.targets]
2299
+ self.target_box.configure(values=names)
2300
+ if self.target_name.get() not in names:
2301
+ self.target_name.set(self.target_label() or (names[0] if names else ""))
2302
+ if len(self.targets) > 1:
2303
+ self.target_row.pack(fill="x", pady=(3, 6), before=self.recipe_box)
2304
+ else:
2305
+ self.target_row.pack_forget()
2306
+ if labels:
2307
+ if not self.checks_frame.winfo_manager():
2308
+ self.checks_frame.pack(anchor="w", pady=(0, 12), before=self.sources_label)
2309
+ else:
2310
+ self.checks_frame.pack_forget()
2311
+ self.run_info.set("No command has run yet.")
2312
+ self.update_buttons()
2313
+
2314
+ def target_changed(self, event=None):
2315
+ """A module chosen from the list: its commands replace the ones on screen."""
2316
+ chosen = next((row for row in self.targets if row["label"] == self.target_name.get()), None)
2317
+ if chosen and chosen["path"] != self.target:
2318
+ self.target = chosen["path"]
2319
+ self.refresh_recipes()
2320
+
2321
+ def target_label(self) -> str:
2322
+ return next((row["label"] for row in self.targets if row["path"] == self.target), "")
2323
+
2324
+ def selected_recipe(self):
2325
+ labels = [runner.RECIPES[name]["label"] for name in self.recipes]
2326
+ try:
2327
+ return self.recipes[labels.index(self.recipe.get())]
2328
+ except ValueError:
2329
+ return None
2330
+
2331
+ def request_timeout_seconds(self) -> int:
2332
+ try:
2333
+ value = self.request_timeout.get()
2334
+ except tk.TclError:
2335
+ value = None # the widget is gone; the default is the honest answer
2336
+ return config.clamp_request_timeout(value)
2337
+
2338
+ def active_kind(self):
2339
+ return config.MODE_KIND.get(self.mode.get(), config.DEFAULT_KIND)
2340
+
2341
+ def endpoint_for(self, mode: str = "") -> str:
2342
+ kind = config.MODE_KIND.get(mode or self.mode.get(), config.DEFAULT_KIND)
2343
+ return self.endpoints.get(kind.key, "") or config.default_endpoint(kind, self.app_dir)
2344
+
2345
+ def cloud_choice(self):
2346
+ """``(cloud, paid)`` for the row on screen: the provider, the endpoint and the model entry
2347
+ can each make a task leave the device, and only the last one is a property of the row."""
2348
+ kind = self.active_kind()
2349
+ entry = self.selected_model()
2350
+ paid = kind.free_only and self.mode.get() == config.paid_mode(kind)
2351
+ cloud = config.needs_consent(kind, self.endpoint_for()) or bool(entry and entry.get("cloud"))
2352
+ return cloud, paid
2353
+
2354
+ def task_settings(self, cloud: bool):
2355
+ """Settings for the selected row, or None with the reason on the status line.
2356
+
2357
+ An endpoint is checked when it is typed, but a row can still be unusable — Custom with an
2358
+ empty field — and refusing here keeps a half-built Settings out of the worker thread.
2359
+ """
2360
+ kind = self.active_kind()
2361
+ try:
2362
+ return config.settings_for(kind, self.endpoint_for(), app_dir=self.app_dir,
2363
+ model=self.model.get().strip(),
2364
+ api_key_env=kind.key_env,
2365
+ max_turns=8 if cloud else 12,
2366
+ timeout_seconds=self.request_timeout_seconds())
2367
+ except AgentError as exc:
2368
+ self.status.set(friendly_error(exc))
2369
+ return None
2370
+
2371
+ def set_endpoint(self, value: str) -> None:
2372
+ kind = self.active_kind()
2373
+ text = str(value or "").strip()
2374
+ if not text:
2375
+ self.endpoints.pop(kind.key, None)
2376
+ else:
2377
+ try:
2378
+ self.endpoints[kind.key] = config.check_endpoint(kind, text)
2379
+ except AgentError as exc:
2380
+ self.status.set(friendly_error(exc))
2381
+ return
2382
+ # A list of models from the old address is not a list of models at the new one.
2383
+ for label, row in config.mode_rows():
2384
+ if row is kind:
2385
+ self.catalogs[label] = []
2386
+ self.catalog_source.pop(label, None)
2387
+ self.endpoint.set(self.endpoint_for())
2388
+ self._save_state()
2389
+ self.check_setup()
2390
+
2391
+ def set_profile(self, label: str) -> None:
2392
+ self.profile = label
2393
+ try:
2394
+ settings = config.load_profile(label, app_dir=self.app_dir) if label else None
2395
+ except AgentError as exc:
2396
+ self.status.set(friendly_error(exc))
2397
+ self.profile = ""
2398
+ self._save_state()
2399
+ return
2400
+ if settings is not None:
2401
+ mode = config.mode_for(settings.provider, settings.model)
2402
+ if mode in self.catalogs:
2403
+ self.selections[self.mode.get()] = self.model.get()
2404
+ self.mode.set(mode)
2405
+ self.active_mode = mode
2406
+ if settings.endpoint:
2407
+ self.set_endpoint(settings.endpoint)
2408
+ if settings.model:
2409
+ self.model.set(settings.model)
2410
+ self._pending_model = ""
2411
+ self.filter_models()
2412
+ self._save_state()
2413
+
2414
+ def provider_args(self):
2415
+ """Validate the composer selection; returns settings for one more request."""
2416
+ model = self.model.get().strip()
2417
+ entry = self.model.get() and self.selected_model()
2418
+ if entry is None:
2419
+ self.status.set(status_text("pick_model", arabic=self.arabic))
2420
+ return None
2421
+ cloud, paid = self.cloud_choice()
2422
+ if cloud and not self.cloud_ok.get():
2423
+ self.status.set(status_text("consent_project", arabic=self.arabic))
2424
+ return None
2425
+ settings = self.task_settings(cloud)
2426
+ if settings is None:
2427
+ return None
2428
+ return settings, cloud, paid, (self.key.get().strip() or None)
2429
+
2430
+ def run_tests(self, auto_fix: bool = False):
2431
+ """Run one allowlisted command in the approved folder and record the result."""
2432
+ if self.busy:
2433
+ return
2434
+ if not self.session or self.session.get("state") not in MUTABLE_STATES:
2435
+ self.status.set(status_text("apply_first", arabic=self.arabic))
2436
+ return
2437
+ recipe = self.selected_recipe()
2438
+ if recipe is None:
2439
+ self.status.set(status_text("no_recipe", arabic=self.arabic))
2440
+ return
2441
+ if self.reading_only:
2442
+ # Reading a folder is not running inside it. This is the only action in the mode that can
2443
+ # execute anything — a build runs whatever its own scripts do — so it names the command
2444
+ # and asks for that one, and a fix round (whose output is a proposal) is off the table.
2445
+ if not self.ask("Run this command?",
2446
+ intent.run_ask(runner.display_command(recipe), arabic=self.arabic,
2447
+ project=Path(self.session["root"]).name),
2448
+ ok_label="Run it"):
2449
+ self.say(intent.run_declined(arabic=self.arabic))
2450
+ return
2451
+ auto_fix = False
2452
+ self._auto_fix = bool(auto_fix)
2453
+ if auto_fix:
2454
+ self._fix_round = 0
2455
+ repo, path = self.session["root"], self.session_path
2456
+ # The module list was scanned from the window's folder; a task rooted somewhere else gets its
2457
+ # own root and no module, rather than a path resolved against the wrong tree.
2458
+ target = self.target if str(Path(repo).resolve()) == self._targets_root else "."
2459
+ label = runner.RECIPES[recipe]["label"]
2460
+ where = Path(repo).name if target in ("", ".") else PurePosixPath(target).name
2461
+ # Read at the click, not stored on the window: an image typed and then unticked must not run.
2462
+ sandbox = self.sandbox_image.get().strip() if self.sandbox_on.get() else ""
2463
+
2464
+ def work():
2465
+ result = runner.run(Path(repo), recipe, timeout=runner.timeout_for(recipe),
2466
+ progress=lambda line: self.events.put(("progress", line)),
2467
+ target=target, sandbox=sandbox)
2468
+ return repair.record_run(path, result), result
2469
+
2470
+ def done(pair):
2471
+ self.display_session(path)
2472
+ self.report_run(pair[1])
2473
+ self.run_job(work, done, "Running %s in %s%s…" % (label, where, " Docker" if sandbox else ""))
2474
+
2475
+ def report_run(self, result):
2476
+ # `summarize()` interpolates the recipe's own `reason` when the tool is missing, and a
2477
+ # Windows error there carries a path. The web window redacts every field of the same
2478
+ # sentence; this line used to be the one place Tk wrote it straight to the screen.
2479
+ summary = redact(runner.summarize(result))[:300]
2480
+ self.run_info.set(summary)
2481
+ self.chat_message("Checks", summary)
2482
+ if result["status"] == "passed":
2483
+ self._auto_fix = False
2484
+ self.status.set(status_text("command_passed", arabic=self.arabic) + summary)
2485
+ elif self._auto_fix and result["status"] in {"failed", "timeout"}:
2486
+ self.ask_for_fix(result)
2487
+ return
2488
+ else:
2489
+ self._auto_fix = False
2490
+ self.status.set(summary + " — the captured output is in the Checks tab.")
2491
+ self.advance_plan(result)
2492
+
2493
+ def round_history(self):
2494
+ """Every attempt in this chat and folder, oldest first. `repair` reads the record; this window
2495
+ only knows where its own chat lives."""
2496
+ if not self.session:
2497
+ return []
2498
+ return repair.round_history(self.runs, self.session["root"], self.chat_id)
2499
+
2500
+ def fix_rounds(self):
2501
+ """The attempt rows the offer, the stop line and the report all read: what ran, what it cost."""
2502
+ if not self.session:
2503
+ return []
2504
+ return repair.attempts_of(self.runs, self.session["root"], self.chat_id)
2505
+
2506
+ def _show_rounds(self):
2507
+ """Put the attempts on record where they stay readable after the round counter has moved on."""
2508
+ block = repair.timeline(self.fix_rounds())
2509
+ if block:
2510
+ self.line("tool", "Tool", block)
2511
+
2512
+ def stop_fix_loop(self, reason):
2513
+ """The web window's answer to the same decision, word for word — see `repair.stop_status`."""
2514
+ self._auto_fix = False
2515
+ self.line("tool", "Tool", repair.stop_line(reason))
2516
+ self.say(repair.stop_status(reason))
2517
+ self._show_rounds()
2518
+
2519
+ def ask_for_fix(self, run):
2520
+ """Feed the failure back as one more reviewable proposal, up to a bound.
2521
+
2522
+ The bound and the two reasons to stop early are `repair`'s decision, not this window's: the
2523
+ web window asks the same question and must get the same answer.
2524
+ """
2525
+ if self.reading_only:
2526
+ # A round ends in a proposal, so no round starts here — and the run it would have followed
2527
+ # did happen, because the operator approved that one command.
2528
+ self.line("tool", "Tool", intent.no_fix_round(arabic=self.arabic))
2529
+ self._auto_fix = False
2530
+ return
2531
+ stop, reason = repair.should_stop(self.round_history(), self._fix_round)
2532
+ if stop:
2533
+ self.stop_fix_loop(reason)
2534
+ return
2535
+ args = self.provider_args()
2536
+ if args is None:
2537
+ self._auto_fix = False
2538
+ return
2539
+ settings, cloud, paid, key = args
2540
+ self._fix_round += 1
2541
+ repo, chat_id = self.session["root"], self.chat_id
2542
+ # A fix session still implements the same plan step, so it has to carry the plan
2543
+ # and the step number: the step completes on whichever session makes the command pass.
2544
+ reference, step_id = self.session.get("plan_reference"), self.session.get("plan_step")
2545
+ plan_file = str(Path(repo) / reference["path"]) if reference and step_id is not None else None
2546
+ task, evidence = repair.fix_task(run), repair.evidence(run)
2547
+ notes = self.project_notes()
2548
+ # The category belongs in the line the user reads, because "asking for a fix" and "asking for
2549
+ # a fix to a dependency the machine cannot resolve" are different odds of working.
2550
+ status = repair.round_status(settings.model, self._fix_round, repair.classify(run))
2551
+
2552
+ def work():
2553
+ provider = make_provider(settings, allow_cloud=cloud,
2554
+ data_class="public" if cloud else "restricted",
2555
+ api_key=key, allow_paid=paid)
2556
+ return plan(Workspace(Path(repo)), task, provider, settings, self.runs,
2557
+ progress=lambda line: self.events.put(("progress", line)),
2558
+ cancelled=self.cancel_event.is_set, chat_id=chat_id, extra_context=evidence,
2559
+ plan_file=plan_file, plan_step=step_id if plan_file else None, memory=notes)
2560
+
2561
+ def done(path):
2562
+ self.display_session(path)
2563
+ earlier = [item for item in self.round_history() if item.get("id") != path.parent.name]
2564
+ if repair.already_tried(earlier, (self.session or {}).get("changes") or []):
2565
+ self._auto_fix = False
2566
+ self.line("tool", "Tool", "🔁 " + repair.REPEATED_PROPOSAL)
2567
+ self.say(repair.REPEATED_ADVICE)
2568
+ self._show_rounds()
2569
+ return
2570
+ if plan_file:
2571
+ try:
2572
+ pair = self.ledger_for(self.session)
2573
+ if pair:
2574
+ planbook.record_session(pair[0], pair[1], step_id, path.parent.name)
2575
+ except (AgentError, OSError) as exc:
2576
+ self.chat_message("Tool", "The plan ledger was not updated: " + friendly_error(exc))
2577
+ self.open_review()
2578
+ self.status.set(status_text("fix_ready", arabic=self.arabic))
2579
+ self.run_job(work, done, status, cancellable=True)
2580
+
2581
+ def check_changes(self):
2582
+ if self.busy or not self.session or self.session.get("state") not in MUTABLE_STATES:
2583
+ return
2584
+ path = self.session_path
2585
+
2586
+ def done(result):
2587
+ self.display_session(path)
2588
+ self.status.set(shared_note("syntax_failed" if result["status"] == "failed"
2589
+ else "syntax_clean", arabic=self.arabic))
2590
+ self.run_job(lambda: verify(path), done, "Checking syntax in changed files…")
2591
+
2592
+ def undo(self):
2593
+ if self.busy:
2594
+ return
2595
+ if self.reading_only:
2596
+ # Rolling back is a write with a friendly name: it puts different bytes on the same paths.
2597
+ self.say(self.write_refusal("Roll back"))
2598
+ return
2599
+ if not self.session or self.session.get("state") not in MUTABLE_STATES | {"PARTIAL_APPLY", "APPLYING"}:
2600
+ return
2601
+ if not messagebox.askyesno("Roll back changes", "Restore this task's files to their previous contents?\nLater edits will block rollback to protect your work.", parent=self.root):
2602
+ return
2603
+ path, approved = self.session_path, self.session["proposal_hash"]
2604
+
2605
+ def done(_):
2606
+ self.display_session(path)
2607
+ self.status.set(status_text("rolled_back", arabic=self.arabic))
2608
+ step_id = self.session.get("plan_step") if self.session else None
2609
+ pair = self.ledger_for(self.session) if step_id is not None and self.session else None
2610
+ if pair:
2611
+ try:
2612
+ planbook.reopen(pair[0], pair[1], step_id)
2613
+ except (AgentError, OSError) as exc:
2614
+ self.status.set(status_text("ledger_needs_look", arabic=self.arabic) + friendly_error(exc))
2615
+ return
2616
+ self.refresh_plan_status()
2617
+ self.chat_message("Tool", shared_note("step_reopened", arabic=self.arabic, step=step_id))
2618
+ self.run_job(lambda: rollback(path, approved), done, "Rolling back changes…")
2619
+
2620
+ def close(self):
2621
+ if self.busy:
2622
+ if self.cancellable:
2623
+ if messagebox.askyesno("Close application", "Stop the task and close the window after the current request finishes?", parent=self.root):
2624
+ self.closing = True
2625
+ self.stop()
2626
+ else:
2627
+ messagebox.showinfo("Operation in progress", "Wait for the current operation to finish before closing the window.", parent=self.root)
2628
+ return
2629
+ self.cancel_timers()
2630
+ self.key.set("")
2631
+ self.root.destroy()
2632
+
2633
+
2634
+ def main(app_dir: Path | None = None):
2635
+ if sys.platform == "win32":
2636
+ import ctypes
2637
+ try:
2638
+ ctypes.windll.shcore.SetProcessDpiAwareness(2)
2639
+ except (AttributeError, OSError):
2640
+ try:
2641
+ ctypes.windll.user32.SetProcessDPIAware()
2642
+ except (AttributeError, OSError):
2643
+ pass
2644
+ root = tk.Tk()
2645
+ AgentWindow(root, app_dir or Path(__file__).resolve().parents[2])
2646
+ root.mainloop()