ata-coder 2.4.2__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 (118) hide show
  1. ata_coder/__init__.py +1 -0
  2. ata_coder/agent.py +874 -0
  3. ata_coder/agent_compact.py +190 -0
  4. ata_coder/agent_controller.py +218 -0
  5. ata_coder/agent_extension.py +69 -0
  6. ata_coder/agent_routing.py +105 -0
  7. ata_coder/agent_subsystems.py +72 -0
  8. ata_coder/agent_tools.py +318 -0
  9. ata_coder/agent_undo.py +63 -0
  10. ata_coder/anthropic_client.py +465 -0
  11. ata_coder/change_tracker.py +368 -0
  12. ata_coder/clawd_integration.py +574 -0
  13. ata_coder/commands/__init__.py +128 -0
  14. ata_coder/commands/_core.py +184 -0
  15. ata_coder/commands/_safety.py +95 -0
  16. ata_coder/commands/_settings.py +241 -0
  17. ata_coder/commands/_workflow.py +451 -0
  18. ata_coder/commands.py +974 -0
  19. ata_coder/config.py +257 -0
  20. ata_coder/core/__init__.py +35 -0
  21. ata_coder/core/events.py +73 -0
  22. ata_coder/core/queue.py +85 -0
  23. ata_coder/core/state.py +17 -0
  24. ata_coder/event_queue.py +5 -0
  25. ata_coder/extension.py +654 -0
  26. ata_coder/extensions/__init__.py +1 -0
  27. ata_coder/extensions/hello_skill.py +47 -0
  28. ata_coder/fool_proof.py +295 -0
  29. ata_coder/git_workflow.py +371 -0
  30. ata_coder/gui.py +511 -0
  31. ata_coder/llm_client.py +543 -0
  32. ata_coder/main.py +814 -0
  33. ata_coder/mcp_client.py +1095 -0
  34. ata_coder/memory.py +539 -0
  35. ata_coder/model_registry.py +134 -0
  36. ata_coder/model_router.py +105 -0
  37. ata_coder/permissions.py +274 -0
  38. ata_coder/privilege.py +464 -0
  39. ata_coder/project.py +273 -0
  40. ata_coder/prompt_template.py +423 -0
  41. ata_coder/prompts/auto-mode.md +7 -0
  42. ata_coder/prompts/coding-rules.md +40 -0
  43. ata_coder/prompts/execution-guardrails.md +14 -0
  44. ata_coder/prompts/memory-system.md +24 -0
  45. ata_coder/prompts/output-style.md +23 -0
  46. ata_coder/prompts/safety.md +17 -0
  47. ata_coder/prompts/slash-commands.md +24 -0
  48. ata_coder/prompts/sub-agents.md +38 -0
  49. ata_coder/prompts/system-reminders.md +17 -0
  50. ata_coder/prompts/system.md +105 -0
  51. ata_coder/prompts/tool-policy.md +46 -0
  52. ata_coder/repl_theme.py +99 -0
  53. ata_coder/repl_tracker.py +89 -0
  54. ata_coder/repl_ui.py +1214 -0
  55. ata_coder/safety_guard.py +434 -0
  56. ata_coder/self_correct.py +346 -0
  57. ata_coder/server.py +882 -0
  58. ata_coder/server_session.py +159 -0
  59. ata_coder/server_shell.py +129 -0
  60. ata_coder/session.py +431 -0
  61. ata_coder/settings.py +439 -0
  62. ata_coder/setup_wizard.py +136 -0
  63. ata_coder/skill_extension.py +92 -0
  64. ata_coder/skills/architect/SKILL.md +42 -0
  65. ata_coder/skills/code-reviewer/SKILL.md +37 -0
  66. ata_coder/skills/codecraft/SKILL.md +452 -0
  67. ata_coder/skills/debugger/SKILL.md +45 -0
  68. ata_coder/skills/doc-writer/SKILL.md +36 -0
  69. ata_coder/skills/general-coder/SKILL.md +76 -0
  70. ata_coder/skills/math-calculator/README.md +40 -0
  71. ata_coder/skills/math-calculator/SKILL.md +59 -0
  72. ata_coder/skills/math-calculator/handler.py +103 -0
  73. ata_coder/skills/math-calculator/prompts/system.md +8 -0
  74. ata_coder/skills/math-calculator/requirements.txt +2 -0
  75. ata_coder/skills/math-calculator/resources/constants.json +8 -0
  76. ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
  77. ata_coder/skills/security-auditor/SKILL.md +40 -0
  78. ata_coder/skills/test-writer/SKILL.md +36 -0
  79. ata_coder/skills/weather-skill/README.md +45 -0
  80. ata_coder/skills/weather-skill/handler.py +76 -0
  81. ata_coder/skills/weather-skill/manifest.json +48 -0
  82. ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
  83. ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
  84. ata_coder/skills/weather-skill/requirements.txt +1 -0
  85. ata_coder/skills/weather-skill/resources/city_list.json +17 -0
  86. ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
  87. ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
  88. ata_coder/skills/weather-skill/weather_utils.py +50 -0
  89. ata_coder/skills.py +1014 -0
  90. ata_coder/sub_agent.py +273 -0
  91. ata_coder/sub_agent_manager.py +203 -0
  92. ata_coder/system_prompt_builder.py +146 -0
  93. ata_coder/task_planner.py +391 -0
  94. ata_coder/terminal.py +318 -0
  95. ata_coder/test_runner.py +219 -0
  96. ata_coder/thread_supervisor.py +195 -0
  97. ata_coder/tool_defs.py +335 -0
  98. ata_coder/tools/__init__.py +11 -0
  99. ata_coder/tools/definitions.py +335 -0
  100. ata_coder/tools/executor.py +1036 -0
  101. ata_coder/tools/result.py +26 -0
  102. ata_coder/tools/subagent.py +332 -0
  103. ata_coder/tools/web.py +361 -0
  104. ata_coder/tools.py +1576 -0
  105. ata_coder/types.py +92 -0
  106. ata_coder/utils.py +113 -0
  107. ata_coder/web/css/style.css +180 -0
  108. ata_coder/web/index.html +84 -0
  109. ata_coder/web/js/app.js +489 -0
  110. ata_coder/web/package-lock.json +25 -0
  111. ata_coder/web/package.json +10 -0
  112. ata_coder/web/tsconfig.json +13 -0
  113. ata_coder-2.4.2.dist-info/METADATA +799 -0
  114. ata_coder-2.4.2.dist-info/RECORD +118 -0
  115. ata_coder-2.4.2.dist-info/WHEEL +5 -0
  116. ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
  117. ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
  118. ata_coder-2.4.2.dist-info/top_level.txt +1 -0
ata_coder/repl_ui.py ADDED
@@ -0,0 +1,1214 @@
1
+ """
2
+ Claude Code-level terminal UI with full color scheme, inline diffs,
3
+ syntax highlighting, token tracking, and interactive prompts.
4
+
5
+ Color scheme:
6
+ green = success, additions, OK
7
+ red = errors, deletions, FAIL
8
+ yellow = warnings, thinking, skill changes
9
+ cyan = tool names, user prompt
10
+ blue = file paths
11
+ magenta = model info, MCP
12
+ dim = metadata, secondary info
13
+ """
14
+
15
+ import difflib
16
+ import logging
17
+ import sys
18
+ import time
19
+ from typing import Any, Callable
20
+
21
+ try:
22
+ import readline
23
+ HAS_READLINE = True
24
+ except ImportError:
25
+ HAS_READLINE = False
26
+
27
+ try:
28
+ from prompt_toolkit import PromptSession
29
+ from prompt_toolkit.key_binding import KeyBindings
30
+ from prompt_toolkit.styles import Style as PTStyle
31
+ from prompt_toolkit.history import InMemoryHistory
32
+ HAS_PROMPT_TOOLKIT = True
33
+ except ImportError:
34
+ HAS_PROMPT_TOOLKIT = False
35
+
36
+
37
+ class _DedupeHistory(InMemoryHistory):
38
+ """Input history that skips consecutive duplicate entries.
39
+
40
+ Wraps prompt_toolkit's InMemoryHistory. Consecutive identical
41
+ inputs are stored only once — pressing ↑ multiple times jumps
42
+ straight to the *different* previous inputs instead of showing
43
+ the same one over and over.
44
+ """
45
+
46
+ def __init__(self):
47
+ super().__init__()
48
+ self._last_text: str | None = None
49
+
50
+ def append_string(self, string: str) -> None:
51
+ """Store only if different from the immediately preceding entry."""
52
+ s = string.strip()
53
+ if s and s != self._last_text:
54
+ self._last_text = s
55
+ super().append_string(s)
56
+
57
+
58
+ from .repl_theme import Colors, render_diff_rich
59
+
60
+ try:
61
+ from rich.console import Console
62
+ from rich.markdown import Markdown
63
+ from rich.markup import escape as rich_escape
64
+ from rich.panel import Panel
65
+ from rich.syntax import Syntax
66
+ from rich.text import Text
67
+ from rich.table import Table
68
+ from rich.layout import Layout
69
+ from rich.live import Live
70
+ from rich.spinner import Spinner
71
+ from rich.progress import Progress, BarColumn, TextColumn, SpinnerColumn, TaskProgressColumn
72
+ from rich import box
73
+ from rich.prompt import Prompt, Confirm
74
+ from rich.columns import Columns
75
+ from rich.rule import Rule
76
+ from rich.style import Style
77
+ from rich.theme import Theme
78
+ HAS_RICH = True
79
+ except ImportError:
80
+ HAS_RICH = False
81
+ def rich_escape(text: str) -> str:
82
+ return text.replace("[", "\\[").replace("]", "\\]")
83
+
84
+
85
+ # ── One Dark Pro theme ────────────────────────────────────────────────────────
86
+
87
+ ONE_DARK_THEME = Theme({
88
+ # Base
89
+ "background": "#282C34",
90
+ "foreground": "#ABB2BF",
91
+ "black": "#3F4451",
92
+ "white": "#D7DAE0",
93
+ # Accent
94
+ "red": "#E06C75",
95
+ "green": "#98C379",
96
+ "yellow": "#D19A66",
97
+ "blue": "#61AFEF",
98
+ "cyan": "#56B6C2",
99
+ "purple": "#C678DD",
100
+ # Bright variants
101
+ "brightBlack": "#4F5666",
102
+ "brightRed": "#BE5046",
103
+ "brightGreen": "#A5E075",
104
+ "brightYellow": "#E5C07B",
105
+ "brightBlue": "#4DC4FF",
106
+ "brightCyan": "#4CD1E0",
107
+ "brightPurple": "#DE73FF",
108
+ "brightWhite": "#E6E6E6",
109
+ # Semantic aliases
110
+ "border": "#3F4451",
111
+ "comment": "#5C6370",
112
+ "dim": "#5C6370",
113
+ "prompt": "#61AFEF",
114
+ "success": "#98C379",
115
+ "warning": "#D19A66",
116
+ "error": "#E06C75",
117
+ "info": "#56B6C2",
118
+ "cursor": "#528BFF",
119
+ "selection": "#ABB2BF",
120
+ }) if HAS_RICH else None
121
+
122
+ ONE_DARK_SYNTAX = {
123
+ "background": "#282C34",
124
+ "default": "#ABB2BF", # default text
125
+ "keyword": "#C678DD", # def, class, if, for, return, import (purple)
126
+ "keyword.namespace":"#C678DD",
127
+ "string": "#98C379", # "hello" (green)
128
+ "number": "#D19A66", # 42, 3.14 (orange)
129
+ "name.function": "#61AFEF", # my_func() (blue)
130
+ "name.class": "#E5C07B", # ClassName (yellow)
131
+ "name.tag": "#E06C75", # <div> (red)
132
+ "name.attribute": "#E06C75", # obj.attr (red — like variables)
133
+ "name": "#E06C75", # variables (red)
134
+ "name.builtin": "#56B6C2", # print, len (cyan)
135
+ "name.constant": "#D19A66", # None, True, False (orange bold)
136
+ "name.decorator": "#E5C07B", # @decorator (yellow)
137
+ "operator": "#56B6C2", # + - * / (cyan)
138
+ "operator.word": "#C678DD", # and, or, not, in, is (purple)
139
+ "comment": "#5C6370", # # comment (gray italic)
140
+ "comment.line": "#5C6370",
141
+ "punctuation": "#ABB2BF", # ( ) [ ] { } , ; .
142
+ } if HAS_RICH else {}
143
+
144
+ try:
145
+ from colorama import init, Fore, Back, Style as ColoramaStyle
146
+ init()
147
+ HAS_COLORAMA = True
148
+ except ImportError:
149
+ HAS_COLORAMA = False
150
+
151
+ logger = logging.getLogger(__name__)
152
+
153
+ from .core import AgentEvent, TextDeltaEvent, ToolCallEvent, ToolResultEvent, ToolStreamEvent
154
+ from .core import ThinkingEvent, ErrorEvent, CompleteEvent, SkillChangedEvent, ReasoningEvent
155
+
156
+
157
+ # ═══════════════════════════════════════════════════════════════════════════════
158
+ # Color & Icon constants
159
+ # ═══════════════════════════════════════════════════════════════════════════════
160
+
161
+ # Tool → icon mapping (Unicode-safe fallbacks)
162
+ TOOL_ICONS = {
163
+ "read_file": "[cyan]📄[/cyan]" if HAS_RICH else "[read]",
164
+ "write_file": "[yellow]📝[/yellow]" if HAS_RICH else "[write]",
165
+ "edit_file": "[yellow]✏️[/yellow]" if HAS_RICH else "[edit]",
166
+ "run_shell": "[magenta]⚡[/magenta]" if HAS_RICH else "[exec]",
167
+ "grep": "[blue]🔍[/blue]" if HAS_RICH else "[grep]",
168
+ "glob": "[blue]🌐[/blue]" if HAS_RICH else "[glob]",
169
+ "list_dir": "[blue]📂[/blue]" if HAS_RICH else "[ls]",
170
+ }
171
+
172
+ CATEGORY_COLORS = {
173
+ "read": "blue",
174
+ "write": "yellow",
175
+ "shell": "magenta",
176
+ "mcp": "green",
177
+ }
178
+
179
+ CATEGORY_LABELS = {
180
+ "read": "READ",
181
+ "write": "WRITE",
182
+ "shell": "EXEC",
183
+ "mcp": "MCP",
184
+ }
185
+
186
+ # Severity colors
187
+ SEVERITY_STYLES = {
188
+ "critical": "bold white on red",
189
+ "high": "bold red",
190
+ "medium": "yellow",
191
+ "low": "dim",
192
+ }
193
+
194
+
195
+ # ═══════════════════════════════════════════════════════════════════════════════
196
+ # Fallback color constants (used by render_diff_simple, show_welcome, etc.)
197
+ # ═══════════════════════════════════════════════════════════════════════════════
198
+
199
+ # Replaced by repl_theme.py import
200
+
201
+ from .repl_tracker import LimitTracker
202
+
203
+ class ClaudeCodeUI:
204
+ """Full Claude Code-level terminal UI."""
205
+
206
+ # Language aliases for syntax detection
207
+ LANG_MAP = {
208
+ "py": "python", "python": "python", "python3": "python",
209
+ "js": "javascript", "javascript": "javascript",
210
+ "ts": "typescript", "typescript": "typescript",
211
+ "jsx": "javascript", "tsx": "typescript",
212
+ "c": "c", "cpp": "cpp", "c++": "cpp", "cxx": "cpp", "h": "cpp", "hpp": "cpp",
213
+ "java": "java", "go": "go", "golang": "go",
214
+ "rs": "rust", "rust": "rust",
215
+ "rb": "ruby", "ruby": "ruby",
216
+ "php": "php", "swift": "swift", "kt": "kotlin", "kotlin": "kotlin",
217
+ "scala": "scala", "clj": "clojure", "clojure": "clojure",
218
+ "hs": "haskell", "haskell": "haskell",
219
+ "html": "html", "css": "css", "scss": "scss", "sass": "sass",
220
+ "sql": "sql", "mysql": "sql", "psql": "sql", "postgresql": "sql",
221
+ "sh": "bash", "bash": "bash", "zsh": "bash", "shell": "bash",
222
+ "yaml": "yaml", "yml": "yaml",
223
+ "json": "json", "xml": "xml", "toml": "toml",
224
+ "dockerfile": "dockerfile", "docker": "dockerfile",
225
+ "makefile": "makefile", "make": "makefile",
226
+ "diff": "diff", "patch": "diff",
227
+ "md": "markdown", "markdown": "markdown",
228
+ "ini": "ini", "cfg": "ini", "conf": "ini",
229
+ "lua": "lua", "r": "r", "dart": "dart",
230
+ "zig": "zig", "elm": "elm", "erlang": "erlang", "ex": "elixir", "elixir": "elixir",
231
+ "tf": "terraform", "hcl": "terraform", "terraform": "terraform",
232
+ "vim": "vim", "nginx": "nginx",
233
+ }
234
+
235
+ def __init__(self, workspace: str = ""):
236
+ self.console = Console(theme=ONE_DARK_THEME, color_system="truecolor") if HAS_RICH else None
237
+ self.workspace = workspace
238
+ self._streaming = False
239
+ self._first_text = True
240
+ self._was_reasoning = False
241
+ self._permission_callback: Callable | None = None
242
+ self._last_edit_file: str = "" # track last edited file for diff
243
+ self._last_edit_old: str = "" # old content before edit
244
+ self._tool_outputs: dict[str, str] = {}
245
+ self._tracker = LimitTracker()
246
+
247
+ # Code block state machine for syntax highlighting
248
+ self._in_code_block = False
249
+ self._code_lang: str = ""
250
+ self._code_buffer: str = ""
251
+ self._text_buffer: str = "" # text before code block
252
+
253
+ # Bold state machine — handles ** that may be split across chunks
254
+ self._in_bold = False
255
+ self._bold_buffer = ""
256
+
257
+ # Heading state machine — handles ### / ## / # at line start
258
+ self._at_line_start = True
259
+ self._heading_hashes = ""
260
+ self._in_heading = False
261
+
262
+ # Command completion state
263
+ self._cmd_names: list[str] = []
264
+
265
+ # prompt_toolkit session for multi-line input + history
266
+ self._pt_session = None
267
+ self._input_history = None
268
+ if HAS_PROMPT_TOOLKIT:
269
+ # Only add bindings for newline insertion — Enter/submit and
270
+ # up/down history navigation use prompt_toolkit defaults with
271
+ # multiline=False.
272
+ kb = KeyBindings()
273
+
274
+ @kb.add("c-j")
275
+ def _on_newline(event):
276
+ """Ctrl+Enter or Ctrl+J inserts a newline."""
277
+ event.app.current_buffer.insert_text("\n")
278
+
279
+ @kb.add("escape", "enter")
280
+ def _on_alt_enter(event):
281
+ """Alt+Enter inserts a newline."""
282
+ event.app.current_buffer.insert_text("\n")
283
+
284
+ # History with consecutive deduplication
285
+ self._input_history = _DedupeHistory()
286
+
287
+ try:
288
+ self._pt_session = PromptSession(
289
+ key_bindings=kb,
290
+ history=self._input_history,
291
+ style=PTStyle.from_dict({
292
+ "prompt": "#61AFEF bold",
293
+ "prompt-danger": "#E06C75 bold",
294
+ }),
295
+ )
296
+ except Exception:
297
+ logger.warning(
298
+ "prompt_toolkit unavailable, falling back to single-line"
299
+ )
300
+ self._pt_session = None
301
+
302
+ # ── Readline command completion ──────────────────────────────────────
303
+
304
+ def setup_command_completion(self, commands: list[tuple[str, str]]):
305
+ """Enable Tab completion for slash commands via readline.
306
+
307
+ Args:
308
+ commands: List of (name, description) tuples from commands.py.
309
+ """
310
+ self._cmd_names = sorted(set(name for name, _ in commands))
311
+
312
+ if not HAS_READLINE:
313
+ return
314
+
315
+ def completer(text: str, state: int) -> str | None:
316
+ """readline completer: match /commands on Tab."""
317
+ if not text.startswith("/"):
318
+ return None
319
+ matches = [c for c in self._cmd_names if c.startswith(text)]
320
+ if state < len(matches):
321
+ return matches[state] + " "
322
+ return None
323
+
324
+ readline.set_completer(completer)
325
+ readline.parse_and_bind("tab: complete")
326
+ # Show all matches on first Tab (not double-Tab)
327
+ try:
328
+ readline.parse_and_bind("set show-all-if-ambiguous on")
329
+ except Exception:
330
+ pass
331
+
332
+ @staticmethod
333
+ def _e(text: str) -> str:
334
+ """Escape Rich markup in external content to prevent MarkupError."""
335
+ return rich_escape(text)
336
+
337
+ def _write_text_with_bold(self, text: str):
338
+ """Character-by-character bold state machine.
339
+
340
+ Detects ** pairs (even when split across chunks via _bold_buffer)
341
+ and markdown headings (### at line start) — both emit ANSI bold.
342
+ Code blocks are handled separately — this method is only called
343
+ for non-code text.
344
+ """
345
+ i = 0
346
+ while i < len(text):
347
+ ch = text[i]
348
+
349
+ # ── Heading detection (### / ## / # at line start) ──────
350
+ if self._at_line_start and not self._in_heading:
351
+ if ch == '#':
352
+ self._heading_hashes += '#'
353
+ i += 1
354
+ continue
355
+ if ch == ' ' and self._heading_hashes:
356
+ # Heading confirmed — strip # prefix + space, emit bold
357
+ self._heading_hashes = ""
358
+ self._in_heading = True
359
+ sys.stdout.write(Colors.BOLD)
360
+ self._at_line_start = False
361
+ i += 1
362
+ continue
363
+ if self._heading_hashes:
364
+ # Buffered # not followed by space — flush as plain text
365
+ sys.stdout.write(self._heading_hashes)
366
+ self._heading_hashes = ""
367
+ # fall through to process current ch
368
+
369
+ # ── Flush buffered * from a previous chunk ──────────────
370
+ if self._bold_buffer:
371
+ if ch == '*':
372
+ # ** completed across chunk boundary
373
+ self._bold_buffer = ""
374
+ if self._in_bold:
375
+ sys.stdout.write(Colors.RESET)
376
+ self._in_bold = False
377
+ if self._in_heading:
378
+ sys.stdout.write(Colors.BOLD)
379
+ else:
380
+ sys.stdout.write(Colors.BOLD)
381
+ self._in_bold = True
382
+ i += 1
383
+ continue
384
+ else:
385
+ # Lone * — not part of a ** pair
386
+ sys.stdout.write('*')
387
+ self._bold_buffer = ""
388
+ # fall through to handle current ch
389
+
390
+ # ── Normal character processing ────────────────────────
391
+ if ch == '\n':
392
+ if self._in_heading:
393
+ if self._in_bold:
394
+ sys.stdout.write(Colors.RESET)
395
+ self._in_bold = False
396
+ sys.stdout.write(Colors.RESET)
397
+ self._in_heading = False
398
+ self._at_line_start = True
399
+ self._heading_hashes = ""
400
+ sys.stdout.write(ch)
401
+ i += 1
402
+ continue
403
+
404
+ self._at_line_start = False
405
+
406
+ if ch == '*':
407
+ if i + 1 < len(text) and text[i + 1] == '*':
408
+ # Complete ** pair within this chunk
409
+ if self._in_bold:
410
+ sys.stdout.write(Colors.RESET)
411
+ self._in_bold = False
412
+ if self._in_heading:
413
+ sys.stdout.write(Colors.BOLD)
414
+ else:
415
+ sys.stdout.write(Colors.BOLD)
416
+ self._in_bold = True
417
+ i += 2
418
+ continue
419
+ else:
420
+ # Single * — buffer it, could continue in next chunk
421
+ self._bold_buffer = '*'
422
+ i += 1
423
+ continue
424
+ else:
425
+ sys.stdout.write(ch)
426
+ i += 1
427
+
428
+ def _detect_lang(self, fence_info: str) -> str:
429
+ """Detect language from code fence info string."""
430
+ lang = fence_info.strip().lower().split()[0] if fence_info.strip() else ""
431
+ return self.LANG_MAP.get(lang, lang if lang else "text")
432
+
433
+ # ── Welcome ──────────────────────────────────────────────────────────
434
+
435
+ def show_welcome(self, model: str, workspace: str, skill: str = "",
436
+ project_info=None, mcp_servers: list[str] | None = None):
437
+ self.workspace = workspace
438
+ self._tracker = LimitTracker(model=model)
439
+
440
+ if not HAS_RICH:
441
+ self._simple_welcome(model, workspace, skill, project_info, mcp_servers)
442
+ return
443
+
444
+ self.console.print()
445
+
446
+ # Big ASCII art title — ATA CODER
447
+ A = "#61AFEF"
448
+ title = [
449
+ f"[bold][{A}] █████╗ ████████╗ █████╗ ██████╗ ██████╗ ██████╗ ███████╗ ██████╗ [/{A}][/bold]",
450
+ f"[bold][{A}] ██╔══██╗ ╚══██╔══╝ ██╔══██╗ ██╔════╝ ██╔═══██╗ ██╔══██╗ ██╔════╝ ██╔══██╗ [/{A}][/bold]",
451
+ f"[bold][{A}] ███████║ ██║ ███████║ ██║ ██║ ██║ ██║ ██║ █████╗ ██████╔╝ [/{A}][/bold]",
452
+ f"[bold][{A}] ██╔══██║ ██║ ██╔══██║ ██║ ██║ ██║ ██║ ██║ ██╔══╝ ██╔══██╗ [/{A}][/bold]",
453
+ f"[bold][{A}] ██║ ██║ ██║ ██║ ██║ ╚██████╗ ╚██████╔╝ ██████╔╝ ███████╗ ██║ ██║ [/{A}][/bold]",
454
+ f"[bold][{A}] ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ [/{A}][/bold]",
455
+ ]
456
+
457
+ # Fetch model routing info
458
+ try:
459
+ from .model_router import get_model_info
460
+ mi = get_model_info()
461
+ model_info = f"[dim]opus={mi['opus']} sonnet={mi['sonnet']} haiku={mi['haiku']}[/dim]"
462
+ except Exception:
463
+ model_info = ""
464
+
465
+ # Main welcome panel
466
+ info_lines = [
467
+ "",
468
+ f"[dim]Model:[/dim] [green]{model}[/green] {model_info}",
469
+ f"[dim]Workspace:[/dim] [blue]{workspace}[/blue]",
470
+ ]
471
+ if project_info:
472
+ if project_info.languages:
473
+ info_lines.append(f"[dim]Languages:[/dim] [blue]{', '.join(project_info.languages)}[/blue]")
474
+ if project_info.is_git_repo:
475
+ info_lines.append(f"[dim]Git:[/dim] [dim]branch={project_info.git_branch}[/dim]")
476
+
477
+ if mcp_servers:
478
+ info_lines.append(f"[dim]MCP:[/dim] [green]{', '.join(mcp_servers)}[/green]")
479
+
480
+ # Add privilege info
481
+ from .privilege import detect_privilege, detect_os, PrivilegeLevel
482
+ priv = detect_privilege()
483
+ os_name = detect_os().value
484
+ if priv == PrivilegeLevel.ROOT:
485
+ info_lines.append(f"[dim]Privilege:[/dim] [red bold]ROOT ({os_name})[/red bold] [dim]— full system access[/dim]")
486
+ elif priv == PrivilegeLevel.ADMIN:
487
+ info_lines.append(f"[dim]Privilege:[/dim] [yellow]admin ({os_name})[/yellow] [dim]— /dangerous on to elevate[/dim]")
488
+ else:
489
+ info_lines.append(f"[dim]Privilege:[/dim] [dim]user ({os_name})[/dim]")
490
+
491
+ info_lines.append("")
492
+ info_lines.append("[dim]Type your task or / for commands (Tab to complete). Ctrl+C to interrupt.[/dim]")
493
+
494
+ self.console.print(Panel("\n".join(title + info_lines), border_style="#3F4451", padding=(1, 2)))
495
+ self.console.print()
496
+
497
+ def _simple_welcome(self, model, workspace, skill, project_info, mcp_servers):
498
+ print(f"\n{Colors.BOLD}{Colors.CYAN}[ATA Coder]{Colors.RESET}")
499
+ print(f" {Colors.DIM}Model:{Colors.RESET} {Colors.GREEN}{model}{Colors.RESET}")
500
+ print(f" {Colors.DIM}Workspace:{Colors.RESET} {Colors.BLUE}{workspace}{Colors.RESET}")
501
+ if project_info and project_info.languages:
502
+ print(f" {Colors.DIM}Project:{Colors.RESET} {', '.join(project_info.languages)}")
503
+ print(f" {Colors.DIM}Type / for commands (Tab to complete){Colors.RESET}")
504
+ print()
505
+
506
+ def reset_stream(self):
507
+ """Clear all streaming state. Call on interrupt/disconnect."""
508
+ self._streaming = False
509
+ self._first_text = True
510
+ self._was_reasoning = False
511
+ self._in_code_block = False
512
+ self._code_buffer = ""
513
+ self._code_lang = ""
514
+ self._text_buffer = ""
515
+ self._in_bold = False
516
+ self._bold_buffer = ""
517
+ self._at_line_start = True
518
+ self._heading_hashes = ""
519
+ self._in_heading = False
520
+
521
+ # ── Event dispatcher ─────────────────────────────────────────────────
522
+
523
+ def on_event(self, event: AgentEvent):
524
+ if isinstance(event, ThinkingEvent):
525
+ pass
526
+ elif isinstance(event, ReasoningEvent):
527
+ self._on_reasoning(event)
528
+ elif isinstance(event, TextDeltaEvent):
529
+ self._on_text(event.text)
530
+ elif isinstance(event, ToolStreamEvent):
531
+ self._on_tool_stream(event)
532
+ elif isinstance(event, SkillChangedEvent):
533
+ self._on_skill_change(event)
534
+ elif isinstance(event, ToolCallEvent):
535
+ self._on_tool_call(event)
536
+ elif isinstance(event, ToolResultEvent):
537
+ self._on_tool_result(event)
538
+ elif isinstance(event, ErrorEvent):
539
+ self._on_error(event)
540
+ elif isinstance(event, CompleteEvent):
541
+ self._on_complete(event)
542
+
543
+ # ── Text streaming with code block detection ────────────────────────
544
+
545
+ def _on_text(self, text: str):
546
+ if self._first_text:
547
+ self._first_text = False
548
+ if self._was_reasoning:
549
+ sys.stdout.write("\n\n")
550
+ sys.stdout.flush()
551
+ self._was_reasoning = False
552
+
553
+ # Prepend any buffered partial fence from previous chunk
554
+ if self._text_buffer:
555
+ text = self._text_buffer + text
556
+ self._text_buffer = ""
557
+
558
+ # Feed text through code block state machine
559
+ while text:
560
+ if not self._in_code_block:
561
+ # Looking for opening ```
562
+ idx = text.find("```")
563
+ if idx == -1:
564
+ # No fence found — but check for partial fence at end
565
+ for partial_len in (2, 1):
566
+ if text.endswith("`" * partial_len) and not text.endswith("`" * (partial_len + 1)):
567
+ self._text_buffer = text[-partial_len:]
568
+ self._write_text_with_bold(text[:-partial_len])
569
+ sys.stdout.flush()
570
+ return
571
+ self._write_text_with_bold(text)
572
+ sys.stdout.flush()
573
+ break
574
+ # Output text before the code fence (with bold conversion)
575
+ self._write_text_with_bold(text[:idx])
576
+ sys.stdout.flush()
577
+ rest = text[idx + 3:]
578
+ newline_idx = rest.find("\n")
579
+ if newline_idx == -1:
580
+ # Fence might not be complete yet — buffer it
581
+ self._text_buffer = text[idx:]
582
+ sys.stdout.flush()
583
+ return
584
+ fence_info = rest[:newline_idx]
585
+ self._code_lang = self._detect_lang(fence_info)
586
+ self._code_buffer = ""
587
+ self._in_code_block = True
588
+ text = rest[newline_idx + 1:]
589
+ sys.stdout.write("\n")
590
+ sys.stdout.flush()
591
+ else:
592
+ # Inside code block — looking for closing ```
593
+ # Check both: \n``` (typical) and ``` at start of chunk
594
+ close_idx = text.find("\n```")
595
+ if close_idx == -1 and text.startswith("```"):
596
+ close_idx = -2 # signal: fence at position 0
597
+
598
+ if close_idx == -1:
599
+ # No closing fence — check for partial at end
600
+ if text.endswith("`") or text.endswith("``"):
601
+ cut = 1 if text.endswith("`") and not text.endswith("``") else 2
602
+ self._code_buffer += text[:-cut]
603
+ self._text_buffer = text[-cut:]
604
+ else:
605
+ self._code_buffer += text
606
+ break
607
+ elif close_idx == -2:
608
+ # Closing ``` at very start of chunk
609
+ self._flush_code_block()
610
+ self._in_code_block = False
611
+ self._code_buffer = ""
612
+ rest = text[3:] # skip ```
613
+ if rest.startswith("\n"):
614
+ rest = rest[1:]
615
+ text = rest
616
+ else:
617
+ # Normal case: \n``` found
618
+ self._code_buffer += text[:close_idx]
619
+ self._flush_code_block()
620
+ self._in_code_block = False
621
+ self._code_buffer = ""
622
+ rest = text[close_idx + 4:] # skip \n```
623
+ if rest.startswith("\n"):
624
+ rest = rest[1:]
625
+ text = rest
626
+
627
+ def _flush_code_block(self):
628
+ """Render the accumulated code buffer with syntax highlighting.
629
+
630
+ ASCII diagrams (box-drawing chars) and plain-text blocks are
631
+ printed raw — no dark background, no syntax highlighting.
632
+ """
633
+ if not self._code_buffer.strip():
634
+ return
635
+
636
+ # Detect ASCII diagrams / plain-text blocks
637
+ lang = self._code_lang.lower() if self._code_lang else ""
638
+ is_plain = lang in ("text", "plaintext", "diagram", "tree", "")
639
+ has_box_drawing = any(
640
+ ord(c) >= 0x2500 and ord(c) <= 0x257F # box-drawing range
641
+ for c in self._code_buffer[:200]
642
+ )
643
+
644
+ if is_plain and has_box_drawing:
645
+ # ASCII diagram — print raw, no Syntax background
646
+ self.console.print(self._code_buffer.rstrip())
647
+ elif is_plain:
648
+ # Plain text block — dim, no background
649
+ self.console.print(self._code_buffer.rstrip(), style="dim")
650
+ else:
651
+ try:
652
+ syntax = Syntax(
653
+ self._code_buffer,
654
+ self._code_lang if self._code_lang else "text",
655
+ theme=ONE_DARK_SYNTAX,
656
+ line_numbers=False,
657
+ word_wrap=False,
658
+ background_color="#282C34",
659
+ )
660
+ self.console.print(syntax)
661
+ except Exception:
662
+ self.console.print(self._code_buffer, style="dim")
663
+
664
+ sys.stdout.write("\n")
665
+ sys.stdout.flush()
666
+
667
+ # ── Reasoning / Thinking display ────────────────────────────────────
668
+
669
+ def _on_reasoning(self, event: ReasoningEvent):
670
+ """Display the model's thinking process in dimmed text."""
671
+ self._was_reasoning = True
672
+ if not HAS_RICH:
673
+ sys.stdout.write(Colors.DIM)
674
+ self._write_text_with_bold(event.text)
675
+ sys.stdout.write(Colors.RESET)
676
+ sys.stdout.flush()
677
+ return
678
+ sys.stdout.write(Colors.DIM)
679
+ self._write_text_with_bold(event.text)
680
+ sys.stdout.write(Colors.RESET)
681
+ sys.stdout.flush()
682
+
683
+ # ── Skill change ────────────────────────────────────────────────────
684
+
685
+ def _on_skill_change(self, event: SkillChangedEvent):
686
+ if HAS_RICH:
687
+ self.console.print(f"\n [yellow][skill] Activated: {event.skill_name}[/yellow]")
688
+ else:
689
+ print(f"\n {Colors.YELLOW}[skill] {event.skill_name}{Colors.RESET}")
690
+
691
+ # ── Tool call ───────────────────────────────────────────────────────
692
+
693
+ def _on_tool_call(self, event: ToolCallEvent):
694
+ self._tracker.add_tool_call()
695
+
696
+ icon = TOOL_ICONS.get(event.tool_name, "[dim][tool][/dim]" if HAS_RICH else "[tool]")
697
+ cat = "mcp" if event.source == "mcp" else ""
698
+ if not cat:
699
+ from .permissions import tool_category
700
+ cat = tool_category(event.tool_name)
701
+ cat_label = CATEGORY_LABELS.get(cat, cat.upper())
702
+ cat_color = CATEGORY_COLORS.get(cat, "dim")
703
+
704
+ args_display = self._fmt_args(event)
705
+ # Store start time for run_shell to show duration
706
+ self._last_tool_start = time.time()
707
+ self._last_tool_name = event.tool_name
708
+
709
+ if HAS_RICH:
710
+ self.console.print()
711
+ if event.tool_name == "run_shell":
712
+ cmd = event.arguments.get("command", "")
713
+ self.console.print(
714
+ f" {icon} "
715
+ f"[{cat_color}][{cat_label}][/{cat_color}] "
716
+ f"[bold]{event.tool_name}[/bold]"
717
+ )
718
+ # Full command on its own line — never truncated
719
+ self.console.print(f" [yellow bold]$ {cmd}[/yellow bold] [dim yellow]⚡ running…[/dim yellow]")
720
+ else:
721
+ self.console.print(
722
+ f" {icon} "
723
+ f"[{cat_color}][{cat_label}][/{cat_color}] "
724
+ f"[bold]{event.tool_name}[/bold] "
725
+ f"[dim]{args_display}[/dim]"
726
+ )
727
+ else:
728
+ if event.tool_name == "run_shell":
729
+ cmd = event.arguments.get("command", "")
730
+ print(f"\n {Colors.DIM}[{cat_label}]{Colors.RESET} {event.tool_name}")
731
+ print(f" {Colors.YELLOW}$ {cmd}{Colors.RESET} {Colors.YELLOW}⚡ running…{Colors.RESET}")
732
+ else:
733
+ print(f"\n {Colors.DIM}[{cat_label}]{Colors.RESET} {event.tool_name} {Colors.DIM}{args_display}{Colors.RESET}")
734
+
735
+ # ── Tool output streaming (real-time) ────────────────────────────────
736
+
737
+ def _on_tool_stream(self, event: ToolStreamEvent):
738
+ """Display real-time shell output as it arrives (no buffering)."""
739
+ chunk = event.chunk
740
+ if not chunk:
741
+ return
742
+ # Strip trailing newlines for compact display — each chunk may be
743
+ # partial, so we print as-is without adding extra line breaks.
744
+ text = chunk.rstrip("\r\n")
745
+ if not text:
746
+ return
747
+ if HAS_RICH:
748
+ self.console.print(f" [dim]{self._e(text)}[/dim]", end="")
749
+ else:
750
+ sys.stdout.write(f" {Colors.DIM}{text}{Colors.RESET}")
751
+ sys.stdout.flush()
752
+
753
+ def _fmt_args(self, event: ToolCallEvent) -> str:
754
+ """Format tool arguments for compact single-line display.
755
+
756
+ Commands (run_shell) are displayed in full on their own line by
757
+ the caller — return empty here to avoid redundant truncation.
758
+ """
759
+ args = event.arguments
760
+ # run_shell commands get their own dedicated display line
761
+ if "command" in args:
762
+ return ""
763
+ # Primary argument per tool type
764
+ primary = (
765
+ args.get("file_path") or
766
+ args.get("pattern") or
767
+ args.get("path") or
768
+ args.get("content", "")
769
+ )
770
+ if isinstance(primary, str):
771
+ s = primary.replace("\n", "\\n")[:200]
772
+ if len(primary) > 200:
773
+ s += "..."
774
+ return s
775
+ return ""
776
+
777
+ # ── Tool result ─────────────────────────────────────────────────────
778
+
779
+ def _on_tool_result(self, event: ToolResultEvent):
780
+ if event.result.success:
781
+ self._tool_ok(event)
782
+ else:
783
+ self._tool_fail(event)
784
+
785
+ def _tool_ok(self, event: ToolResultEvent):
786
+ tool_name = event.tool_name
787
+ output = event.result.output
788
+
789
+ if HAS_RICH:
790
+ # read_file / grep — show line count
791
+ if tool_name in ("read_file", "grep", "glob", "list_dir"):
792
+ lines = output.count("\n") + 1
793
+ chars = len(output)
794
+ preview = output[:120].replace("\n", " ")
795
+ if len(output) > 120:
796
+ preview += "..."
797
+ self.console.print(f" [green][OK][/green] [dim]{lines} lines, {chars:,} chars[/dim]")
798
+
799
+ # edit_file — show diff!
800
+ elif tool_name == "edit_file" and self._last_edit_old:
801
+ fp = self._last_edit_file
802
+ # Read new content
803
+ try:
804
+ with open(fp, "r", encoding="utf-8") as f:
805
+ new_content = f.read()
806
+ if self._last_edit_old and new_content:
807
+ render_diff_rich(self.console, self._last_edit_old, new_content, fp)
808
+ except Exception:
809
+ self.console.print(" [green][OK][/green] [dim]File edited[/dim]")
810
+ self._last_edit_old = ""
811
+ self._last_edit_file = ""
812
+
813
+ # write_file — show summary or diff if overwriting
814
+ elif tool_name == "write_file":
815
+ fp = self._last_edit_file
816
+ if self._last_edit_old:
817
+ # Show diff for overwritten files
818
+ try:
819
+ with open(fp, "r", encoding="utf-8") as f:
820
+ new_content = f.read()
821
+ if new_content:
822
+ render_diff_rich(self.console, self._last_edit_old, new_content, fp)
823
+ except Exception:
824
+ pass
825
+ self._last_edit_old = ""
826
+ self._last_edit_file = ""
827
+ else:
828
+ lines = output.count("\n") + 1
829
+ size = len(output)
830
+ self.console.print(f" [green][OK][/green] [dim]Created {fp}: {lines} lines, {size:,} bytes[/dim]")
831
+
832
+ # run_shell — show duration, output summary
833
+ elif tool_name == "run_shell":
834
+ elapsed = time.time() - getattr(self, '_last_tool_start', time.time())
835
+ lines = output.count("\n") + 1 if output else 0
836
+ preview = self._e(output[:200].replace("\n", "\\n"))
837
+ if len(output) > 200: preview += "..."
838
+ dur = f"{elapsed:.1f}s" if elapsed > 1 else f"{elapsed*1000:.0f}ms"
839
+ self.console.print(
840
+ f" [green][OK][/green] [dim]{lines} lines, {dur} → {preview}[/dim]"
841
+ )
842
+
843
+ else:
844
+ preview = self._e(output[:120].replace("\n", " "))
845
+ self.console.print(f" [green][OK][/green] [dim]{preview}[/dim]")
846
+ else:
847
+ preview = output[:120].replace("\n", " ")
848
+ print(f" {Colors.GREEN}[OK]{Colors.RESET} {Colors.DIM}{preview}{Colors.RESET}")
849
+
850
+ def _tool_fail(self, event: ToolResultEvent):
851
+ if HAS_RICH:
852
+ self.console.print(f" [red][FAIL][/red] [red]{self._e(event.result.error)}[/red]")
853
+ else:
854
+ print(f" {Colors.RED}[FAIL] {event.result.error}{Colors.RESET}")
855
+
856
+ # ── Error ────────────────────────────────────────────────────────────
857
+
858
+ def _on_error(self, event: ErrorEvent):
859
+ if HAS_RICH:
860
+ self.console.print(f"\n[red bold]Error:[/red bold] [red]{self._e(event.error)}[/red]")
861
+ else:
862
+ print(f"\n{Colors.RED}Error: {event.error}{Colors.RESET}")
863
+
864
+ # ── Complete ─────────────────────────────────────────────────────────
865
+
866
+ def _on_complete(self, event: CompleteEvent):
867
+ # Update window token estimate from the agent
868
+ if event.estimated_tokens:
869
+ self._tracker.window_tokens = event.estimated_tokens
870
+ # Flush any remaining code block
871
+ if self._in_code_block:
872
+ self._flush_code_block()
873
+ self._streaming = False
874
+ self._first_text = True
875
+ self._was_reasoning = False
876
+ self._in_code_block = False
877
+ self._code_buffer = ""
878
+ self._code_lang = ""
879
+ self._text_buffer = "" # clear partial fence buffer
880
+ self._in_bold = False
881
+ self._bold_buffer = ""
882
+ self._at_line_start = True
883
+ self._heading_hashes = ""
884
+ self._in_heading = False
885
+ if HAS_RICH:
886
+ self.console.print() # newline after streamed text
887
+ self.console.print(
888
+ f"[dim]--- {event.total_tool_calls} tools | "
889
+ f"{self._tracker.window_tokens:,} tokens | "
890
+ f"{event.total_time:.1f}s ---[/dim]"
891
+ )
892
+ sys.stdout.flush()
893
+ else:
894
+ w = self._tracker.window_tokens or self._tracker.total_tokens
895
+ print(f"\n{Colors.DIM}--- {event.total_tool_calls} tools, "
896
+ f"~{w:,} tokens, "
897
+ f"{event.total_time:.1f}s ---{Colors.RESET}", flush=True)
898
+
899
+ # ── Permission prompt ────────────────────────────────────────────────
900
+
901
+ def permission_prompt(self, tool_name: str, arguments: dict[str, Any],
902
+ category: str) -> bool:
903
+ """Interactive permission prompt with clear formatting."""
904
+ if HAS_RICH:
905
+ return self._rich_permission(tool_name, arguments, category)
906
+ else:
907
+ return self._simple_permission(tool_name, arguments, category)
908
+
909
+ def _rich_permission(self, tool_name, arguments, category) -> bool:
910
+ cat_color = CATEGORY_COLORS.get(category, "yellow")
911
+ cat_label = CATEGORY_LABELS.get(category, category.upper())
912
+
913
+ lines = [
914
+ f"[bold {cat_color}][{cat_label}][/bold {cat_color}] [bold]{tool_name}[/bold]",
915
+ ]
916
+
917
+ if tool_name == "run_shell" and "command" in arguments:
918
+ cmd = self._e(arguments["command"])
919
+ lines.append("")
920
+ lines.append(f"[yellow bold]$ {cmd}[/yellow bold]")
921
+ elif tool_name in ("write_file", "edit_file") and "file_path" in arguments:
922
+ fp = arguments["file_path"]
923
+ lines.append(f"[cyan]{self._e(fp)}[/cyan]")
924
+ if tool_name == "edit_file" and "old_string" in arguments:
925
+ old = arguments["old_string"]
926
+ new = arguments["new_string"]
927
+ # Show inline diff with truncation for long strings
928
+ lines.append("")
929
+ if len(old) <= 200 and len(new) <= 200:
930
+ lines.append(f" [red]- {self._e(old)}[/red]")
931
+ lines.append(f" [green]+ {self._e(new)}[/green]")
932
+ else:
933
+ # Show unified diff for larger changes
934
+ diff = list(difflib.unified_diff(
935
+ old.splitlines(keepends=True),
936
+ new.splitlines(keepends=True),
937
+ fromfile="old", tofile="new", lineterm="",
938
+ ))
939
+ for dline in diff[:30]: # cap at 30 lines
940
+ if dline.startswith("---") or dline.startswith("+++"):
941
+ lines.append(f" [dim]{self._e(dline[:120])}[/dim]")
942
+ elif dline.startswith("@@"):
943
+ lines.append(f" [bold cyan]{self._e(dline[:120])}[/bold cyan]")
944
+ elif dline.startswith("+"):
945
+ lines.append(f" [green]{self._e(dline[:120])}[/green]")
946
+ elif dline.startswith("-"):
947
+ lines.append(f" [red]{self._e(dline[:120])}[/red]")
948
+ else:
949
+ lines.append(f" [dim]{self._e(dline[:120])}[/dim]")
950
+ if len(diff) > 30:
951
+ lines.append(f" [dim]... ({len(diff) - 30} more lines)[/dim]")
952
+ else:
953
+ for k, v in arguments.items():
954
+ s = self._e(str(v)[:100])
955
+ lines.append(f"[dim]{k}:[/dim] {s}")
956
+
957
+ lines.append("")
958
+ lines.append(
959
+ f"[dim][[bold green]y[/bold green]]es "
960
+ f"[[bold red]n[/bold red]]o "
961
+ f"[[bold green]a[/bold green]]llow all {category} "
962
+ f"[[bold red]d[/bold red]]eny all {category}[/dim]"
963
+ )
964
+
965
+ self.console.print()
966
+ self.console.print(Panel("\n".join(lines), border_style=cat_color))
967
+
968
+ while True:
969
+ try:
970
+ choice = self.console.input("[bold yellow]?[/bold yellow] ").strip().lower()
971
+ except (KeyboardInterrupt, EOFError):
972
+ return False
973
+ if choice in ("y", "yes", ""):
974
+ return True
975
+ elif choice in ("n", "no"):
976
+ return False
977
+ elif choice == "a":
978
+ if self._permission_callback:
979
+ self._permission_callback("allow_category", category)
980
+ return True
981
+ elif choice == "d":
982
+ if self._permission_callback:
983
+ self._permission_callback("deny_category", category)
984
+ return False
985
+ else:
986
+ self.console.print("[red]y/n/a/d[/red]")
987
+
988
+ def _simple_permission(self, tool_name, arguments, category) -> bool:
989
+ print(f"\n{Colors.YELLOW}[{category.upper()}] {tool_name}{Colors.RESET}")
990
+ if tool_name == "run_shell" and "command" in arguments:
991
+ print(f" {Colors.YELLOW}$ {arguments['command']}{Colors.RESET}")
992
+ elif "file_path" in arguments:
993
+ print(f" {Colors.CYAN}{arguments['file_path']}{Colors.RESET}")
994
+ print(f" {Colors.DIM}[y]es [n]o [a]llow all {category} [d]eny all {category}{Colors.RESET}")
995
+ while True:
996
+ try:
997
+ choice = input(f"{Colors.YELLOW}?{Colors.RESET} ").strip().lower()
998
+ except (KeyboardInterrupt, EOFError):
999
+ return False
1000
+ if choice in ("y", "yes", ""): return True
1001
+ elif choice in ("n", "no"): return False
1002
+ elif choice == "a":
1003
+ if self._permission_callback:
1004
+ self._permission_callback("allow_category", category)
1005
+ return True
1006
+ elif choice == "d":
1007
+ if self._permission_callback:
1008
+ self._permission_callback("deny_category", category)
1009
+ return False
1010
+
1011
+ def set_permission_callback(self, callback: Callable) -> None:
1012
+ self._permission_callback = callback
1013
+
1014
+ # ── Track edit for diff ──────────────────────────────────────────────
1015
+
1016
+ def track_edit(self, file_path: str, old_content: str):
1017
+ """Record old content before an edit for diff display."""
1018
+ self._last_edit_file = file_path
1019
+ self._last_edit_old = old_content
1020
+
1021
+ def track_usage(self, prompt_tokens: int = 0, completion_tokens: int = 0):
1022
+ self._tracker.add_usage(prompt_tokens, completion_tokens)
1023
+
1024
+ # ── Input & Status ──────────────────────────────────────────────────
1025
+
1026
+ async def get_input(self, session_info: str = "", dangerous: bool = False) -> str:
1027
+ status = self._tracker.status_line() if self._tracker.total_tokens > 0 else ""
1028
+ if HAS_RICH:
1029
+ self.console.print() # blank line before prompt
1030
+ if dangerous:
1031
+ self.console.print("[red bold]DANGEROUS MODE[/red bold] [dim]elevated privileges active[/dim]")
1032
+ if status:
1033
+ self.console.print(f"[dim]{status}[/dim]")
1034
+
1035
+ if HAS_PROMPT_TOOLKIT:
1036
+ return await self._get_input_pt(dangerous)
1037
+ else:
1038
+ return self._get_input_fallback(dangerous)
1039
+
1040
+ async def _get_input_pt(self, dangerous: bool = False) -> str:
1041
+ """Read input via prompt_toolkit (async).
1042
+
1043
+ Enter → submit
1044
+ Ctrl+Enter → insert newline
1045
+ Alt+Enter → insert newline
1046
+ Up/Down → browse input history (consecutive dupes skipped)
1047
+ """
1048
+ if self._pt_session is None:
1049
+ return self._get_input_fallback(dangerous)
1050
+
1051
+ prompt_class = "prompt-danger" if dangerous else "prompt"
1052
+ try:
1053
+ result = await self._pt_session.prompt_async(
1054
+ [("class:" + prompt_class, "❯ ")],
1055
+ multiline=False,
1056
+ )
1057
+ sys.stdout.flush()
1058
+ return result.strip()
1059
+ except (KeyboardInterrupt, EOFError):
1060
+ return ""
1061
+
1062
+ def _get_input_fallback(self, dangerous: bool = False) -> str:
1063
+ """Fallback single-line input when prompt_toolkit is unavailable."""
1064
+ if HAS_RICH:
1065
+ prompt_style = "[bold red]❯[/bold red]" if dangerous else "[bold cyan]❯[/bold cyan]"
1066
+ result = self.console.input(prompt_style + " ")
1067
+ sys.stdout.flush()
1068
+ return result.strip()
1069
+ else:
1070
+ print()
1071
+ if dangerous:
1072
+ print(f"{Colors.RED}{Colors.BOLD}[DANGEROUS MODE]{Colors.RESET}")
1073
+ prompt_char = f"{Colors.RED}{Colors.BOLD}>{Colors.RESET}" if dangerous else f"{Colors.CYAN}{Colors.BOLD}>{Colors.RESET}"
1074
+ result = input(prompt_char + " ")
1075
+ return result.strip()
1076
+
1077
+ # ── Help ─────────────────────────────────────────────────────────────
1078
+
1079
+ def show_help(self):
1080
+ if HAS_RICH:
1081
+ help_text = """
1082
+ [bold cyan]Slash Commands[/bold cyan]
1083
+ [cyan]/help[/cyan] [dim]Show this help[/dim]
1084
+ [cyan]/clear[/cyan] [dim]Start fresh conversation[/dim]
1085
+ [cyan]/compact[/cyan] [dim]Compact conversation history[/dim]
1086
+ [cyan]/context[/cyan] [dim]Show token usage and limits[/dim]
1087
+ [cyan]/cost[/cyan] [dim]Estimate session cost[/dim]
1088
+
1089
+ [bold yellow]Skills[/bold yellow]
1090
+ [yellow]/skill [name][/yellow] [dim]Switch persona[/dim]
1091
+ [yellow]/skills[/yellow] [dim]List all skills[/dim]
1092
+ [yellow]/skill-auto on|off[/yellow] [dim]Toggle auto-detection[/dim]
1093
+
1094
+ [bold green]Memory[/bold green]
1095
+ [green]/remember[/green] [dim]Save: /remember type/name desc | content[/dim]
1096
+ [green]/recall <q>[/green] [dim]Search memories[/dim]
1097
+ [green]/memories[/green] [dim]List all memories[/dim]
1098
+ [green]/forget <name>[/green] [dim]Delete a memory[/dim]
1099
+
1100
+ [bold magenta]Sessions[/bold magenta]
1101
+ [magenta]/save [name][/magenta] [dim]Save current session[/dim]
1102
+ [magenta]/sessions[/magenta] [dim]List saved sessions[/dim]
1103
+ [magenta]/resume <id>[/magenta] [dim]Resume saved session[/dim]
1104
+ [magenta]/export <id> [path][/magenta] [dim]Export as markdown[/dim]
1105
+
1106
+ [bold red]Safety & Undo[/bold red]
1107
+ [red]/undo [n|all][/red] [dim]Undo last N changes[/dim]
1108
+ [red]/redo <change-id>[/red] [dim]Re-apply reverted change[/dim]
1109
+ [red]/changes[/red] [dim]List all file changes[/dim]
1110
+ [red]/diff-changes [n][/red] [dim]Show diffs of recent changes[/dim]
1111
+ [red]/dry-run [on|off][/red] [dim]Preview mode (no actual changes)[/dim]
1112
+ [red]/stats[/red] [dim]Safety & change statistics[/dim]
1113
+
1114
+ [bold red]Dangerous Mode[/bold red] [dim](OS-aware privilege escalation)[/dim]
1115
+ [bold red]/dangerous on[/bold red] [dim]Enable elevated privileges[/dim]
1116
+ [bold red]/dangerous off[/bold red] [dim]Disable, restore safety[/dim]
1117
+ [bold red]/dangerous status[/bold red] [dim]Current mode & OS info[/dim]
1118
+ [bold red]/dangerous audit[/bold red] [dim]Audit log of privileged ops[/dim]
1119
+ [bold red]/elevate[/bold red] [dim]OS-specific elevation guide[/dim]
1120
+
1121
+ [bold cyan]Settings[/bold cyan]
1122
+ [cyan]/model <n>[/cyan] [dim]Change model[/dim]
1123
+ [cyan]/workspace <p>[/cyan] [dim]Change workspace[/dim]
1124
+ [cyan]/permissions[/cyan] [dim]Show permission rules[/dim]
1125
+ [cyan]/mcp[/cyan] [dim]MCP server status[/dim]
1126
+ [cyan]/mcp-tools[/cyan] [dim]List MCP tools[/dim]
1127
+ [cyan]/templates[/cyan] [dim]List prompt templates[/dim]
1128
+ [cyan]/template <n>[/cyan] [dim]Render a template[/dim]
1129
+
1130
+ [bold]Tips[/bold]
1131
+ - Type [cyan]/[/cyan] then [cyan]Tab[/cyan] to see all commands
1132
+ - Be specific: [dim]\"Add type hints to api/handlers.py\"[/dim]
1133
+ - Use [cyan]--allow-all[/cyan] to skip permission prompts
1134
+ - Use [cyan]--resume[/cyan] to continue a saved session
1135
+ - The agent auto-detects your skill from the task
1136
+ """
1137
+ self.console.print(Panel(help_text, border_style="#3F4451"))
1138
+ else:
1139
+ print("""
1140
+ Commands: /help /clear /compact /context /cost
1141
+ Skills: /skill /skills /skill-auto
1142
+ Memory: /remember /recall /memories /forget
1143
+ Sessions: /save /sessions /resume /export
1144
+ Settings: /model /workspace /permissions /mcp /mcp-tools /templates /template
1145
+ Tip: Type / then press Tab to auto-complete commands
1146
+ """)
1147
+
1148
+ # ── Context display ────────────────────────────────────────────────
1149
+
1150
+ def show_context(self, total_messages: int, tool_calls: int, skill: str,
1151
+ model: str, estimated_tokens: int, max_tokens: int):
1152
+ pct = min(100, int(estimated_tokens / max(max_tokens, 1) * 100))
1153
+ if HAS_RICH:
1154
+ bar = self._tracker.render_bar(pct)
1155
+
1156
+ table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))
1157
+ table.add_column("Key", style="bold dim")
1158
+ table.add_column("Value")
1159
+ table.add_row("Messages", f"{total_messages}")
1160
+ table.add_row("Tool calls", f"{tool_calls}")
1161
+ table.add_row("Skill", f"{skill}")
1162
+ table.add_row("Model", f"{model}")
1163
+ table.add_row("Tokens", f"~{estimated_tokens:,} / {max_tokens:,}")
1164
+ table.add_row("Usage", bar)
1165
+ table.add_row("Time", f"{self._tracker.elapsed:.0f}s")
1166
+
1167
+ self.console.print()
1168
+ self.console.print(Panel(table, title="Context", border_style="#3F4451"))
1169
+ else:
1170
+ print(f"\nContext: {total_messages} msgs | {tool_calls} tools | ~{estimated_tokens:,} / {max_tokens:,} ({pct}%)")
1171
+ print(f"Time: {self._tracker.elapsed:.0f}s")
1172
+
1173
+ # ── Session list ────────────────────────────────────────────────────
1174
+
1175
+ def show_sessions(self, sessions: list):
1176
+ if not sessions:
1177
+ print("No saved sessions.")
1178
+ return
1179
+ if HAS_RICH:
1180
+ table = Table(title="Saved Sessions", box=box.SIMPLE)
1181
+ table.add_column("ID", style="cyan", max_width=45)
1182
+ table.add_column("Msgs", justify="right")
1183
+ table.add_column("Tools", justify="right")
1184
+ table.add_column("Skill", style="yellow")
1185
+ table.add_column("Date")
1186
+ for s in sessions[:20]:
1187
+ table.add_row(
1188
+ s.id[:45], str(s.message_count), str(s.tool_call_count),
1189
+ s.skill or "-", s.updated[:16] if s.updated else "",
1190
+ )
1191
+ self.console.print(table)
1192
+ else:
1193
+ for s in sessions[:20]:
1194
+ print(f" {s.id[:50]} [{s.skill or 'default'}] {s.updated[:16]}")
1195
+ print(f" {s.message_count} msgs, {s.tool_call_count} tools")
1196
+
1197
+
1198
+ # ═══════════════════════════════════════════════════════════════════════════════
1199
+ # Generic diff utility (for external use)
1200
+ # ═══════════════════════════════════════════════════════════════════════════════
1201
+
1202
+ def generate_unified_diff(old_text: str, new_text: str, filename: str = "file",
1203
+ context_lines: int = 3) -> str:
1204
+ """Generate a unified diff string."""
1205
+ old_lines = old_text.splitlines(keepends=True)
1206
+ new_lines = new_text.splitlines(keepends=True)
1207
+ diff = difflib.unified_diff(
1208
+ old_lines, new_lines,
1209
+ fromfile=f"a/{filename}",
1210
+ tofile=f"b/{filename}",
1211
+ n=context_lines,
1212
+ )
1213
+ result = "".join(diff)
1214
+ return result if result else "(no changes)"