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.
- ai_code_engineer/__init__.py +2 -0
- ai_code_engineer/catalog.py +143 -0
- ai_code_engineer/chat.py +181 -0
- ai_code_engineer/cli.py +384 -0
- ai_code_engineer/config.py +405 -0
- ai_code_engineer/engine.py +1282 -0
- ai_code_engineer/errors.py +27 -0
- ai_code_engineer/git_integration.py +443 -0
- ai_code_engineer/gui.py +2646 -0
- ai_code_engineer/host.py +81 -0
- ai_code_engineer/ignore.py +269 -0
- ai_code_engineer/intent.py +222 -0
- ai_code_engineer/labels.py +871 -0
- ai_code_engineer/memory.py +91 -0
- ai_code_engineer/modes.py +156 -0
- ai_code_engineer/overrides.py +540 -0
- ai_code_engineer/planbook.py +192 -0
- ai_code_engineer/providers.py +404 -0
- ai_code_engineer/redaction.py +54 -0
- ai_code_engineer/repair.py +564 -0
- ai_code_engineer/report.py +352 -0
- ai_code_engineer/runner.py +854 -0
- ai_code_engineer/setup.py +386 -0
- ai_code_engineer/symbols.py +1286 -0
- ai_code_engineer/verification.py +218 -0
- ai_code_engineer/webapp/__init__.py +1 -0
- ai_code_engineer/webapp/__main__.py +45 -0
- ai_code_engineer/webapp/contract.py +36 -0
- ai_code_engineer/webapp/controller.py +3556 -0
- ai_code_engineer/webapp/fake.py +1141 -0
- ai_code_engineer/webapp/launch.py +108 -0
- ai_code_engineer/webapp/server.py +349 -0
- ai_code_engineer/webapp/static/app.css +780 -0
- ai_code_engineer/webapp/static/app.js +2118 -0
- ai_code_engineer/webapp/static/boot.js +19 -0
- ai_code_engineer/webapp/static/index.html +89 -0
- ai_code_engineer/webapp/static/tokens.css +173 -0
- ai_code_engineer/workspace.py +385 -0
- ai_code_engineer-0.1.0.dist-info/METADATA +7 -0
- ai_code_engineer-0.1.0.dist-info/RECORD +44 -0
- ai_code_engineer-0.1.0.dist-info/WHEEL +5 -0
- ai_code_engineer-0.1.0.dist-info/entry_points.txt +2 -0
- ai_code_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
- ai_code_engineer-0.1.0.dist-info/top_level.txt +1 -0
ai_code_engineer/gui.py
ADDED
|
@@ -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()
|