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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. ai_code_engineer/__init__.py +2 -0
  2. ai_code_engineer/catalog.py +143 -0
  3. ai_code_engineer/chat.py +181 -0
  4. ai_code_engineer/cli.py +384 -0
  5. ai_code_engineer/config.py +405 -0
  6. ai_code_engineer/engine.py +1282 -0
  7. ai_code_engineer/errors.py +27 -0
  8. ai_code_engineer/git_integration.py +443 -0
  9. ai_code_engineer/gui.py +2646 -0
  10. ai_code_engineer/host.py +81 -0
  11. ai_code_engineer/ignore.py +269 -0
  12. ai_code_engineer/intent.py +222 -0
  13. ai_code_engineer/labels.py +871 -0
  14. ai_code_engineer/memory.py +91 -0
  15. ai_code_engineer/modes.py +156 -0
  16. ai_code_engineer/overrides.py +540 -0
  17. ai_code_engineer/planbook.py +192 -0
  18. ai_code_engineer/providers.py +404 -0
  19. ai_code_engineer/redaction.py +54 -0
  20. ai_code_engineer/repair.py +564 -0
  21. ai_code_engineer/report.py +352 -0
  22. ai_code_engineer/runner.py +854 -0
  23. ai_code_engineer/setup.py +386 -0
  24. ai_code_engineer/symbols.py +1286 -0
  25. ai_code_engineer/verification.py +218 -0
  26. ai_code_engineer/webapp/__init__.py +1 -0
  27. ai_code_engineer/webapp/__main__.py +45 -0
  28. ai_code_engineer/webapp/contract.py +36 -0
  29. ai_code_engineer/webapp/controller.py +3556 -0
  30. ai_code_engineer/webapp/fake.py +1141 -0
  31. ai_code_engineer/webapp/launch.py +108 -0
  32. ai_code_engineer/webapp/server.py +349 -0
  33. ai_code_engineer/webapp/static/app.css +780 -0
  34. ai_code_engineer/webapp/static/app.js +2118 -0
  35. ai_code_engineer/webapp/static/boot.js +19 -0
  36. ai_code_engineer/webapp/static/index.html +89 -0
  37. ai_code_engineer/webapp/static/tokens.css +173 -0
  38. ai_code_engineer/workspace.py +385 -0
  39. ai_code_engineer-0.1.0.dist-info/METADATA +7 -0
  40. ai_code_engineer-0.1.0.dist-info/RECORD +44 -0
  41. ai_code_engineer-0.1.0.dist-info/WHEEL +5 -0
  42. ai_code_engineer-0.1.0.dist-info/entry_points.txt +2 -0
  43. ai_code_engineer-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. ai_code_engineer-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,871 @@
1
+ """State vocabulary and user-facing error text, shared by the Tk window and the web UI.
2
+
3
+ These live outside ``gui`` so the web controller never imports tkinter just to name a
4
+ state. The window re-exports them, so existing call sites and tests keep working.
5
+
6
+ The second half of this file is the language rule. The model is already told to answer in
7
+ the language it was asked in (``chat.py`` LANGUAGE_RULE, ``engine.py`` SYSTEM), which covers
8
+ its own prose; the sentences *we* generate were English-only. They are chosen here so the
9
+ rule has one home rather than a conditional at every call site.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ from .errors import AgentError, Cancelled
16
+ from .redaction import redact
17
+
18
+ # The whole Arabic block, not just its letters: a task written with digits, paths or punctuation
19
+ # still counts, and the diacritics are inside the range too. Written as escapes so the intent is
20
+ # readable without a bidi-rendering editor.
21
+ ARABIC_SCRIPT = re.compile(r"[؀-ۿ]")
22
+
23
+
24
+ def is_arabic(text: str | None) -> bool:
25
+ return bool(ARABIC_SCRIPT.search(text or ""))
26
+
27
+
28
+ def say(arabic: bool, *, en: str, ar: str) -> str:
29
+ """Pick the wording for the language the user asked in.
30
+
31
+ ``ar`` keeps code names, paths and identifiers in Latin script: this is the sentence around
32
+ them, not the text inside them.
33
+ """
34
+ return ar if arabic else en
35
+
36
+
37
+ # One entry per engine state. The wording is deliberately a sentence the user can act
38
+ # on, not a status code echoed back.
39
+ STATES = {
40
+ "DISCOVERING": "Exploring the project",
41
+ "WAITING_APPROVAL": "Changes ready for review",
42
+ "APPLIED_UNVERIFIED": "Applied — project tests have not run",
43
+ "VERIFICATION_BLOCKED": "Verification incomplete",
44
+ "VERIFICATION_FAILED": "Checks need attention",
45
+ "CHECKS_PASSED": "Selected checks passed",
46
+ "ROLLED_BACK": "Changes rolled back",
47
+ "CANCELLED": "Task cancelled",
48
+ "BLOCKED": "Task needs attention",
49
+ "PARTIAL_APPLY": "Partially applied — review before continuing",
50
+ "APPLYING": "Application interrupted — review the task",
51
+ }
52
+ # The same sentences for a task written in Arabic. Keyed by state, so a state that gains an
53
+ # English entry without one here still reads as its own name rather than as nothing.
54
+ STATES_AR = {
55
+ "DISCOVERING": "أستكشف المشروع",
56
+ "WAITING_APPROVAL": "التعديلات جاهزة للمراجعة",
57
+ "APPLIED_UNVERIFIED": "تم التطبيق — اختبارات المشروع لم تُشغّل بعد",
58
+ "VERIFICATION_BLOCKED": "التحقق غير مكتمل",
59
+ "VERIFICATION_FAILED": "الفحوص تحتاج مراجعة",
60
+ "CHECKS_PASSED": "الفحوص المختارة نجحت",
61
+ "ROLLED_BACK": "تم التراجع عن التعديلات",
62
+ "CANCELLED": "تم إلغاء المهمة",
63
+ "BLOCKED": "المهمة تحتاج انتباهك",
64
+ "PARTIAL_APPLY": "تم التطبيق جزئيًا — راجع قبل المتابعة",
65
+ "APPLYING": "انقطع التطبيق — راجع المهمة",
66
+ }
67
+ # States whose files exist on disk and can still be checked, run against, or rolled back.
68
+ MUTABLE_STATES = {"APPLIED_UNVERIFIED", "VERIFICATION_BLOCKED", "VERIFICATION_FAILED", "CHECKS_PASSED"}
69
+ # A task whose files are on disk without a passing command run. Starting the next task
70
+ # on top of these is how a half-checked change turns into two.
71
+ UNVERIFIED_STATES = {"APPLIED_UNVERIFIED", "VERIFICATION_FAILED", "PARTIAL_APPLY", "APPLYING"}
72
+ # An interrupted apply can leave a project that no longer matches any proposal, so the
73
+ # next task is worth a deliberate yes rather than a line of text the user might miss.
74
+ INTERRUPTED_STATES = {"PARTIAL_APPLY", "APPLYING"}
75
+
76
+ TONE = {"CHECKS_PASSED": "ok", "WAITING_APPROVAL": "warn", "APPLIED_UNVERIFIED": "warn",
77
+ "VERIFICATION_BLOCKED": "warn", "VERIFICATION_FAILED": "bad", "PARTIAL_APPLY": "bad",
78
+ "APPLYING": "bad", "ROLLED_BACK": "idle", "CANCELLED": "idle", "BLOCKED": "bad",
79
+ "DISCOVERING": "idle"}
80
+
81
+
82
+ def state_label(value: str | None, *, arabic: bool = False) -> str:
83
+ """The card's headline for an engine state.
84
+
85
+ A state missing from the Arabic table falls back to its own name rather than to English, so
86
+ a new engine state cannot arrive as a half-translated card.
87
+ """
88
+ if not value:
89
+ return "No task open"
90
+ if arabic:
91
+ return STATES_AR.get(value, value)
92
+ return STATES.get(value, value)
93
+
94
+
95
+ # --------------------------- the write, in the language it was asked for ---------------------------
96
+ # These build whole sentences rather than fragments so the two windows and the two controllers
97
+ # cannot each invent their own wording for "the files are on disk". Tool text reaches the browser
98
+ # as plain text, not markdown, so no `**bold**` here.
99
+
100
+ def artifact_title(*, arabic: bool, count: int, project: str, written: bool) -> str:
101
+ """The artifact card's title. Past tense once the write happened: this card used to read
102
+ "N proposed file(s)" after an Auto-Apply write, which is the one moment that is untrue."""
103
+ if not count:
104
+ return say(arabic, en="No applicable proposal was created",
105
+ ar="لم يُنشَأ أي اقتراح قابل للتطبيق")
106
+ if written:
107
+ return say(arabic, en=f"{count} file(s) applied to {project} and saved to disk",
108
+ ar=f"تم تطبيق {count} ملف(ات) على {project} وحُفظت على القرص")
109
+ return say(arabic, en=f"{count} proposed file(s) in {project}",
110
+ ar=f"{count} ملف(ات) مقترحة في {project} وبانتظار موافقتك")
111
+
112
+
113
+ def artifact_card(state: str | None, *, arabic: bool, count: int, project: str,
114
+ summary: str = "", written: bool = False, has_project: bool = True) -> dict:
115
+ """The whole right-hand card: headline, tone, title and detail in one language.
116
+
117
+ Both controllers build this card, and `--fake` is the window a design is reviewed in, so the
118
+ sentence lives here once rather than twice. `state` is the engine state name; `count` the
119
+ files it touches; `written` says whether they are on disk yet.
120
+ """
121
+ if not state:
122
+ return {"state": "No task open", "tone": "idle", "written": False,
123
+ "title": say(arabic, en="No proposal yet", ar="لا يوجد اقتراح بعد"),
124
+ "detail": say(arabic,
125
+ en="Choose a project to turn a request into reviewed file changes.",
126
+ ar="اختر مشروعًا لتحويل طلبك إلى تعديلات ملفات تراجعها قبل الكتابة.")
127
+ if has_project else say(
128
+ arabic,
129
+ en="This chat has no project attached, so nothing is proposed.",
130
+ ar="هذا الحوار بلا مشروع مرتبط، لذلك لا يُقترح شيء.")}
131
+ return {"state": state_label(state, arabic=arabic), "tone": TONE.get(state, ""),
132
+ # `written` travels with the card: the same file list reads as "saved to disk" after a
133
+ # click and as "waiting for you" before it, and only this side knows which.
134
+ "written": bool(count) and written,
135
+ "title": artifact_title(arabic=arabic, count=count, project=project, written=written),
136
+ # Stripped before the fallback test, because a model that answers with whitespace alone
137
+ # used to leave the card with an empty detail line rather than the advice.
138
+ "detail": (summary or "").strip()[:240] or say(
139
+ arabic, en="Nothing to apply for this task.", ar="لا يوجد ما يُطبَّق في هذه المهمة.")}
140
+
141
+
142
+ def run_warning(*, arabic: bool) -> str:
143
+ """The sentence that says what pressing Run actually does.
144
+
145
+ It lived in `runner.py`'s module docstring and in a code comment, which is how a person who never
146
+ opens a source file misses it: the command is the project's own build tool, run with this user's
147
+ permissions in this folder. The argv allowlist and the stripped environment decide *which*
148
+ program runs and what it inherits; they do not stop it doing whatever that program does. A build
149
+ script is code, and code from a repository someone else wrote is being executed here.
150
+ """
151
+ return say(arabic,
152
+ en="\u26a0\ufe0f Run and Check syntax execute this project's own build code, with your "
153
+ "permissions in this folder. The command is allowlisted and its environment is "
154
+ "stripped, but that is a limit, not a sandbox.",
155
+ ar="\u26a0\ufe0f زرّا Run وCheck syntax ينفّذان كود البناء الخاص بالمشروع نفسه، "
156
+ "بصلاحياتك في هذا المجلد. الأمر من قائمة مسموحات والبيئة مُجرَّدة، "
157
+ "لكن هذا تقييد وليس صندوق معزول.")
158
+
159
+
160
+ def applied_note(*, arabic: bool, count: int) -> str:
161
+ """The line on the card reminding the user that no Apply click was involved in this write.
162
+
163
+ It names the disk, because "applied" alone was the wording this round set out to fix: the one
164
+ sentence that follows an unattended write should not need interpreting.
165
+ """
166
+ return say(arabic,
167
+ en=f"\u2705 Applied and saved to disk: {count} file(s), with no Apply click."
168
+ f" Roll back from this card.",
169
+ ar=f"\u2705 تم التطبيق والحفظ على القرص: {count} ملف(ات) بلا ضغطة Apply."
170
+ f" التراجع من هذه البطاقة.")
171
+
172
+
173
+ def write_notice(*, arabic: bool, count: int, summary: str = "", lines: int = 0,
174
+ total: int = 0, rewrote: bool = False) -> str:
175
+ """What the thread says after Auto-Apply has already written.
176
+
177
+ The model's summary rides along, because for an unattended write this is the first place the
178
+ user reads what the model decided to do.
179
+
180
+ `lines`/`total` say how much of the file that was already there survived. A task that named one
181
+ line and replaced thirty is the shape that loses a `package` declaration, and the number is the
182
+ only part of it that fits in a row the user actually reads.
183
+ """
184
+ parts = [say(arabic,
185
+ en=f"\u26a1 Auto-Apply saved {count} file(s) directly to the project, because this"
186
+ f" folder's switch is on.",
187
+ ar=f"\u26a1 تم حفظ وتطبيق {count} ملف(ات) مباشرة في المشروع لأن مفتاح هذا المجلد مُفعّل.")]
188
+ if lines:
189
+ if not total:
190
+ parts.append(say(arabic, en=f"\U0001f5d2 {lines} line(s) written.",
191
+ ar=f"\U0001f5d2 كُتب {lines} سطرًا."))
192
+ elif rewrote:
193
+ parts.append(say(
194
+ arabic,
195
+ en=f"\U0001f5d2 {lines} line(s) changed — every one of the {total} lines the file "
196
+ f"already had was replaced, not only the lines the task named.",
197
+ ar=f"\U0001f5d2 تم تعديل {lines} سطرًا — استُبدلت الأسطر {total} التي كانت في الملف "
198
+ f"وليس الأسطر التي ذكرتها المهمة فقط."))
199
+ else:
200
+ parts.append(say(arabic, en=f"\U0001f5d2 {lines} line(s) changed in a file of {total}.",
201
+ ar=f"\U0001f5d2 تم تعديل {lines} سطرًا في ملف من {total} سطرًا."))
202
+ if (summary or "").strip():
203
+ parts.append(say(arabic, en=f"\U0001f4c4 Task summary: {summary.strip()}",
204
+ ar=f"\U0001f4c4 ملخص التعديل: {summary.strip()}"))
205
+ parts.append(say(arabic,
206
+ en="The files are on disk. Click any file above to inspect its diff, or use"
207
+ " Roll back to undo the whole task.",
208
+ ar="الملفات محفوظة على القرص. اضغط على أي ملف للمعاينة، أو استخدم التراجع للعودة."))
209
+ return "\n\n".join(parts)
210
+
211
+
212
+ def checkpoint_note(*, arabic: bool, count: int, sha: str) -> str:
213
+ """The git commit that covers a write. `sha` and `--no-verify` stay Latin in both languages."""
214
+ files = say(arabic, en=f"{count} file(s)", ar=f"{count} ملف(ات)")
215
+ return say(arabic,
216
+ en=f"Git checkpoint {sha} covers the {files} this task wrote. The commit was made"
217
+ f" with --no-verify, so the repository's own hooks did not run; anything else in"
218
+ f" that folder stayed where it was.",
219
+ ar=f"نقطة الحفظ في git برقم {sha} تغطي {files} التي كتبتها هذه المهمة. تم الالتزام"
220
+ f" بوسم --no-verify، لذلك لم تُشغَّل خطافات المستودع؛ وبقي كل شيء آخر في هذا"
221
+ f" المجلد كما هو.")
222
+
223
+
224
+ def no_checkpoint_note(*, arabic: bool, reason: str) -> str:
225
+ """The reason comes from git itself and stays as it arrived — the wrapper is what translates."""
226
+ return say(arabic, en=f"No git checkpoint: {reason}", ar=f"لا توجد نقطة حفظ في git: {reason}")
227
+
228
+
229
+ # Moving HEAD is the one git action this window can be asked to do that changes where *future*
230
+ # commits land, so all three sentences say what moved and what did not. A branch name is a code
231
+ # name: Latin in both languages, like a path or a command.
232
+ def branch_started(*, arabic: bool, branch: str, back: str = "") -> str:
233
+ return say(arabic,
234
+ en=f"🌿 Started git branch {branch}. This task's commits land there, and everything"
235
+ f" uncommitted in the working tree came with you"
236
+ + (f". The branch chip offers the way back to {back}." if back else "."),
237
+ ar=f"🌿 تم إنشاء فرع git باسم {branch}. تعليقات هذه المهمة تُحفظ فيه، وكل ما لم"
238
+ f" يُعلَّق بعد في مجلد العمل انتقل معك"
239
+ + (f". الشريط يعيدك إلى {back}." if back else "."))
240
+
241
+
242
+ def branch_switched(*, arabic: bool, branch: str) -> str:
243
+ return say(arabic,
244
+ en=f"🌿 Back on git branch {branch}. Nothing was forced: files that were not"
245
+ f" committed stayed exactly where they were.",
246
+ ar=f"🌿 عدت إلى فرع git باسم {branch}. لم يُجبَر شيء: الملفات التي لم تُعلَّق بقيت"
247
+ f" كما هي.")
248
+
249
+
250
+ def no_branch_note(*, arabic: bool, reason: str) -> str:
251
+ """git's own refusal line is the useful half, so it is kept verbatim inside a translated wrapper."""
252
+ return say(arabic, en=f"No branch change: {reason}",
253
+ ar=f"لا تغيير في الفروع: {reason}")
254
+
255
+
256
+ # The restore offer only ever appears after the session's own rollback refused, so the sentences say
257
+ # what git can still do and what it will overwrite while doing it. A hash is a code name: Latin.
258
+ def restore_offer(*, arabic: bool, commit: str, count: int) -> str:
259
+ """One sentence for the refusal *and* its remedy.
260
+
261
+ `friendly_error` already answers this PolicyError with "create a new proposal", which is the right
262
+ advice where git has no idea what the task wrote and the wrong advice here — two remedies in the
263
+ thread for one fact is how this project keeps having to undo drift. So the row that appears when a
264
+ git copy exists states the refusal itself rather than repeating the generic one.
265
+ """
266
+ files = say(arabic, en=f"{count} file(s)", ar=f"{count} ملف(ات)")
267
+ return say(arabic,
268
+ en=f"⚠️ Rollback refused: those files changed after this task wrote them. git can"
269
+ f" still put {files} back to commit {commit}. The button appears beside the branch"
270
+ f" chip: it replaces only the files this task wrote, and it overwrites whatever is"
271
+ f" in them now.",
272
+ ar=f"⚠️ رفض التراجع: هذه الملفات تغيّرت بعد أن كتبتها هذه المهمة. ما زال بمقدور git"
273
+ f" أن يُرجع {files} إلى الكوميت {commit}. الزر يظهر بجوار شارة الفرع: يستبدل ملفات"
274
+ f" هذه المهمة فقط، ويكتب فوق ما فيها الآن.")
275
+
276
+
277
+ def restore_done(*, arabic: bool, commit: str, restored: list, skipped: list) -> str:
278
+ files = say(arabic, en=f"{len(restored)} file(s)", ar=f"{len(restored)} ملف(ات)")
279
+ text = say(arabic, en=f"⎇ Restored {files} from commit {commit}.",
280
+ ar=f"⎇ تم إرجاع {files} من الكوميت {commit}.")
281
+ if skipped:
282
+ text += say(arabic,
283
+ en=" git had no copy of: " + ", ".join(skipped) + " — those files are unchanged.",
284
+ ar=" لم يجد git نسخة من: " + ", ".join(skipped) + " — بقيت كما هي.")
285
+ return text
286
+
287
+
288
+ # One status sentence, one home. Twenty of these were written twice — once in `gui.py` and once in
289
+ # `webapp/controller.py` — which is the drift code-review item 16 counted with its "52 against 72".
290
+ # The English is byte-identical to what both windows already said, so the only thing a user can
291
+ # notice is that the fallback window can now answer in the language it was asked in.
292
+ STATUS_TEXTS = {
293
+ "apply_first": ("Apply a reviewed proposal before running project commands.",
294
+ "طبّق المقترح بعد مراجعته قبل تشغيل أوامر المشروع."),
295
+ "consent_message": ("Approve sending this message to the cloud service before using a cloud model.",
296
+ "اسمح بإرسال هذه الرسالة إلى الخدمة السحابية قبل استخدام موديل سحابي."),
297
+ "consent_project": ("Approve sending this project to the cloud service before continuing.",
298
+ "اسمح بإرسال هذا المشروع إلى الخدمة السحابية قبل المتابعة."),
299
+ "applied_rerun": ("Changes applied. Running the command again\u2026",
300
+ "تم التطبيق. يتم تشغيل الأمر مرة أخرى\u2026"),
301
+ "applied_idle": ("Changes applied. You can check syntax or run the project's own command.",
302
+ "تم التطبيق. يمكنك فحص الصياغة أو تشغيل أمر المشروع نفسه."),
303
+ "applied_no_command": ("Changes applied. Nothing ran afterwards: this folder has no runnable "
304
+ "command the tool can use.",
305
+ "تم التطبيق. لم يعمل شيء بعده: هذا المجلد لا يحتوي على أمر قابل "
306
+ "للتشغيل يعرفه البرنامج."),
307
+ "need_folder_notes": ("Choose a project folder before saving notes.",
308
+ "اختر مجلد مشروع قبل حفظ الملاحظات."),
309
+ "need_folder_exists": ("Choose an existing project folder, or clear it to chat without one.",
310
+ "اختر مجلد مشروع موجود، أو امسحه للتحدث بدون مشروع."),
311
+ "registry_unsaved": ("Could not save the project list.",
312
+ "تعذّر حفظ قائمة المشاريع."),
313
+ "fix_ready": ("Fix proposal ready. Review it and apply it \u2014 the command then runs again by itself.",
314
+ "مقترح الإصلاح جاهز. راجعه وطبّقه، وبعدها سيعمل الأمر من تلقاء نفسه."),
315
+ "no_proposal": ("No applicable proposal was created. See the conversation for details.",
316
+ "لم يُنشأ مقترح صالح. راجع المحادثة للتفاصيل."),
317
+ "ask_expired": ("That question waited too long and was withdrawn. Nothing was changed.",
318
+ "انتهى انتظار هذا السؤال فسُحب. لم يُغيَّر شيء."),
319
+ "no_recipe": ("No runnable command was detected in this project folder.",
320
+ "لم يُعثر على أمر قابل للتشغيل داخل هذا المجلد."),
321
+ "prior_unverified": ("Previous task is still unverified \u2014 see the conversation.",
322
+ "المهمة السابقة لم يتم التحقق منها بعد — راجع المحادثة."),
323
+ "granted": ("Project folder granted: ",
324
+ "تم منح الوصول إلى مجلد المشروع: "),
325
+ "proposal_ready": ("Proposal ready. Review the changes, then apply them if you want.",
326
+ "المقترح جاهز. راجع التغييرات ثم طبّقها إن أردت."),
327
+ "pick_model": ("Refresh models and select a model from the available list.",
328
+ "حدّث قائمة الموديلات واختر موديلًا منها."),
329
+ "ledger_needs_look": ("Rolled back, but the plan ledger needs a look: ",
330
+ "تم التراجع، لكن سجل الخطة يحتاج مراجعة: "),
331
+ "task_opened": ("Saved task opened.",
332
+ "تم فتح المهمة المحفوظة."),
333
+ "rolled_back": ("Task changes rolled back.",
334
+ "تم التراجع عن تغييرات المهمة."),
335
+ "command_passed": ("The command passed. ",
336
+ "نجح الأمر. "),
337
+ "too_long": ("Type your message in up to 4,000 characters and select a model.",
338
+ "اكتب رسالتك في حدود 4,000 حرف واختر موديلًا."),
339
+ }
340
+
341
+ # The sentences only one window can truthfully say. A web question travels to the browser and the
342
+ # browser may simply never answer it, so the wait expires and the ask is withdrawn; Tk's messagebox
343
+ # holds the mainloop until it is answered, so it has no such moment to report.
344
+ # Sentences that belong to one window by construction, not by drift: the web window's ask has no Tk
345
+ # counterpart (a Tk dialog is modal), and neither has Auto-Apply, so the line that follows a write
346
+ # nobody clicked for -- and the folder that has no command to check it with -- can only appear there.
347
+ SINGLE_WINDOW_STATUS = ("ask_expired", "applied_no_command")
348
+
349
+
350
+ # The sentences one window used to write for itself. Each of these existed twice — in `gui.py` and in
351
+ # `webapp/controller.py` — and seven of them had already drifted: Tk said "click Send" where the web
352
+ # window said "press Send", the web note about saved project notes lost the half that explains *why*
353
+ # they are safe, and Tk's stop notice promised "no changes will be applied" that the web one stopped
354
+ # saying. The wording below is the more informative of the two, in both languages, from one place.
355
+ #
356
+ # `{field}` is filled by `note()`. A sentence that needs a field the caller forgot raises KeyError,
357
+ # because a half-formatted status line is the kind of bug nobody notices until it is on screen.
358
+ NOTE_TEMPLATES = {
359
+ "plan_attached_chained": (
360
+ "Plan attached. Send starts its first unfinished step; each step unlocks the next only after "
361
+ "a command run proves it. The message box is an optional note.",
362
+ "أُرفقت الخطة. الإرسال يبدأ أول خطوة غير منتهية، وكل خطوة تفتح التالية فقط بعد تشغيل أمر "
363
+ "يُثبتها. صندوق الرسالة ملاحظة اختيارية."),
364
+ "plan_attached_plain": ("Plan attached. Describe the phase, then press Send.",
365
+ "أُرفقت الخطة. صف المرحلة ثم اضغط Send."),
366
+ "notes_saved": ("Project notes saved outside the project folder, so a proposal cannot rewrite them.",
367
+ "حُفظت ملاحظات المشروع خارج مجلد المشروع، لذا لا يستطيع أي مقترح إعادة كتابتها."),
368
+ "notes_in_request": ("Your saved project notes ({count} characters) are part of this request.",
369
+ "ملاحظاتك المحفوظة عن المشروع ({count} حرفًا) جزء من هذا الطلب."),
370
+ "new_chat_plain": ("New chat — it answers in prose and reads no project files. Choose a project "
371
+ "and it can read that folder too.",
372
+ "محادثة جديدة — تجيب بنثر ولا تقرأ ملفات مشروع. اختر مشروعًا ليُقرأ مجلده أيضًا."),
373
+ "new_chat_project": ("New chat in {project} — Send answers in prose; the badge by Send switches "
374
+ "to reviewed changes.",
375
+ "محادثة جديدة في {project} — الإرسال يجيب بنثر، والشارة بجوار Send تحوّل إلى "
376
+ "التغييرات بعد المراجعة."),
377
+ "sample_ready": ("Sample ready. Choose a model, then press Send.",
378
+ "المثال جاهز. اختر موديلًا ثم اضغط Send."),
379
+ "chat_reopened_plain": ("Chat reopened — still no project attached.",
380
+ "أُعيد فتح المحادثة — لا يوجد مشروع مرفق بعد."),
381
+ "chat_reopened_project": ("Chat reopened — it reads {project} as context.",
382
+ "أُعيد فتح المحادثة — تقرأ {project} كسياق."),
383
+ "stop_requested": ("Stop requested. Waiting for the current model request to finish; no changes "
384
+ "will be applied.",
385
+ "طُلب الإيقاف. في انتظار انتهاء طلب الموديل الحالي، ولن تُطبَّق أي تغييرات."),
386
+ "syntax_failed": ("Syntax check found a problem. Open the Checks tab for details.",
387
+ "فحص الصياغة وجد مشكلة. افتح تبويب Checks للتفاصيل."),
388
+ # Both windows say this after a proposal lands, because both now keep the diff in a viewer of its
389
+ # own rather than in a pane inside the conversation: the sentence has to name where to look.
390
+ "review_here": ("Review the files in the proposal viewer, then press Apply to write them.",
391
+ "راجع الملفات في نافذة المقترح، ثم اضغط Apply لكتابتها."),
392
+ # The four answers the sandbox switch gives, in the card where the switch sits. Both windows say
393
+ # the same sentence because the same question is being asked at the same moment: right before Run.
394
+ "sandbox_missing": ("Docker is not installed on this machine, so every command runs here.",
395
+ "Docker غير مثبّت على هذا الجهاز، لذلك تُشغَّل كل الأوامر هنا."),
396
+ "sandbox_off": ("The command runs on this machine, inside the project folder.",
397
+ "يُشغَّل الأمر على هذا الجهاز داخل مجلد المشروع."),
398
+ "sandbox_unpinned": ("Name a preloaded image as name@sha256:… — a tag can be retagged while a "
399
+ "build is running.",
400
+ "اكتب اسم صورة مُحمَّلة مسبقًا بالشكل name@sha256:… لأن الوسم يمكن تغييره "
401
+ "أثناء تشغيل البناء."),
402
+ "sandbox_on": ("The command runs on a copy of the project, with no network and nothing written "
403
+ "back to your files.",
404
+ "يُشغَّل الأمر على نسخة من المشروع، بلا شبكة وبدون كتابة أي شيء في ملفاتك."),
405
+ "syntax_clean": ("Syntax checks finished. Project tests have not run; verification remains "
406
+ "incomplete.",
407
+ "انتهى فحص الصياغة. اختبارات المشروع لم تُشغَّل، فالتحقق ما زال ناقصًا."),
408
+ "step_open_detail": ("The command passed, but step {step} is not marked done: {reason}",
409
+ "نجح الأمر، لكن الخطوة {step} لم تُعلَّم كمنتهية: {reason}"),
410
+ "step_still_open": ("Step {step} still open — see the conversation.",
411
+ "الخطوة {step} ما زالت مفتوحة — راجع المحادثة."),
412
+ "step_reopened": ("Reopened plan step {step}: the files that passed are gone, so the step must be "
413
+ "implemented again.",
414
+ "أُعيد فتح الخطوة {step} من الخطة: الملفات التي نجحت زالت، لذا يجب تنفيذ الخطوة "
415
+ "من جديد."),
416
+ # Leading spaces are part of the text: these three are appended to `prior_write`, which ends in a
417
+ # full stop. Keeping the space here is what lets both windows join the same two pieces.
418
+ "apply_rerun_warning": ("\nIt will then run {label} again in that folder, which executes the "
419
+ "project's own build and test code.",
420
+ "\nسيُشغَّل {label} مرة أخرى في هذا المجلد بعد ذلك، وهذا ينفّذ كود البناء "
421
+ "والاختبار الخاص بالمشروع نفسه."),
422
+ "prior_write": ("The last task here ('{task}') left files applied without a passing command run "
423
+ "({state}).",
424
+ "آخر مهمة هنا ('{task}') تركت ملفات مطبَّقة بدون تشغيل أمر ناجح ({state})."),
425
+ "prior_continue": ("\n\nContinue with a new task anyway? Rolling back that task first is the "
426
+ "safer step.",
427
+ "\n\nهل تضيف مهمة جديدة على أي حال؟ الرجوع عن تلك المهمة أولًا هو الأأمن."),
428
+ "prior_blocked": (" Roll it back or run its command, then start the new task.",
429
+ " تراجع عنها أو شغّل أمرها، ثم ابدأ المهمة الجديدة."),
430
+ "prior_continued": (" Continuing on this state by your choice.",
431
+ " نكمل على هذه الحالة باختيارك."),
432
+ "prior_stacked": (" Run its command (or roll it back) before stacking more changes on top of it.",
433
+ " شغّل أمرها أو تراجع عنها قبل تكديس تغييرات أخرى فوقها."),
434
+ }
435
+
436
+ # `apply_rerun_warning` is the only one with no second-language twin in the other window: Tk has no
437
+ # Auto-Apply switch, so nothing else in it can promise a command will run by itself.
438
+ NOTE_KEYS = tuple(NOTE_TEMPLATES)
439
+
440
+
441
+ def note(key: str, *, arabic: bool = False, **fields) -> str:
442
+ """One of the sentences both windows owe the user, in the language the task was asked in.
443
+
444
+ A key that does not exist raises rather than answering in the wrong language, and a field the
445
+ caller forgot raises too — a status line that prints `{step}` is worse than no status line.
446
+ """
447
+ if key not in NOTE_TEMPLATES:
448
+ raise KeyError("no shared sentence named " + str(key))
449
+ english, arabic_text = NOTE_TEMPLATES[key]
450
+ return say(arabic, en=english, ar=arabic_text).format(**fields)
451
+
452
+
453
+ def status_text(key: str, *, arabic: bool = False, tail: str = "") -> str:
454
+ """One of the shared status sentences, in the language the task was asked in.
455
+
456
+ `tail` is for the four that end in a colon or a full stop and then carry what only this moment
457
+ knows — a path, a command's own summary, an error. Those stay Latin, as everywhere else here.
458
+ A key nobody defined raises rather than answering in the wrong language.
459
+ """
460
+ if key not in STATUS_TEXTS:
461
+ raise KeyError("no shared status sentence named " + str(key))
462
+ english, arabic_text = STATUS_TEXTS[key]
463
+ return say(arabic, en=english, ar=arabic_text) + tail
464
+
465
+
466
+ # The step vocabulary the agent's own work is announced with. Paths, commands, file names and the
467
+ # emoji stay Latin in both languages, exactly as everywhere else in this file.
468
+ STEP_MAX_FILES = 6
469
+ # The fields a step record carries. `digest` is the eight leading characters of what was read, and it
470
+ # is in the record and not the sentence because "it read the file" and "it read the file as it stood
471
+ # before the last write" are different claims — that difference is what a row opens to say. `label` is
472
+ # the recipe name a build error came from: the rebuild in `display_session` filters the stored record
473
+ # through this tuple, so a field left out of it makes a reopened task say less than the live row did.
474
+ STEP_FIELDS = ("path", "query", "count", "names", "reason", "detail", "digest", "label")
475
+
476
+
477
+ def step_has_detail(action: str, fields: dict | None = None) -> bool:
478
+ """Whether a step row has anything behind it.
479
+
480
+ Both windows ask this one function, and the client draws its chevron from the answer, because a row
481
+ that opens onto nothing teaches the reader to stop opening rows. A running command is the exception
482
+ and the client knows it: while the job is live the row's detail is the output streaming in, which no
483
+ stored record has yet.
484
+ """
485
+ fields = fields or {}
486
+ if action == "executing":
487
+ return False
488
+ if action == "executed":
489
+ return bool(fields.get("command"))
490
+ if action in {"propose", "applied"}:
491
+ return bool(fields.get("names"))
492
+ if action == "read_file":
493
+ return bool(fields.get("digest"))
494
+ if action == "model_reasoning":
495
+ return bool(fields.get("detail"))
496
+ return action in {"search_code", "list_files"}
497
+
498
+
499
+ # Why the engine picked a file, said in the thread's own words. The engine sends a code and the symbol
500
+ # it came from; a reason written at the call site would be a sentence in the engine's voice, which is
501
+ # how the two windows ended up disagreeing about a refusal once already.
502
+ CONTEXT_REASON = {
503
+ "declares": ("declares {}", "يُعرّف {}"),
504
+ "defines": ("defines {}", "يحوي تعريف {}"),
505
+ "imports": ("imports {}", "يستقدم {}"),
506
+ "module": ("its folder is named in the task", "مجلده مذكور في المهمة"),
507
+ "names": ("the task names this file", "المهمة تسمي هذا الملف"),
508
+ }
509
+
510
+
511
+ def graph_caption(arabic: bool, *, nodes: int, edges: int, cyclic: bool = False,
512
+ hidden: int = 0) -> str:
513
+ """One line under the module graph: what is drawn, and what the drawing leaves out.
514
+
515
+ Server-written for the same reason the setup tally is: the sheet is one surface and the sentence
516
+ about what it omits belongs with the code that did the omitting. A cycle is said out loud because a
517
+ column that is approximate looks exactly like a column that is right.
518
+ """
519
+ parts = [say(arabic, en=f"{nodes} modules, {edges} dependencies",
520
+ ar=f"{nodes} موديول، {edges} تبعية")]
521
+ if cyclic:
522
+ parts.append(say(arabic, en="a cycle was found, so its column is approximate",
523
+ ar="لقيت دورة، فالعمود بتاعها تقريبي"))
524
+ if hidden:
525
+ parts.append(say(arabic, en=f"{hidden} smaller modules are not drawn",
526
+ ar=f"{hidden} موديول أصغر مش رسمانين"))
527
+ return " · ".join(parts)
528
+
529
+
530
+ def step_line(arabic: bool, action: str, *, path: str = "", query: str = "", count: int = 0,
531
+ names: list | None = None, reason: str = "", detail: str = "", digest: str = "",
532
+ label: str = "") -> str:
533
+ """One line for one thing the agent just did.
534
+
535
+ These reach the chat as tool rows, so they are plain text by construction. An action this
536
+ function has never heard of still gets a line — a new tool silently vanishing from the thread is
537
+ the failure to avoid here.
538
+
539
+ `count` and `digest` are recorded and deliberately not spoken: the row says what happened, and what
540
+ it found is what opening the row answers.
541
+ """
542
+ if action == "read_file":
543
+ return say(arabic, en=f"\U0001f4d6 Reading file: {path}", ar=f"\U0001f4d6 قراءة الملف: {path}")
544
+ if action == "search_code":
545
+ return say(arabic, en=f"\U0001f50d Searching code: {query}", ar=f"\U0001f50d البحث في الكود: {query}")
546
+ if action == "list_files":
547
+ return say(arabic, en="\U0001f4c1 Scanning project files...",
548
+ ar="\U0001f4c1 فحص ملفات المشروع...")
549
+ if action == "propose":
550
+ listed = [str(name) for name in (names or [])]
551
+ if len(listed) > STEP_MAX_FILES:
552
+ extra = len(listed) - STEP_MAX_FILES
553
+ listed = listed[:STEP_MAX_FILES] + [say(arabic, en=f"+{extra} more", ar=f"+{extra} أخرى")]
554
+ body = say(arabic, en=f"\u270d\ufe0f Proposed changes for {count} file(s)",
555
+ ar=f"\u270d\ufe0f اقتراح تعديلات على {count} ملف(ات)")
556
+ named = ", ".join(listed)
557
+ return f"{body}: {named}" if named else body
558
+ if action == "find_symbol":
559
+ return say(arabic, en=f"\U0001f9ed Looking up: {query}", ar=f"\U0001f9ed البحث عن الرمز: {query}")
560
+ if action == "find_references":
561
+ return say(arabic, en=f"\U0001f9ed Finding uses of: {query}",
562
+ ar=f"\U0001f9ed البحث عن استخدامات: {query}")
563
+ if action == "context_files":
564
+ # The reason travels as a code and a symbol, never as a finished sentence: which file was chosen
565
+ # is the engine's decision, and how it is said belongs here with the rest of the thread's words.
566
+ listed = []
567
+ for row in (names or []):
568
+ phrase = CONTEXT_REASON.get(str(row.get("why", "")), "")
569
+ why = say(arabic, en=phrase[0], ar=phrase[1]).replace("{}", str(row.get("symbol", ""))) \
570
+ if phrase else ""
571
+ listed.append(f"{row.get('path', '')}" + (f" ({why})" if why else ""))
572
+ body = say(arabic, en=f"\U0001f3af Chose {count} file(s) for this task",
573
+ ar=f"\U0001f3af اختيرت {count} ملف(ات) لهذه المهمة")
574
+ return f"{body}: {', '.join(listed)}" if listed else body
575
+ if action == "model_reasoning":
576
+ # The preview is one line; the whole thought is what the row opens to. A model that thinks out
577
+ # loud gets to be read, but not at the cost of the thread it works in.
578
+ shown = str(detail or "").strip().splitlines()[0][:110] if detail else ""
579
+ body = say(arabic, en=f"\U0001f9ed Thought for {count} characters",
580
+ ar=f"\U0001f9ed فكر {count} حرف")
581
+ return f"{body}: {shown}…" if shown else body
582
+ if action == "unresolved_error":
583
+ # D42: the same failure, from a task this window is not looking at. Said once at the start of a
584
+ # turn rather than rediscovered by a model that has no memory of the last conversation.
585
+ body = say(arabic, en=f"\U0001f501 {count} earlier task(s) left this build error open",
586
+ ar=f"\U0001f501 {count} مهمة سابقة سابت خطأ البناء ده من غير حل")
587
+ where = f" ({label})" if label else ""
588
+ return f"{body}{where}: {detail}" if detail else f"{body}{where}"
589
+ if action == "blocked":
590
+ return say(arabic, en=f"\u26d4 Blocked: {reason}", ar=f"\u26d4 توقفت المهمة: {reason}")
591
+ if action == "applied":
592
+ return applied_line(arabic, count=count)
593
+ if action == "executing":
594
+ return executing_line(arabic, command=path)
595
+ return say(arabic, en=f"\u2699\ufe0f {action}" + (f": {detail}" if detail else ""),
596
+ ar=f"\u2699\ufe0f {action}" + (f": {detail}" if detail else ""))
597
+
598
+
599
+ def applied_line(*, arabic: bool, count: int, removed: int = 0) -> str:
600
+ """What the thread says after a write the user approved by hand.
601
+
602
+ `applied_note` is the banner on the card, and it names the disk because that write was
603
+ unattended; this one is the sentence for the click, so it says what happened and where to undo it.
604
+ A removal is named apart because "Applied changes to 2 file(s)" over a file that is now gone
605
+ reads as a rewrite of something the folder no longer has.
606
+ """
607
+ tail = say(arabic, en=f", removing {removed} of them", ar=f"، وحُذف {removed} منها") if removed else ""
608
+ return say(arabic,
609
+ en=f"\U0001f4be Applied changes to {count} file(s){tail}. "
610
+ "Roll back undoes the whole task.",
611
+ ar=f"\U0001f4be تم تطبيق التعديلات على {count} ملف(ات){tail}. "
612
+ "التراجع يُلغي المهمة كاملة.")
613
+
614
+
615
+ def run_unrecorded_line(*, arabic: bool, project: str) -> str:
616
+ """A project command that ran with no applied task to carry the verdict.
617
+
618
+ The run is real and its output is in Activity; what is missing is a session to write the result
619
+ on, because the task before it blocked or rolled back. Left unsaid, that looks like the tool
620
+ invented a pass for a task it never applied.
621
+ """
622
+ return say(arabic,
623
+ en=f"\u23f1 {project} was checked, but no applied task is open to hold the result — "
624
+ "the output is in Activity.",
625
+ ar=f"\u23f1 تم فحص {project}، لكن لا توجد مهمة مطبَّقة تحمل النتيجة — المُخرج في Activity.")
626
+
627
+
628
+ def fix_offers_off_line(*, arabic: bool) -> str:
629
+ """The row that says the batch stopped being asked. Suppression with no sentence reads as the
630
+ tool ceasing to care about the failure, and the failure line above it is the only evidence left."""
631
+ return say(arabic,
632
+ en="\u23ed Fix offers are off for the rest of this batch. Each failure is still "
633
+ "reported and nothing is fixed on its own.",
634
+ ar="\u23ed إيقاف أسطر الإصلاح لبقية هذه الدفعة. كل فشل ما زال يُعرض ولا يُصلح شيء تلقائيًا.")
635
+
636
+
637
+ def batch_summary_line(*, arabic: bool, tasks: int, files: int, paths: list[str],
638
+ short: list[str]) -> str:
639
+ """The row that closes a queue batch: what ran, what landed, and what fell short.
640
+
641
+ A batch of ten tasks used to leave ten separate write notices and no answer to "what did that
642
+ actually do to my folder" — D36. Paths are basename-only because the row is read in a narrow
643
+ column, and the full paths are on each task's own card.
644
+ """
645
+ listed = ", ".join(str(path).replace("\\", "/").rsplit("/", 1)[-1] for path in paths[:6])
646
+ if len(paths) > 6:
647
+ listed += f", +{len(paths) - 6} more"
648
+ parts = [say(arabic,
649
+ en=f"\U0001f4e6 Batch finished: {tasks} task(s), {files} file(s) written.",
650
+ ar=f"\U0001f4e6 انتهت الدفعة: {tasks} مهمة(ات) و{files} ملف(ات).")]
651
+ if listed:
652
+ parts.append("\U0001f4c1 " + listed)
653
+ for task in short[:3]:
654
+ parts.append(say(arabic,
655
+ en=f"\u26a0 A task asked for a file count it did not deliver: {task}",
656
+ ar=f"\u26a0 طلبت مهمة عددًا من الملفات لم يُسلَّم: {task}"))
657
+ return "\n".join(parts)
658
+
659
+
660
+ def executing_line(*, arabic: bool, command: str) -> str:
661
+ """The command about to run.
662
+
663
+ Naming the argv is the point: a project's own build executes code the repository defines, and
664
+ this is the last line the user reads before it does.
665
+ """
666
+ return say(arabic, en=f"\u2699\ufe0f Executing: {command}", ar=f"\u2699\ufe0f تنفيذ الأمر: {command}")
667
+
668
+
669
+ # What a command came back with, in the short form a step row carries. `runner.summarize` is the
670
+ # long sentence for the status strip; these are the words inside "Ran mvn -B test — failed · exit 1".
671
+ RUN_WORDS = {"passed": ("passed", "نجح"), "failed": ("failed", "فشل"),
672
+ "timeout": ("timed out", "انتهت مهلته"), "unverified": ("no tests ran", "لم تُشغَّل اختبارات"),
673
+ "unavailable": ("not available", "غير متاح")}
674
+
675
+
676
+ def run_verdict(arabic: bool, run: dict) -> str:
677
+ status, arabic_status = RUN_WORDS.get(str(run.get("status")), RUN_WORDS["unverified"])
678
+ parts = [say(arabic, en=status, ar=arabic_status),
679
+ f"exit {run.get('exit_code')}", f"{run.get('seconds')}s"]
680
+ proof = run.get("proof") or {}
681
+ if proof.get("tests"):
682
+ parts.append(say(arabic, en=f"{proof['tests']} tests", ar=f"{proof['tests']} اختبار"))
683
+ if run.get("truncated"):
684
+ parts.append(say(arabic, en="output trimmed", ar="المُخرج مختصر"))
685
+ return " · ".join(str(part) for part in parts)
686
+
687
+
688
+ def executed_line(*, arabic: bool, command: str, verdict: str) -> str:
689
+ """The same row once the command has answered.
690
+
691
+ A thread left holding "Executing mvn -B test" after the build finished is describing a moment
692
+ that has passed, and the reader cannot tell a finished run from one they are still waiting on.
693
+ """
694
+ return say(arabic, en=f"\u2699\ufe0f Ran {command} — {verdict}",
695
+ ar=f"\u2699\ufe0f نُفِّذ {command} — {verdict}")
696
+
697
+
698
+ # The words on the inside of a step row. A row that cannot answer one of these has nothing to open,
699
+ # and the client draws no chevron for it (docs/UI41-STEP-ROWS-PLAN.md).
700
+ DETAIL_SECTIONS = {
701
+ "command": ("Command", "الأمر"),
702
+ "result": ("Result", "النتيجة"),
703
+ "problems": ("Reported problems", "المشكلات المُبلَّغ عنها"),
704
+ "output": ("End of output", "آخر المُخرج"),
705
+ "files": ("Files in this change", "ملفات هذا التغيير"),
706
+ "file": ("File", "الملف"),
707
+ "read": ("Read by the agent", "قرأها الوكيل"),
708
+ "search": ("Search", "البحث"),
709
+ "reasoning": ("What it thought first", "ما فكر فيه الأول"),
710
+ }
711
+
712
+
713
+ def detail_section(arabic: bool, key: str) -> str:
714
+ english, arabic_text = DETAIL_SECTIONS[key]
715
+ return say(arabic, en=english, ar=arabic_text)
716
+
717
+
718
+ def graph_empty_line(*, arabic: bool) -> str:
719
+ """The answer to a click on the graph button when there is no graph.
720
+
721
+ A sheet that opens empty reads as a project with no structure; the true sentence is that this window
722
+ has no folder to walk, or none with a source file in it yet.
723
+ """
724
+ return say(arabic,
725
+ en="Nothing to draw yet: this window has no project folder with source files in it.",
726
+ ar="مفيش حاجة ارسمها لحد دلوقتي: النافذة دي ملهاش فولدر بروجيكت فيه ملفات كود.")
727
+
728
+
729
+ def step_missing_line(*, arabic: bool) -> str:
730
+ """The answer to a click on a row this window has no record of.
731
+
732
+ A row that opens onto silence is the dead-button failure all over again, and the honest sentence is
733
+ short: the page is behind the task, and reloading fixes it.
734
+ """
735
+ return say(arabic,
736
+ en="This step is not in the task this window has open. Reload to see the current one.",
737
+ ar="هذه الخطوة ليست في المهمة المفتوحة في هذه النافذة. أعد التحميل لرؤية الحالية.")
738
+
739
+
740
+ def log_dropped_line(*, arabic: bool, count: int) -> str:
741
+ """The Activity list is capped so a long build cannot ride every later snapshot. The cap has to
742
+ be visible: a trimmed log reads as a task that did less than it did."""
743
+ return say(arabic,
744
+ en=f"\u2026 {count} earlier line(s) are not kept in this window; the task's own "
745
+ "record still holds them.",
746
+ ar=f"\u2026 {count} سطر أقدم غير محفوظ في هذه النافذة؛ سجل المهمة نفسه ما زال يحتفظ بها.")
747
+
748
+
749
+ def queue_notes(arabic: bool, elsewhere: int, asked: bool) -> dict:
750
+ """The sentences on the queue strip.
751
+
752
+ They live here because the strip is drawn by two controllers — the real one and `--fake` — and
753
+ because only the server knows what language the queued task was asked in. The client supplies
754
+ the glyphs and the buttons, never the prose.
755
+
756
+ `asked` is the difference between a queue that is waiting and a queue that is *stopped*: while a
757
+ question is on screen nothing drains, and a strip saying "when the current task ends" beside a
758
+ dialog that can sit there for half an hour describes a moment that is not coming.
759
+ """
760
+ return {"when": say(
761
+ arabic,
762
+ en="runs when you answer the question on the screen" if asked
763
+ else "runs when the current task ends",
764
+ ar="تُنفَّذ بعد أن تجيب على السؤال المعروض" if asked
765
+ else "تُنفَّذ عند انتهاء المهمة الجارية"),
766
+ "when_detached": say(arabic, en="next, in its own chat",
767
+ ar="التالية، في محادثة مستقلة"),
768
+ "held_note": say(arabic, en="These wait until you press Resume.",
769
+ ar="هذه تنتظر حتى تضغط استئناف."),
770
+ # A queued message that survived a restart is the one row the user did not type in this
771
+ # session, and a change request can be pointed at files that moved on since it was
772
+ # written. It is restored held; the sentence has to say why it is not running.
773
+ "when_restored": say(arabic,
774
+ en="typed before this window restarted — press ▶ to run it",
775
+ ar="كُتبت قبل إعادة تشغيل هذه النافذة — اضغط ▶ لتنفيذها"),
776
+ "elsewhere_note": say(
777
+ arabic,
778
+ en=f"{elsewhere} waiting in another chat — open it to see them",
779
+ ar=f"{elsewhere} في محادثة أخرى — افتحها لترى ما ينتظر")}
780
+
781
+
782
+ def key_needed_line(*, arabic: bool, env_name: str = "") -> str:
783
+ """Which variable has to hold the key. Naming it is the difference between a fix and a search."""
784
+ if env_name:
785
+ return say(arabic, en=f"Enter your {env_name} first — in the key field, or in that "
786
+ "environment variable. It is never saved to a file.",
787
+ ar=f"اكتب {env_name} أولاً — في خانة المفتاح أو في متغير البيئة ده. "
788
+ "المفتاح ما بيتحفظش في ملف خالص.")
789
+ return say(arabic, en="This provider needs an API key first.", ar="البروفايدر ده محتاج مفتاح API الأول.")
790
+
791
+
792
+ def catalog_status_line(*, arabic: bool, count: int, model: str, label: str,
793
+ live: bool = True) -> str:
794
+ """What a refresh found, and *where* it came from.
795
+
796
+ A built-in fallback list and a live catalog look identical in a dropdown but mean different
797
+ things: one is what the service says it has today, the other is a name this tool shipped with.
798
+ Saying which is the difference between a stale id being a surprise and being a known risk.
799
+ """
800
+ if not count:
801
+ return say(arabic,
802
+ en=f"No models found for {label}. Check the service or the endpoint, "
803
+ "or choose another provider.",
804
+ ar=f"مفيش موديلات اتلقات لـ {label}. اتأكد من الخدمة أو من العنوان، "
805
+ f"أو اختار بروفايدر تاني.")
806
+ head = say(arabic, en=f"{count} models loaded from {label}. ",
807
+ ar=f"{count} موديل من {label}. ")
808
+ using = (say(arabic, en=f"Using {model}.", ar=f"هيستخدم {model}.") if model else
809
+ say(arabic, en="Choose one from the list.", ar="اختار واحد من القائمة."))
810
+ if live:
811
+ return head + using
812
+ return head + using + " " + say(
813
+ arabic,
814
+ en="These are names this tool ships with — the live list could not be reached, so one may "
815
+ "no longer exist on that service.",
816
+ ar="دي أسماء موجودة في Tool نفسها — قائمة السيرفر الحي ما وصلتش، فممكن واحد منها ما بقاش موجود.")
817
+
818
+
819
+ def friendly_error(exc: Exception) -> str:
820
+ # Redacted once, here, because almost every branch returns a slice of ``value``: the fallthrough
821
+ # hands back the exception text verbatim, and ``engine`` interpolates the model's own refusal
822
+ # reason into an AgentError. Whatever a provider, a command or a model put in that text is then
823
+ # this window's status line, chat row and Activity log.
824
+ value = redact(str(exc))
825
+ if "request timeout" in value:
826
+ return ("The model needed more than the request timeout. Raise it in Project & model settings, "
827
+ "or ask for a smaller change.")
828
+ if "connection failed" in value or "timed out" in value:
829
+ return "Cannot reach the model. Start Ollama, check the selected model, and try again."
830
+ if "changed since" in value or "overwrite a later edit" in value:
831
+ return "Files changed after review. Create a new proposal to preserve your edits."
832
+ if "repeated the same action" in value:
833
+ # The runtime stops a model that answers identically three times. In practice that
834
+ # is almost never a crash: it is a model with nothing left to do, because the
835
+ # change it was asked for already exists or the request is too vague to act on.
836
+ return ("The model kept giving the same answer without making progress. Usually the requested "
837
+ "change is already in the files, or the task is too vague to act on — open the file to "
838
+ "check, or describe one concrete edit.")
839
+ if "unchanged file" in value:
840
+ # A message with no edit in it reaches this in Change mode: the model reads a file,
841
+ # proposes it as it stands, and is refused. The fix is the badge, not another model.
842
+ return ("That message did not ask for a file change, so there was nothing to propose. "
843
+ "Switch the badge next to Send to Chat mode to ask freely, or name one concrete "
844
+ "edit to stay in Change mode.")
845
+ if "could not produce a proposal" in value:
846
+ reason = value.split("could not produce a proposal:", 1)[-1].strip()
847
+ if reason:
848
+ return (f"\u26d4 The model was unable to produce a proposal: {reason} "
849
+ f"Try using a larger model or breaking the task into smaller steps.")
850
+ return "\u26d4 The model could not complete a valid proposal. Try a larger model or a smaller step."
851
+ if "without progress" in value or "budget" in value:
852
+ return "The model could not complete a valid proposal. Try a smaller task or another model."
853
+ if "output truncated" in value:
854
+ # The reply stopped at the output-token ceiling, so what the user sees is half an answer
855
+ # rather than a wrong one. Both fixes are the user's to make, so name them.
856
+ return ("The model reached its output limit before finishing, so the answer is cut off. "
857
+ "Ask for one file or one step at a time, or raise the output limit in Settings.")
858
+ if " in your environment" in value:
859
+ # Named by the provider that refused, so a Groq key is not reported as an OpenRouter one.
860
+ return "Enter your " + value.split(" in your environment", 1)[0].split("Set ", 1)[-1].strip() + " first."
861
+ if "API_KEY" in value:
862
+ return "Enter your OpenRouter API key first."
863
+ if "HTTP 401" in value or "HTTP 403" in value:
864
+ return "Access denied by the provider. Check your API key and permissions."
865
+ if "HTTP 429" in value:
866
+ return "Rate limit reached. Wait a moment or select a local model."
867
+ if isinstance(exc, Cancelled):
868
+ return "Task cancelled. No project files were changed."
869
+ if isinstance(exc, (AgentError, OSError)):
870
+ return value[:600]
871
+ return "Could not complete the operation. Reopen the task and try again."