aru-code 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.
aru/cli.py ADDED
@@ -0,0 +1,1993 @@
1
+ """Interactive CLI for aru - a Claude Code clone."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import random
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+
15
+ from prompt_toolkit import PromptSession
16
+ from prompt_toolkit.completion import Completer, Completion
17
+ from prompt_toolkit.document import Document
18
+ from prompt_toolkit.formatted_text import HTML
19
+ from prompt_toolkit.key_binding import KeyBindings
20
+ from prompt_toolkit.keys import Keys
21
+ from rich.console import Console, ConsoleOptions, RenderResult
22
+ from rich.live import Live
23
+ from rich.markdown import Markdown
24
+ from rich.measure import Measurement
25
+ from rich.panel import Panel
26
+ from rich.rule import Rule
27
+ from rich.spinner import Spinner
28
+ from rich.syntax import Syntax
29
+ from rich.text import Text
30
+
31
+ from aru.agents.executor import create_executor
32
+ from aru.agents.planner import create_planner, review_plan
33
+ from aru.config import AgentConfig, load_config, render_command_template
34
+ from aru.tools.codebase import get_skip_permissions
35
+ from aru.providers import (
36
+ MODEL_ALIASES,
37
+ create_model,
38
+ get_model_display,
39
+ list_providers,
40
+ resolve_model_ref,
41
+ )
42
+
43
+ import io as _io
44
+ import logging as _logging
45
+
46
+ if sys.platform == "win32" and not hasattr(sys, "_called_from_test"):
47
+ sys.stdout = _io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
48
+ sys.stderr = _io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8")
49
+
50
+ # Suppress Agno INFO logs (e.g. "Tool count limit hit") — only show warnings/errors
51
+ _logging.getLogger("agno").setLevel(_logging.WARNING)
52
+
53
+ console = Console()
54
+
55
+ # Arte ASCII original mantida
56
+ aru_logo = """
57
+ ██████▖ ██▗████ ██ ██
58
+ ██ ██ ██ ██
59
+ ▗███████ ██ ██ ██
60
+ ██ ██ ██ ██ ██
61
+ ▝████▘██████ ▝████▘██
62
+ """
63
+
64
+ neon_green = "#39ff14" # Um verde bem "fósforo brilhante"
65
+ shadow_green = "#042800" # Verde bem escuro para sombra
66
+
67
+
68
+ def _build_logo_with_shadow(logo_text: str) -> Text:
69
+ """Build logo Text with a drop-shadow effect for depth."""
70
+ lines = logo_text.strip("\n").split("\n")
71
+ # Pad all lines to same width
72
+ max_width = max(len(l) for l in lines)
73
+ lines = [l.ljust(max_width) for l in lines]
74
+
75
+ rows = len(lines)
76
+ cols = max_width
77
+
78
+ # Shadow offset: 1 row down, 1 col right
79
+ shadow_dy, shadow_dx = 1, 1
80
+ out_rows = rows + shadow_dy
81
+ out_cols = cols + shadow_dx
82
+
83
+ # Build grid: 'main' cells and 'shadow' cells
84
+ grid = [[" "] * out_cols for _ in range(out_rows)]
85
+ cell_type = [["empty"] * out_cols for _ in range(out_rows)]
86
+
87
+ # First pass: mark shadow cells
88
+ for r, line in enumerate(lines):
89
+ for c, ch in enumerate(line):
90
+ if ch != " ":
91
+ sr, sc = r + shadow_dy, c + shadow_dx
92
+ if cell_type[sr][sc] == "empty":
93
+ grid[sr][sc] = ch
94
+ cell_type[sr][sc] = "shadow"
95
+
96
+ # Second pass: mark main cells (overwrite shadow)
97
+ for r, line in enumerate(lines):
98
+ for c, ch in enumerate(line):
99
+ if ch != " ":
100
+ grid[r][c] = ch
101
+ cell_type[r][c] = "main"
102
+
103
+ # Render as Rich Text
104
+ result = Text("\n")
105
+ for r in range(out_rows):
106
+ result.append(" ")
107
+ for c in range(out_cols):
108
+ ch = grid[r][c]
109
+ ct = cell_type[r][c]
110
+ if ct == "main":
111
+ result.append(ch, style=f"bold {neon_green}")
112
+ elif ct == "shadow":
113
+ result.append(ch, style=shadow_green)
114
+ else:
115
+ result.append(ch)
116
+ result.append("\n")
117
+ return result
118
+
119
+ # Default model reference (provider/model format)
120
+ DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
121
+
122
+
123
+ def format_duration(seconds: float) -> str:
124
+ """Format a duration in seconds to a human-readable string.
125
+
126
+ Examples:
127
+ 0.5 -> "500ms"
128
+ 1.0 -> "1s"
129
+ 90.0 -> "1m 30s"
130
+ 3661 -> "1h 1m 1s"
131
+ """
132
+ if seconds < 1:
133
+ return f"{int(seconds * 1000)}ms"
134
+ total = int(seconds)
135
+ hours, remainder = divmod(total, 3600)
136
+ minutes, secs = divmod(remainder, 60)
137
+ if hours:
138
+ return f"{hours}h {minutes}m {secs}s"
139
+ if minutes:
140
+ return f"{minutes}m {secs}s"
141
+ return f"{secs}s"
142
+
143
+
144
+ def _sanitize_input(text: str) -> str:
145
+ """Remove lone UTF-16 surrogates that Windows clipboard can introduce."""
146
+ return text.encode("utf-8", errors="replace").decode("utf-8")
147
+
148
+
149
+ _MENTION_RE = re.compile(r'(?<!\S)@([a-zA-Z0-9_./\\-]+)')
150
+ _MENTION_MAX_SIZE = 30_000 # bytes, same limit as read_file
151
+
152
+
153
+ def _resolve_mentions(text: str, cwd: str) -> tuple[str, int]:
154
+ """Resolve @file mentions by appending file contents to the message.
155
+
156
+ Returns (resolved_text, number_of_files_attached).
157
+ """
158
+ matches = list(_MENTION_RE.finditer(text))
159
+ if not matches:
160
+ return text, 0
161
+
162
+ appendix_parts = []
163
+ seen = set()
164
+ for m in matches:
165
+ rel_path = m.group(1)
166
+ if rel_path in seen:
167
+ continue
168
+ seen.add(rel_path)
169
+ abs_path = os.path.join(cwd, rel_path)
170
+ if not os.path.isfile(abs_path):
171
+ continue
172
+ try:
173
+ size = os.path.getsize(abs_path)
174
+ with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
175
+ content = f.read(_MENTION_MAX_SIZE)
176
+ if size > _MENTION_MAX_SIZE:
177
+ appendix_parts.append(
178
+ f"\n\n---\nContents of {rel_path} (truncated to {_MENTION_MAX_SIZE // 1000}KB):\n```\n{content}\n```"
179
+ )
180
+ else:
181
+ appendix_parts.append(
182
+ f"\n\n---\nContents of {rel_path}:\n```\n{content}\n```"
183
+ )
184
+ except OSError:
185
+ continue
186
+
187
+ if appendix_parts:
188
+ return text + "".join(appendix_parts), len(appendix_parts)
189
+ return text, 0
190
+
191
+
192
+ TIPS = [
193
+ "Type naturally — aru decides whether to plan or execute.",
194
+ "Use /plan to break down complex tasks before executing.",
195
+ "Place AGENTS.md in project root for custom instructions.",
196
+ "Use .agents/commands/ and .agents/skills/ for extensions.",
197
+ "Use ! <command> to run shell commands directly.",
198
+ "Use /model to switch providers (e.g., /model ollama/llama3.1).",
199
+ "Use /sessions to resume previous conversations.",
200
+ ]
201
+
202
+
203
+ def _render_input_separator() -> None:
204
+ """Print a green separator line above the input prompt."""
205
+ console.print(Rule(style=f"dim {neon_green}"))
206
+
207
+
208
+
209
+ def _render_home(session: "Session", skip_permissions: bool) -> None:
210
+ """Render a clean home screen inspired by Claude Code."""
211
+ from rich.table import Table
212
+
213
+ from aru import __version__
214
+
215
+ logo = _build_logo_with_shadow(aru_logo)
216
+ console.print(logo)
217
+ console.print(
218
+ Text.from_markup(f" [dim]A coding agent powered by OpenSource[/dim] [bold {neon_green}]v{__version__}[/bold {neon_green}]"),
219
+ )
220
+ console.print()
221
+
222
+ # Compact command reference
223
+ cmds = Table(show_header=False, box=None, padding=(0, 2), expand=False)
224
+ cmds.add_column(style="bold cyan", min_width=12)
225
+ cmds.add_column(style="dim")
226
+ cmds.add_row("/help", "Show all commands")
227
+ console.print(cmds)
228
+ console.print()
229
+
230
+ # Status line
231
+ mode_label = "[red]skip permissions[/red]" if skip_permissions else "[green]safe mode[/green]"
232
+ console.print(
233
+ Text.from_markup(
234
+ f" [dim]model:[/dim] [bold]{session.model_display}[/bold] [dim]({session.model_id})[/dim]"
235
+ f" [dim]|[/dim] {mode_label}"
236
+ )
237
+ )
238
+ console.print(
239
+ Text.from_markup(f" [dim]cwd:[/dim] {os.getcwd()}")
240
+ )
241
+ console.print()
242
+
243
+
244
+ SLASH_COMMANDS = [
245
+ ("/help", "Show help and available commands", "/help"),
246
+ ("/plan", "Create an implementation plan", "/plan <task>"),
247
+ ("/model", "Switch model/provider", "/model [provider/model]"),
248
+ ("/sessions", "List recent sessions", "/sessions"),
249
+ ("/commands", "List custom commands", "/commands"),
250
+ ("/skills", "List available skills", "/skills"),
251
+ ("/mcp", "List loaded MCP tools", "/mcp"),
252
+ ("/quit", "Exit aru", "/quit"),
253
+ ]
254
+
255
+
256
+ class SlashCommandCompleter(Completer):
257
+ """Show slash commands only when '/' is typed as the first character."""
258
+
259
+ def __init__(self, custom_commands: dict | None = None):
260
+ self._custom_commands = custom_commands or {}
261
+
262
+ def get_completions(self, document: Document, complete_event):
263
+ text = document.text_before_cursor
264
+ # Only complete when '/' is the first character
265
+ if not text.startswith("/"):
266
+ return
267
+ # Built-in commands
268
+ for cmd, description, usage in SLASH_COMMANDS:
269
+ if cmd.startswith(text):
270
+ yield Completion(
271
+ cmd,
272
+ start_position=-len(text),
273
+ display=HTML(f"<b>{cmd}</b>"),
274
+ display_meta=description,
275
+ )
276
+ # Custom commands from .agents/commands/
277
+ for name, cmd_def in self._custom_commands.items():
278
+ slash_name = f"/{name}"
279
+ if slash_name.startswith(text):
280
+ yield Completion(
281
+ slash_name,
282
+ start_position=-len(text),
283
+ display=HTML(f"<b>{slash_name}</b>"),
284
+ display_meta=cmd_def.description,
285
+ )
286
+
287
+
288
+ class FileMentionCompleter(Completer):
289
+ """Show file/directory suggestions when '@' is typed."""
290
+
291
+ def get_completions(self, document: Document, complete_event):
292
+ text = document.text_before_cursor
293
+ # Find the last '@' that is either at start or preceded by whitespace
294
+ idx = text.rfind("@")
295
+ if idx < 0:
296
+ return
297
+ if idx > 0 and not text[idx - 1].isspace():
298
+ return
299
+
300
+ partial = text[idx + 1:] # e.g. "arc/con" from "@arc/con"
301
+ # Split into directory part and name prefix
302
+ if "/" in partial or "\\" in partial:
303
+ # Normalize to forward slashes
304
+ normalized = partial.replace("\\", "/")
305
+ dir_part, name_prefix = normalized.rsplit("/", 1)
306
+ search_dir = os.path.join(os.getcwd(), dir_part)
307
+ rel_prefix = dir_part + "/"
308
+ else:
309
+ dir_part = ""
310
+ name_prefix = partial
311
+ search_dir = os.getcwd()
312
+ rel_prefix = ""
313
+
314
+ if not os.path.isdir(search_dir):
315
+ return
316
+
317
+ from aru.tools.gitignore import is_ignored
318
+ cwd = os.getcwd()
319
+
320
+ try:
321
+ entries = sorted(os.listdir(search_dir))
322
+ except OSError:
323
+ return
324
+
325
+ count = 0
326
+ for entry in entries:
327
+ if count >= 50: # limit suggestions
328
+ break
329
+ if not entry.lower().startswith(name_prefix.lower()):
330
+ continue
331
+
332
+ full_path = os.path.join(search_dir, entry)
333
+ rel_path = os.path.relpath(full_path, cwd).replace("\\", "/")
334
+
335
+ # Skip gitignored entries
336
+ if is_ignored(rel_path, cwd):
337
+ continue
338
+ # Skip hidden files/dirs
339
+ if entry.startswith("."):
340
+ continue
341
+
342
+ is_dir = os.path.isdir(full_path)
343
+ display_text = rel_prefix + entry + ("/" if is_dir else "")
344
+ meta = "dir" if is_dir else ""
345
+
346
+ yield Completion(
347
+ display_text,
348
+ start_position=-len(partial),
349
+ display=HTML(f"<b>@{display_text}</b>"),
350
+ display_meta=meta,
351
+ )
352
+ count += 1
353
+
354
+
355
+ class AruCompleter(Completer):
356
+ """Merges slash-command and @file completions."""
357
+
358
+ def __init__(self, custom_commands: dict | None = None):
359
+ self._slash = SlashCommandCompleter(custom_commands)
360
+ self._mention = FileMentionCompleter()
361
+
362
+ def get_completions(self, document: Document, complete_event):
363
+ text = document.text_before_cursor
364
+ if text.startswith("/"):
365
+ yield from self._slash.get_completions(document, complete_event)
366
+ elif "@" in text:
367
+ yield from self._mention.get_completions(document, complete_event)
368
+
369
+
370
+ class PasteState:
371
+ """Tracks pasted content so the user can annotate it."""
372
+
373
+ def __init__(self):
374
+ self.pasted_content: str | None = None
375
+ self.line_count: int = 0
376
+
377
+ def set(self, content: str):
378
+ lines = content.splitlines()
379
+ self.pasted_content = content
380
+ self.line_count = len(lines)
381
+
382
+ def clear(self):
383
+ self.pasted_content = None
384
+ self.line_count = 0
385
+
386
+ def build_message(self, user_text: str) -> str:
387
+ """Combine user annotation with pasted content."""
388
+ if self.pasted_content and user_text.strip():
389
+ return f"{user_text.strip()}\n\n```\n{self.pasted_content}\n```"
390
+ if self.pasted_content:
391
+ return self.pasted_content
392
+ return user_text
393
+
394
+
395
+ def _create_prompt_session(paste_state: PasteState, config: AgentConfig | None = None) -> PromptSession:
396
+ """Create a prompt_toolkit session with smart paste detection."""
397
+ bindings = KeyBindings()
398
+
399
+ @bindings.add(Keys.Escape, Keys.Enter)
400
+ def _newline(event):
401
+ """Escape+Enter inserts a newline for manual multi-line editing."""
402
+ event.current_buffer.insert_text("\n")
403
+
404
+ custom_cmds = config.commands if config else {}
405
+ session = PromptSession(
406
+ key_bindings=bindings,
407
+ multiline=False,
408
+ enable_open_in_editor=False,
409
+ completer=AruCompleter(custom_cmds),
410
+ complete_while_typing=True,
411
+ )
412
+
413
+ @bindings.add(Keys.BracketedPaste)
414
+ def _handle_paste(event):
415
+ """Intercept multi-line pastes: store content and show line count."""
416
+ data = event.data
417
+ lines = data.splitlines()
418
+ if len(lines) > 1:
419
+ paste_state.set(data)
420
+ # Preserve text typed before the paste (e.g., "/plan ")
421
+ existing_text = event.current_buffer.text
422
+ event.current_buffer.reset()
423
+ if existing_text.strip():
424
+ event.current_buffer.insert_text(existing_text)
425
+ # Dynamically enable toolbar now that paste exists
426
+ session.bottom_toolbar = HTML(
427
+ f'<style fg="ansicyan">│</style> <b><style bg="ansiblue" fg="ansiwhite"> {paste_state.line_count} lines pasted </style></b>'
428
+ f' <i><style fg="ansigray">Type a message about this paste, or press Enter to send as-is</style></i>'
429
+ )
430
+ event.app.invalidate()
431
+ else:
432
+ event.current_buffer.insert_text(data)
433
+
434
+ return session
435
+
436
+ from aru.agents.base import build_instructions as _build_instructions
437
+
438
+
439
+ class PlanStep:
440
+ """A single step in a structured plan."""
441
+
442
+ def __init__(self, index: int, description: str, subtasks: list[str] | None = None):
443
+ self.index = index
444
+ self.description = description
445
+ self.subtasks: list[str] = subtasks or []
446
+ self.status: str = "pending" # pending | in_progress | completed | failed
447
+
448
+ @property
449
+ def checkbox(self) -> str:
450
+ if self.status == "completed":
451
+ return "[bold green]\\[x][/bold green]"
452
+ elif self.status == "in_progress":
453
+ return "[bold yellow]\\[~][/bold yellow]"
454
+ elif self.status == "failed":
455
+ return "[bold red]\\[!][/bold red]"
456
+ return "[dim]\\[ ][/dim]"
457
+
458
+ @property
459
+ def full_description(self) -> str:
460
+ """Description with subtask list for executor prompt."""
461
+ if not self.subtasks:
462
+ return self.description
463
+ subtask_lines = "\n".join(f" {i+1}. {s}" for i, s in enumerate(self.subtasks))
464
+ return f"{self.description}\n\nSubtasks:\n{subtask_lines}"
465
+
466
+ def __str__(self) -> str:
467
+ return f"Step {self.index}: {self.description}"
468
+
469
+ def to_dict(self) -> dict:
470
+ return {"index": self.index, "description": self.description, "subtasks": self.subtasks, "status": self.status}
471
+
472
+ @classmethod
473
+ def from_dict(cls, data: dict) -> "PlanStep":
474
+ step = cls(data["index"], data["description"], data.get("subtasks", []))
475
+ step.status = data.get("status", "pending")
476
+ return step
477
+
478
+
479
+ def parse_plan_steps(plan_text: str) -> list[PlanStep]:
480
+ """Extract structured steps from a plan markdown output.
481
+
482
+ Matches step lines like:
483
+ - [ ] Step 1: Do something
484
+ - [ ] 1. Do something
485
+
486
+ And subtask lines indented below each step:
487
+ 1. Write backend/models.py
488
+ 2. Edit backend/main.py — add router
489
+ """
490
+ steps = []
491
+ lines = plan_text.split("\n")
492
+
493
+ # Patterns
494
+ checkbox_pattern = re.compile(r"^\s*-\s*\[[ x]\]\s*(.+)$")
495
+ subtask_pattern = re.compile(r"^\s+\d+[.:]\s*(.+)$")
496
+
497
+ current_step_desc = None
498
+ current_subtasks: list[str] = []
499
+ step_index = 0
500
+
501
+ def _flush_step():
502
+ nonlocal current_step_desc, current_subtasks, step_index
503
+ if current_step_desc is not None:
504
+ step_index += 1
505
+ cleaned = re.sub(r"^(?:step\s*)?\d+[.:]\s*", "", current_step_desc, flags=re.IGNORECASE).strip()
506
+ steps.append(PlanStep(step_index, cleaned or current_step_desc.strip(), current_subtasks))
507
+ current_subtasks = []
508
+ current_step_desc = None
509
+
510
+ for line in lines:
511
+ checkbox_match = checkbox_pattern.match(line)
512
+ subtask_match = subtask_pattern.match(line)
513
+
514
+ if checkbox_match:
515
+ _flush_step()
516
+ current_step_desc = checkbox_match.group(1)
517
+ elif subtask_match and current_step_desc is not None:
518
+ current_subtasks.append(subtask_match.group(1).strip())
519
+
520
+ _flush_step()
521
+
522
+ if steps:
523
+ return steps
524
+
525
+ # Fallback: numbered items without checkboxes
526
+ numbered_pattern = re.compile(r"^\s*(?:step\s*)?\d+[.:]\s*(.+)$", re.IGNORECASE)
527
+ for line in lines:
528
+ match = numbered_pattern.match(line)
529
+ if match:
530
+ desc = match.group(1)
531
+ cleaned = re.sub(r"^(?:step\s*)?\d+[.:]\s*", "", desc, flags=re.IGNORECASE).strip()
532
+ steps.append(PlanStep(len(steps) + 1, cleaned or desc.strip()))
533
+
534
+ return steps if len(steps) >= 2 else []
535
+
536
+
537
+ class Session:
538
+ """Holds shared state across the conversation."""
539
+
540
+ # Approximate chars-per-token ratio for estimation (conservative)
541
+ _CHARS_PER_TOKEN = 3.5
542
+ # History summarization threshold: summarize oldest messages when history exceeds this
543
+ _HISTORY_SUMMARIZE_THRESHOLD = 20
544
+ _HISTORY_SUMMARIZE_COUNT = 6 # number of oldest messages to condense
545
+
546
+ def __init__(self, session_id: str | None = None):
547
+ self.session_id: str = session_id or _generate_session_id()
548
+ self.history: list[dict[str, str]] = []
549
+ self.current_plan: str | None = None
550
+ self.plan_task: str | None = None
551
+ self.plan_steps: list[PlanStep] = []
552
+ self.model_ref: str = DEFAULT_MODEL # provider/model format
553
+ self.cwd: str = os.getcwd()
554
+ self.created_at: str = datetime.now().isoformat(timespec="milliseconds")
555
+ self.updated_at: str = self.created_at
556
+ self.total_input_tokens: int = 0
557
+ self.total_output_tokens: int = 0
558
+ self.total_cache_read_tokens: int = 0
559
+ self.total_cache_write_tokens: int = 0
560
+ self.api_calls: int = 0
561
+ # Context cache — invalidated on file mutations
562
+ self._cached_tree: str | None = None
563
+ self._cached_git_status: str | None = None
564
+ self._context_dirty: bool = True
565
+ # Token budget (0 = unlimited)
566
+ self.token_budget: int = 0
567
+
568
+ @property
569
+ def model_id(self) -> str:
570
+ """Resolve to the actual model ID for the API."""
571
+ from aru.providers import _get_actual_model_id, get_provider
572
+ provider_key, model_name = resolve_model_ref(self.model_ref)
573
+ provider = get_provider(provider_key)
574
+ if provider:
575
+ return _get_actual_model_id(provider, model_name)
576
+ return model_name
577
+
578
+ @property
579
+ def model_display(self) -> str:
580
+ return get_model_display(self.model_ref)
581
+
582
+ @property
583
+ def title(self) -> str:
584
+ """Generate a short title from the first user message or plan task."""
585
+ if self.plan_task:
586
+ return self.plan_task[:60]
587
+ for msg in self.history:
588
+ if msg["role"] == "user":
589
+ text = msg["content"][:60]
590
+ return text.split("\n")[0]
591
+ return "(empty session)"
592
+
593
+ def set_plan(self, task: str, plan_content: str):
594
+ """Store a plan and parse its steps."""
595
+ self.current_plan = plan_content
596
+ self.plan_task = task
597
+ self.plan_steps = parse_plan_steps(plan_content)
598
+
599
+ def clear_plan(self):
600
+ """Clear the active plan."""
601
+ self.current_plan = None
602
+ self.plan_task = None
603
+ self.plan_steps = []
604
+
605
+ def track_tokens(self, metrics):
606
+ """Accumulate token usage from a RunCompletedEvent.metrics."""
607
+ if metrics is None:
608
+ return
609
+ self.total_input_tokens += getattr(metrics, "input_tokens", 0) or 0
610
+ self.total_output_tokens += getattr(metrics, "output_tokens", 0) or 0
611
+ self.total_cache_read_tokens += getattr(metrics, "cache_read_tokens", 0) or 0
612
+ self.total_cache_write_tokens += getattr(metrics, "cache_write_tokens", 0) or 0
613
+ self.api_calls += 1
614
+
615
+ @property
616
+ def token_summary(self) -> str:
617
+ total = self.total_input_tokens + self.total_output_tokens
618
+ if total == 0:
619
+ return ""
620
+ metrics_str = f"in: {self.total_input_tokens:,} / out: {self.total_output_tokens:,}"
621
+ if self.total_cache_read_tokens > 0:
622
+ metrics_str += f" / cached: {self.total_cache_read_tokens:,}"
623
+ summary = f"tokens: {total:,} ({metrics_str}) | calls: {self.api_calls}"
624
+ if self.token_budget > 0:
625
+ pct = int(total / self.token_budget * 100)
626
+ summary += f" | budget: {pct}%"
627
+ return summary
628
+
629
+ def invalidate_context_cache(self):
630
+ """Mark cached tree/git status as stale. Call after file mutations."""
631
+ self._context_dirty = True
632
+
633
+ def get_cached_tree(self, cwd: str) -> str | None:
634
+ """Return cached directory tree, regenerating if dirty."""
635
+ if self._context_dirty or self._cached_tree is None:
636
+ self._refresh_context_cache(cwd)
637
+ return self._cached_tree
638
+
639
+ def get_cached_git_status(self, cwd: str) -> str | None:
640
+ """Return cached git status, regenerating if dirty."""
641
+ if self._context_dirty or self._cached_git_status is None:
642
+ self._refresh_context_cache(cwd)
643
+ return self._cached_git_status
644
+
645
+ def _refresh_context_cache(self, cwd: str):
646
+ """Regenerate tree and git status caches."""
647
+ try:
648
+ from aru.tools.codebase import get_project_tree
649
+ self._cached_tree = get_project_tree(cwd, max_depth=3) or None
650
+ except Exception:
651
+ self._cached_tree = None
652
+ try:
653
+ self._cached_git_status = subprocess.run(
654
+ ["git", "status", "-s"], capture_output=True, text=True, cwd=cwd, timeout=2
655
+ ).stdout.strip() or None
656
+ except Exception:
657
+ self._cached_git_status = None
658
+ self._context_dirty = False
659
+
660
+ @staticmethod
661
+ def estimate_tokens(text: str) -> int:
662
+ """Fast approximate token count based on character length."""
663
+ return int(len(text) / Session._CHARS_PER_TOKEN)
664
+
665
+ def check_budget_warning(self) -> str | None:
666
+ """Return a warning string if token usage is approaching the budget."""
667
+ if self.token_budget <= 0:
668
+ return None
669
+ total = self.total_input_tokens + self.total_output_tokens
670
+ pct = total / self.token_budget * 100
671
+ if pct >= 95:
672
+ return f"[bold red]Token budget nearly exhausted ({pct:.0f}%)[/bold red]"
673
+ if pct >= 80:
674
+ return f"[yellow]Token budget at {pct:.0f}%[/yellow]"
675
+ return None
676
+
677
+ def add_message(self, role: str, content: str):
678
+ self.history.append({"role": role, "content": content})
679
+ # Summarize oldest messages instead of hard-truncating
680
+ if len(self.history) > self._HISTORY_SUMMARIZE_THRESHOLD:
681
+ self._summarize_old_messages()
682
+ # Hard cap as safety net
683
+ if len(self.history) > 30:
684
+ self.history = self.history[-30:]
685
+
686
+ def _summarize_old_messages(self):
687
+ """Condense the oldest messages into a single summary message.
688
+
689
+ Preserves [Tools] and [Plan] sections so the model knows what actions
690
+ were taken even after summarization.
691
+ """
692
+ n = self._HISTORY_SUMMARIZE_COUNT
693
+ old = self.history[:n]
694
+ rest = self.history[n:]
695
+ summary_parts = []
696
+ for msg in old:
697
+ role = msg["role"]
698
+ content = msg["content"]
699
+ # Extract [Tools] section before truncating
700
+ tools_section = ""
701
+ tools_idx = content.find("\n[Tools]\n")
702
+ if tools_idx != -1:
703
+ tools_section = content[tools_idx:]
704
+ # Truncate the main text but keep tools metadata
705
+ text = content[:300] if tools_idx == -1 else content[:tools_idx][:300]
706
+ if len(content) > 300:
707
+ text += "..."
708
+ if tools_section:
709
+ text += tools_section
710
+ summary_parts.append(f"[{role}]: {text}")
711
+ summary = "[Conversation summary of earlier messages]\n" + "\n".join(summary_parts)
712
+ self.history = [{"role": "user", "content": summary}] + rest
713
+ self.updated_at = datetime.now().isoformat(timespec="milliseconds")
714
+
715
+ def compact_history(self, max_tokens: int) -> int:
716
+ """Remove oldest messages until the estimated token total is below max_tokens.
717
+
718
+ Token count is estimated from the total character length of all messages
719
+ using the class-level _CHARS_PER_TOKEN ratio (3.5 chars ≈ 1 token).
720
+
721
+ Messages are dropped from the front of the history (oldest first).
722
+ If a single message already exceeds max_tokens, the history is reduced
723
+ to that one message only.
724
+
725
+ Args:
726
+ max_tokens: Target token ceiling for the conversation history.
727
+
728
+ Returns:
729
+ Number of messages removed.
730
+ """
731
+ def _total_tokens() -> int:
732
+ return sum(self.estimate_tokens(m["content"]) for m in self.history)
733
+
734
+ removed = 0
735
+ while self.history and _total_tokens() > max_tokens:
736
+ self.history.pop(0)
737
+ removed += 1
738
+
739
+ if removed:
740
+ self.updated_at = datetime.now().isoformat(timespec="milliseconds")
741
+
742
+ return removed
743
+
744
+ def to_dict(self) -> dict:
745
+ return {
746
+ "session_id": self.session_id,
747
+ "history": self.history,
748
+ "current_plan": self.current_plan,
749
+ "plan_task": self.plan_task,
750
+ "plan_steps": [s.to_dict() for s in self.plan_steps],
751
+ "model_ref": self.model_ref,
752
+ "cwd": self.cwd,
753
+ "created_at": self.created_at,
754
+ "updated_at": self.updated_at,
755
+ }
756
+
757
+ @classmethod
758
+ def from_dict(cls, data: dict) -> "Session":
759
+ session = cls(session_id=data["session_id"])
760
+ session.history = data.get("history", [])
761
+ session.current_plan = data.get("current_plan")
762
+ session.plan_task = data.get("plan_task")
763
+ session.plan_steps = [PlanStep.from_dict(s) for s in data.get("plan_steps", [])]
764
+ # Support both new "model_ref" and legacy "model_key" for backward compat
765
+ model_ref = data.get("model_ref")
766
+ if not model_ref:
767
+ legacy_key = data.get("model_key", "sonnet")
768
+ model_ref = MODEL_ALIASES.get(legacy_key, DEFAULT_MODEL)
769
+ session.model_ref = model_ref
770
+ session.cwd = data.get("cwd", os.getcwd())
771
+ session.created_at = data.get("created_at", "")
772
+ session.updated_at = data.get("updated_at", "")
773
+ return session
774
+
775
+ def get_context_summary(self) -> str:
776
+ """Build compact context string from active plan status."""
777
+ parts = []
778
+ if self.current_plan:
779
+ # Send only plan progress (checkboxes), not the full plan text
780
+ parts.append(f"## Active Plan\nTask: {self.plan_task}\n\n{self.render_plan_progress()}")
781
+ return "\n\n".join(parts)
782
+
783
+ def render_plan_progress(self) -> str:
784
+ """Render the plan steps with checkbox status for display."""
785
+ if not self.plan_steps:
786
+ return ""
787
+ lines = []
788
+ completed = sum(1 for s in self.plan_steps if s.status == "completed")
789
+ total = len(self.plan_steps)
790
+ lines.append(f"[bold]Plan Progress ({completed}/{total}):[/bold]")
791
+ for step in self.plan_steps:
792
+ style = ""
793
+ if step.status == "completed":
794
+ style = "green"
795
+ elif step.status == "in_progress":
796
+ style = "yellow"
797
+ elif step.status == "failed":
798
+ style = "red"
799
+ desc = f"[{style}]{step.description}[/{style}]" if style else step.description
800
+ lines.append(f" {step.checkbox} {desc}")
801
+ return "\n".join(lines)
802
+
803
+ def render_compact_progress(self, current_index: int) -> str:
804
+ """Render a token-efficient progress view for LLM context.
805
+
806
+ Shows completed steps as one-liners and only the current step in full.
807
+ Pending steps are listed by index only.
808
+ """
809
+ if not self.plan_steps:
810
+ return ""
811
+ completed = sum(1 for s in self.plan_steps if s.status == "completed")
812
+ total = len(self.plan_steps)
813
+ lines = [f"Progress: {completed}/{total} steps done."]
814
+ for step in self.plan_steps:
815
+ if step.status == "completed":
816
+ lines.append(f" [x] Step {step.index} (done)")
817
+ elif step.index == current_index:
818
+ lines.append(f" [~] Step {step.index}: {step.description} << CURRENT")
819
+ else:
820
+ lines.append(f" [ ] Step {step.index}: {step.description}")
821
+ return "\n".join(lines)
822
+
823
+
824
+ SESSIONS_DIR = os.path.join(".aru", "sessions")
825
+
826
+
827
+ def _generate_session_id() -> str:
828
+ """Generate a short, unique session ID like 'a3f7b2'."""
829
+ raw = f"{time.time()}-{os.getpid()}-{random.randint(0, 999999)}"
830
+ return hashlib.md5(raw.encode()).hexdigest()[:8]
831
+
832
+
833
+ class SessionStore:
834
+ """Persist and load sessions from .aru/sessions/."""
835
+
836
+ def __init__(self, base_dir: str | None = None):
837
+ self.base_dir = base_dir or os.path.join(os.getcwd(), SESSIONS_DIR)
838
+ os.makedirs(self.base_dir, exist_ok=True)
839
+
840
+ def _path(self, session_id: str) -> str:
841
+ return os.path.join(self.base_dir, f"{session_id}.json")
842
+
843
+ def save(self, session: Session):
844
+ """Save session state to disk."""
845
+ session.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
846
+ with open(self._path(session.session_id), "w", encoding="utf-8") as f:
847
+ json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
848
+
849
+ def load(self, session_id: str) -> Session | None:
850
+ """Load a session by ID (full or prefix match)."""
851
+ # Try exact match first
852
+ path = self._path(session_id)
853
+ if os.path.isfile(path):
854
+ return self._read(path)
855
+
856
+ # Try prefix match
857
+ for filename in os.listdir(self.base_dir):
858
+ if filename.startswith(session_id) and filename.endswith(".json"):
859
+ return self._read(os.path.join(self.base_dir, filename))
860
+
861
+ return None
862
+
863
+ def _read(self, path: str) -> Session | None:
864
+ try:
865
+ with open(path, "r", encoding="utf-8") as f:
866
+ data = json.load(f)
867
+ return Session.from_dict(data)
868
+ except (json.JSONDecodeError, OSError, KeyError):
869
+ return None
870
+
871
+ def list_sessions(self, limit: int = 20) -> list[dict]:
872
+ """List recent sessions, newest first."""
873
+ sessions = []
874
+ if not os.path.isdir(self.base_dir):
875
+ return sessions
876
+
877
+ for filename in os.listdir(self.base_dir):
878
+ if not filename.endswith(".json"):
879
+ continue
880
+ path = os.path.join(self.base_dir, filename)
881
+ try:
882
+ with open(path, "r", encoding="utf-8") as f:
883
+ data = json.load(f)
884
+ sessions.append({
885
+ "session_id": data["session_id"],
886
+ "title": data.get("plan_task") or self._first_user_msg(data),
887
+ "model": data.get("model_ref", data.get("model_key", "?")),
888
+ "messages": len(data.get("history", [])),
889
+ "updated_at": data.get("updated_at", ""),
890
+ "cwd": data.get("cwd", ""),
891
+ })
892
+ except (json.JSONDecodeError, OSError, KeyError):
893
+ continue
894
+
895
+ sessions.sort(key=lambda s: s["updated_at"], reverse=True)
896
+ return sessions[:limit]
897
+
898
+ def _first_user_msg(self, data: dict) -> str:
899
+ for msg in data.get("history", []):
900
+ if msg["role"] == "user":
901
+ return msg["content"][:60].split("\n")[0]
902
+ return "(empty session)"
903
+
904
+ def load_last(self) -> Session | None:
905
+ """Load the most recently updated session."""
906
+ sessions = self.list_sessions(limit=1)
907
+ if sessions:
908
+ return self.load(sessions[0]["session_id"])
909
+ return None
910
+
911
+
912
+ def create_general_agent(session: Session, config: AgentConfig | None = None):
913
+ """Create the general-purpose agent."""
914
+ from agno.agent import Agent
915
+ from agno.compression.manager import CompressionManager
916
+
917
+ from aru.tools.codebase import GENERAL_TOOLS, _get_small_model_ref
918
+
919
+ extra = config.get_extra_instructions() if config else ""
920
+
921
+ return Agent(
922
+ name="Aru",
923
+ model=create_model(session.model_ref, max_tokens=8192),
924
+ tools=GENERAL_TOOLS,
925
+ instructions=_build_instructions("general", extra),
926
+ markdown=True,
927
+ # Compress tool results after 7 uncompressed tool calls to save tokens
928
+ compress_tool_results=True,
929
+ compression_manager=CompressionManager(
930
+ model=create_model(_get_small_model_ref(), max_tokens=1024),
931
+ compress_tool_results=True,
932
+ compress_tool_results_limit=7,
933
+ ),
934
+ tool_call_limit=20,
935
+ )
936
+
937
+
938
+ def run_shell(command: str):
939
+ """Run a shell command directly, streaming output to the terminal."""
940
+ console.print()
941
+ console.print(Panel(
942
+ Syntax(command, "bash", theme="monokai"),
943
+ title="[bold]Shell[/bold]",
944
+ border_style="dim",
945
+ expand=False,
946
+ ))
947
+ try:
948
+ process = subprocess.Popen(
949
+ command,
950
+ shell=True,
951
+ stdout=subprocess.PIPE,
952
+ stderr=subprocess.STDOUT,
953
+ text=True,
954
+ cwd=os.getcwd(),
955
+ bufsize=1,
956
+ )
957
+ for line in process.stdout:
958
+ console.print(Text(line.rstrip()))
959
+ process.wait()
960
+ if process.returncode != 0:
961
+ console.print(f"[red]Exit code: {process.returncode}[/red]")
962
+ except KeyboardInterrupt:
963
+ process.kill()
964
+ console.print("\n[yellow]Interrupted.[/yellow]")
965
+ except Exception as e:
966
+ from rich.markup import escape
967
+ console.print(f"[red]Error: {escape(str(e))}[/red]")
968
+ console.print()
969
+
970
+
971
+ def _show_help(config: AgentConfig | None):
972
+ """Display help with available commands."""
973
+ from rich.table import Table
974
+
975
+ table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
976
+ table.add_column("Command", style="cyan")
977
+ table.add_column("Description", style="dim")
978
+
979
+ # Built-in commands
980
+ table.add_row("/plan <task>", "Create detailed implementation plan")
981
+ table.add_row("/model [provider/model]", "Switch models (e.g., ollama/llama3.1, openai/gpt-4o)")
982
+ table.add_row("/sessions", "List recent sessions")
983
+ table.add_row("/commands", "List custom commands")
984
+ table.add_row("/skills", "List available skills")
985
+ table.add_row("/help", "Show this help")
986
+ table.add_row("/quit", "Exit aru")
987
+ table.add_row("! <cmd>", "Run shell command")
988
+
989
+ # Custom commands
990
+ if config and config.commands:
991
+ table.add_row("", "") # Separator
992
+ for name, cmd_def in config.commands.items():
993
+ table.add_row(f"/{name}", cmd_def.description)
994
+
995
+ console.print(table)
996
+ console.print()
997
+
998
+
999
+ THINKING_PHRASES = [
1000
+ "Thinking...",
1001
+ "Cooking...",
1002
+ "Working...",
1003
+ "Making magic...",
1004
+ "Brewing ideas...",
1005
+ "Crunching code...",
1006
+ "Connecting the dots...",
1007
+ "Crafting a plan...",
1008
+ "On it...",
1009
+ "Diving deep...",
1010
+ "Almost there...",
1011
+ "Putting pieces together...",
1012
+ "Wiring things up...",
1013
+ "Spinning up neurons...",
1014
+ "Loading creativity...",
1015
+ ]
1016
+
1017
+
1018
+ class StatusBar:
1019
+ """A bottom status bar that cycles through fun phrases.
1020
+
1021
+ Renders as a thin separator line + spinner text. Rich's Live calls
1022
+ ``__rich_console__`` on every refresh tick, so we rotate the phrase
1023
+ based on wall-clock time — no extra threads needed.
1024
+ """
1025
+
1026
+ def __init__(self, interval: float = 3.0):
1027
+ self._interval = interval
1028
+ self._phrases = list(THINKING_PHRASES)
1029
+ random.shuffle(self._phrases)
1030
+ self._index = 0
1031
+ self._last_switch = time.monotonic()
1032
+ self._override: str | None = None
1033
+
1034
+ @property
1035
+ def current_text(self) -> str:
1036
+ if self._override is not None:
1037
+ return self._override
1038
+ return self._phrases[self._index % len(self._phrases)]
1039
+
1040
+ def set_text(self, text: str):
1041
+ self._override = text
1042
+
1043
+ def resume_cycling(self):
1044
+ self._override = None
1045
+ self._last_switch = time.monotonic()
1046
+
1047
+ def _maybe_rotate(self):
1048
+ now = time.monotonic()
1049
+ if now - self._last_switch >= self._interval:
1050
+ self._last_switch = now
1051
+ self._index += 1
1052
+ if self._index >= len(self._phrases):
1053
+ random.shuffle(self._phrases)
1054
+ self._index = 0
1055
+ self._override = None
1056
+
1057
+ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
1058
+ self._maybe_rotate()
1059
+ spinner = Spinner("dots", text=f"[dim]{self.current_text}[/dim]", style="cyan")
1060
+ yield from spinner.__rich_console__(console, options)
1061
+
1062
+ def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement:
1063
+ return Measurement(1, options.max_width)
1064
+
1065
+
1066
+ @dataclass
1067
+ class AgentRunResult:
1068
+ """Result from run_agent_capture including text output and tool call history."""
1069
+ content: str | None = None
1070
+ tool_calls: list[str] = field(default_factory=list)
1071
+
1072
+ def with_tools_summary(self) -> str | None:
1073
+ """Return content with appended tool call summary for session history."""
1074
+ if not self.content:
1075
+ return self.content
1076
+ if not self.tool_calls:
1077
+ return self.content
1078
+ tools_section = "\n".join(f" - {t}" for t in self.tool_calls)
1079
+ return f"{self.content}\n\n[Tools]\n{tools_section}"
1080
+
1081
+
1082
+ # Categories of tools that modify files (for highlighting in history)
1083
+ _MUTATION_TOOLS = {"write_file", "write_files", "edit_file", "edit_files", "bash", "run_command"}
1084
+
1085
+
1086
+ TOOL_DISPLAY_NAMES = {
1087
+ "read_file": "Read",
1088
+ "read_file_smart": "ReadSmart",
1089
+ "write_file": "Write",
1090
+ "write_files": "Write",
1091
+ "edit_file": "Edit",
1092
+ "edit_files": "Edit",
1093
+ "glob_search": "Glob",
1094
+ "grep_search": "Grep",
1095
+ "list_directory": "List",
1096
+ "bash": "Bash",
1097
+ "code_structure": "Structure",
1098
+ "find_dependencies": "Deps",
1099
+ "rank_files": "Rank",
1100
+ }
1101
+
1102
+ TOOL_PRIMARY_ARG = {
1103
+ "read_file": "file_path",
1104
+ "read_file_smart": "file_path",
1105
+ "write_file": "file_path",
1106
+ "edit_file": "file_path",
1107
+ "glob_search": "pattern",
1108
+ "grep_search": "pattern",
1109
+ "list_directory": "directory",
1110
+ "bash": "command",
1111
+ "code_structure": "file_path",
1112
+ "find_dependencies": "file_path",
1113
+ "rank_files": "task",
1114
+ }
1115
+
1116
+
1117
+ def _format_tool_label(tool_name: str, tool_args: dict | None) -> str:
1118
+ """Format a tool call into a Claude Code-style label like Read(file_path)."""
1119
+ display = TOOL_DISPLAY_NAMES.get(tool_name, tool_name)
1120
+ if not tool_args:
1121
+ return display
1122
+
1123
+ # Batch tools: show count
1124
+ if tool_name == "write_files":
1125
+ files = tool_args.get("files", [])
1126
+ return f"{display}({len(files)} files)"
1127
+ if tool_name == "edit_files":
1128
+ edits = tool_args.get("edits", [])
1129
+ return f"{display}({len(edits)} edits)"
1130
+
1131
+ # Single-arg tools: show the primary arg value
1132
+ primary_key = TOOL_PRIMARY_ARG.get(tool_name)
1133
+ if primary_key and primary_key in tool_args:
1134
+ value = str(tool_args[primary_key])
1135
+ # Truncate long values
1136
+ if len(value) > 60:
1137
+ value = value[:57] + "..."
1138
+ return f"{display}({value})"
1139
+
1140
+ return display
1141
+
1142
+
1143
+ class ToolTracker:
1144
+ """Tracks active tool calls with timing, displayed inside the Live area."""
1145
+
1146
+ def __init__(self):
1147
+ self._active: dict[str, tuple[str, float]] = {} # id -> (label, start_time)
1148
+ self._completed: list[tuple[str, float]] = [] # (label, duration)
1149
+
1150
+ def start(self, tool_id: str, label: str):
1151
+ self._active[tool_id] = (label, time.monotonic())
1152
+
1153
+ def complete(self, tool_id: str) -> tuple[str, float] | None:
1154
+ entry = self._active.pop(tool_id, None)
1155
+ if entry:
1156
+ label, start = entry
1157
+ duration = time.monotonic() - start
1158
+ self._completed.append((label, duration))
1159
+ return label, duration
1160
+ return None
1161
+
1162
+ @property
1163
+ def active_labels(self) -> list[tuple[str, float]]:
1164
+ """Return (label, elapsed_seconds) for each active tool."""
1165
+ now = time.monotonic()
1166
+ return [(label, now - start) for label, start in self._active.values()]
1167
+
1168
+ def pop_completed(self) -> list[tuple[str, float]]:
1169
+ """Drain and return completed tools since last call."""
1170
+ items = self._completed[:]
1171
+ self._completed.clear()
1172
+ return items
1173
+
1174
+
1175
+ class StreamingDisplay:
1176
+ """Shows un-flushed streaming content + active tool indicators + status bar.
1177
+
1178
+ Active tools are rendered inline (inside Live) so they're always visible.
1179
+ Completed tools are flushed as static output above Live.
1180
+ """
1181
+
1182
+ def __init__(self, status_bar: StatusBar):
1183
+ self.status_bar = status_bar
1184
+ self.tool_tracker = ToolTracker()
1185
+ self._flushed_len: int = 0
1186
+ self._accumulated: str = ""
1187
+ self._content: Markdown | None = None
1188
+
1189
+ def set_content(self, accumulated: str):
1190
+ self._accumulated = accumulated
1191
+ delta = accumulated[self._flushed_len:]
1192
+ self.content = Markdown(delta) if delta else None
1193
+
1194
+ def flush(self):
1195
+ delta = self._accumulated[self._flushed_len:]
1196
+ if delta:
1197
+ console.print(Markdown(delta))
1198
+ self._flushed_len = len(self._accumulated)
1199
+ self.content = None
1200
+
1201
+ @property
1202
+ def content(self) -> Markdown | None:
1203
+ return self._content
1204
+
1205
+ @content.setter
1206
+ def content(self, value: Markdown | None):
1207
+ self._content = value
1208
+
1209
+ def __rich_console__(self, rconsole: Console, options: ConsoleOptions) -> RenderResult:
1210
+ if self._content is not None:
1211
+ yield self._content
1212
+ yield Text()
1213
+
1214
+ # Render active tools with spinner and elapsed time
1215
+ active = self.tool_tracker.active_labels
1216
+ if active:
1217
+ for label, elapsed in active:
1218
+ elapsed_str = f"{elapsed:.1f}s" if elapsed >= 1.0 else ""
1219
+ tool_line = Text.assemble(
1220
+ (" ", ""),
1221
+ ("↻ ", "bold cyan"),
1222
+ (label, "bold"),
1223
+ (f" {elapsed_str}" if elapsed_str else "", "dim"),
1224
+ )
1225
+ yield tool_line
1226
+ yield Text()
1227
+
1228
+ yield self.status_bar
1229
+
1230
+ def __rich_measure__(self, rconsole: Console, options: ConsoleOptions) -> Measurement:
1231
+ return Measurement(1, options.max_width)
1232
+
1233
+
1234
+ async def run_agent_capture(agent, message: str, session: "Session | None" = None, lightweight: bool = False) -> AgentRunResult:
1235
+ """Run agent with async streaming display and parallel tool execution.
1236
+
1237
+ Args:
1238
+ agent: The Agno agent to run.
1239
+ message: The user message/prompt.
1240
+ session: Optional session for history and context.
1241
+ lightweight: If True, skip tree/git/plan context and history (for executor steps).
1242
+
1243
+ Returns:
1244
+ AgentRunResult with text content and list of tool call labels.
1245
+ """
1246
+ from agno.models.message import Message
1247
+ from agno.run.agent import (
1248
+ RunContentEvent,
1249
+ RunOutput,
1250
+ ToolCallCompletedEvent,
1251
+ ToolCallStartedEvent,
1252
+ )
1253
+
1254
+ console.print()
1255
+ final_content = None
1256
+ collected_tool_calls: list[str] = []
1257
+
1258
+ try:
1259
+ from aru.tools.codebase import set_display, set_live
1260
+ from aru.tools.tasklist import set_live as tasklist_set_live, set_display as tasklist_set_display
1261
+
1262
+ status = StatusBar(interval=3.0)
1263
+ display = StreamingDisplay(status)
1264
+ tracker = display.tool_tracker
1265
+
1266
+ # Build enriched message with environment context (using cache)
1267
+ dynamic_parts = []
1268
+ cwd = os.getcwd()
1269
+ dynamic_parts.append(f"The current working directory is: {cwd}")
1270
+
1271
+ if session and not lightweight:
1272
+ env_context_parts = []
1273
+ tree_text = session.get_cached_tree(cwd)
1274
+ if tree_text:
1275
+ env_context_parts.append(f"Directory Tree (max depth 3):\n```text\n{tree_text}\n```")
1276
+
1277
+ git_status = session.get_cached_git_status(cwd)
1278
+ if git_status:
1279
+ env_context_parts.append(f"Git status:\n{git_status}")
1280
+
1281
+ if env_context_parts:
1282
+ dynamic_parts.append("## Environment Context\n" + "\n\n".join(env_context_parts))
1283
+
1284
+ # Include only compact plan progress (not full plan text)
1285
+ if session.current_plan:
1286
+ dynamic_parts.append(f"## Active Plan\nTask: {session.plan_task}\n\n{session.render_plan_progress()}")
1287
+
1288
+ # Token budget warning
1289
+ warning = session.check_budget_warning()
1290
+ if warning:
1291
+ console.print(warning)
1292
+
1293
+ dynamic_context = "\n\n".join(dynamic_parts)
1294
+ run_message = f"{dynamic_context}\n\n---\n\n## Current Task/Message\n{message}"
1295
+
1296
+ # Build conversation history as real messages for the LLM
1297
+ # Skip history for lightweight executor steps — they don't need prior conversation
1298
+ from aru.context import prune_history
1299
+ history_messages: list[Message] = []
1300
+ if session and session.history and not lightweight:
1301
+ # The last message is the current user input (already added before calling this function)
1302
+ prior_history = session.history[:-1]
1303
+ pruned = prune_history(prior_history)
1304
+ for msg in pruned:
1305
+ history_messages.append(Message(role=msg["role"], content=msg["content"], from_history=True))
1306
+
1307
+ # Combine: history messages + current enriched message
1308
+ if history_messages:
1309
+ history_messages.append(Message(role="user", content=run_message))
1310
+ agent_input = history_messages
1311
+ else:
1312
+ agent_input = run_message
1313
+
1314
+ run_output = None
1315
+ with Live(display, console=console, refresh_per_second=10) as live:
1316
+ set_live(live)
1317
+ set_display(display)
1318
+ tasklist_set_live(live)
1319
+ tasklist_set_display(display)
1320
+ accumulated = ""
1321
+ async for event in agent.arun(agent_input, stream=True, stream_events=True, yield_run_output=True):
1322
+ if isinstance(event, RunOutput):
1323
+ run_output = event
1324
+ break
1325
+
1326
+ if isinstance(event, ToolCallStartedEvent):
1327
+ if hasattr(event, "tool") and event.tool:
1328
+ tool_name = event.tool.tool_name or "tool"
1329
+ tool_args = event.tool.tool_args or None
1330
+ tool_id = getattr(event.tool, "tool_call_id", None) or tool_name
1331
+ else:
1332
+ tool_name = getattr(event, "tool_name", "tool")
1333
+ tool_args = getattr(event, "tool_args", None)
1334
+ tool_id = getattr(event, "tool_call_id", None) or tool_name
1335
+ label = _format_tool_label(tool_name, tool_args)
1336
+ collected_tool_calls.append(label)
1337
+ # Flush any accumulated content before tool runs
1338
+ if accumulated[display._flushed_len:]:
1339
+ live.stop()
1340
+ display.flush()
1341
+ live.start()
1342
+ live._live_render._shape = None
1343
+ tracker.start(tool_id, label)
1344
+ status.set_text(f"{label}...")
1345
+ live.update(display)
1346
+
1347
+ elif isinstance(event, ToolCallCompletedEvent):
1348
+ if hasattr(event, "tool") and event.tool:
1349
+ tool_id = getattr(event.tool, "tool_call_id", None) or getattr(event.tool, "tool_name", "tool")
1350
+ else:
1351
+ tool_id = getattr(event, "tool_call_id", None) or getattr(event, "tool_name", "tool")
1352
+
1353
+ result = tracker.complete(tool_id)
1354
+ # Flush completed tools as static output above Live
1355
+ for label, duration in tracker.pop_completed():
1356
+ dur_str = f" {duration:.1f}s" if duration >= 0.5 else ""
1357
+ live.console.print(Text.assemble(
1358
+ (" ", ""),
1359
+ ("\u2713 ", "bold green"),
1360
+ (label, "dim"),
1361
+ (dur_str, "dim cyan"),
1362
+ ))
1363
+ if not tracker.active_labels:
1364
+ status.resume_cycling()
1365
+ live.update(display)
1366
+
1367
+ elif isinstance(event, RunContentEvent):
1368
+ if hasattr(event, "content") and event.content:
1369
+ accumulated += event.content
1370
+ unflushed = accumulated[display._flushed_len:]
1371
+
1372
+ # Auto-flush long chunks to prevent rich.Live smearing
1373
+ if unflushed.count("\n") > 15:
1374
+ break_point = unflushed.rfind("\n\n")
1375
+ if break_point == -1:
1376
+ break_point = unflushed.rfind("\n")
1377
+
1378
+ if break_point != -1:
1379
+ chunk = unflushed[:break_point + 1]
1380
+ # Only flush if we are outside of a code block (balanced ```)
1381
+ if chunk.count("```") % 2 == 0:
1382
+ # Clear live content before stopping to prevent
1383
+ # rich.Live re-rendering stale text on stop()
1384
+ display.content = None
1385
+ live.stop()
1386
+ console.print(Markdown(chunk))
1387
+ display._flushed_len += len(chunk)
1388
+ live.start()
1389
+ live._live_render._shape = None
1390
+
1391
+ display.set_content(accumulated)
1392
+ live.update(display)
1393
+
1394
+ set_live(None)
1395
+ set_display(None)
1396
+
1397
+ if run_output and session and hasattr(run_output, "metrics"):
1398
+ session.track_tokens(run_output.metrics)
1399
+
1400
+ # Layer 3: Auto-compact conversation when approaching context limits
1401
+ from aru.context import should_compact, compact_conversation
1402
+ if should_compact(session.total_input_tokens, session.model_id):
1403
+ try:
1404
+ session.history = await compact_conversation(
1405
+ session.history, session.model_ref, session.plan_task
1406
+ )
1407
+ console.print("[dim]Context compacted to save tokens.[/dim]")
1408
+ except Exception:
1409
+ pass # compaction is best-effort
1410
+
1411
+ # Print only un-flushed content
1412
+ final_content = accumulated or final_content
1413
+ remaining = (final_content or "")[display._flushed_len:]
1414
+ if remaining:
1415
+ console.print(Markdown(remaining))
1416
+
1417
+ except (KeyboardInterrupt, asyncio.CancelledError):
1418
+ set_live(None)
1419
+ set_display(None)
1420
+ console.print("\n[yellow]Interrupted.[/yellow]")
1421
+ except Exception as e:
1422
+ set_live(None)
1423
+ set_display(None)
1424
+ from rich.markup import escape
1425
+ console.print(f"[red]Error: {escape(str(e))}[/red]")
1426
+
1427
+ console.print()
1428
+ return AgentRunResult(content=final_content, tool_calls=collected_tool_calls)
1429
+
1430
+
1431
+ def ask_yes_no(prompt: str) -> bool:
1432
+ """Ask the user a yes/no question."""
1433
+ try:
1434
+ answer = console.input(f"[bold yellow]{prompt} (y/n):[/bold yellow] ").strip().lower()
1435
+ return answer in ("y", "yes", "s", "sim")
1436
+ except (EOFError, KeyboardInterrupt):
1437
+ return False
1438
+
1439
+
1440
+ def _extract_plan_file_paths(plan_text: str) -> list[str]:
1441
+ """Extract file paths mentioned in plan steps (e.g., 'in `aru/cli.py`')."""
1442
+ # Match backtick-quoted paths that look like file paths
1443
+ matches = re.findall(r"`([^`]+\.\w{1,5})`", plan_text or "")
1444
+ seen = set()
1445
+ paths = []
1446
+ for m in matches:
1447
+ norm = os.path.normpath(m)
1448
+ if norm not in seen and os.path.isfile(norm):
1449
+ seen.add(norm)
1450
+ paths.append(norm)
1451
+ return paths
1452
+
1453
+
1454
+ def _build_file_context(file_paths: list[str], max_total: int = 20_000) -> str:
1455
+ """Read files and build a context string, respecting a total char budget."""
1456
+ if not file_paths:
1457
+ return ""
1458
+ parts = []
1459
+ total = 0
1460
+ for path in file_paths:
1461
+ try:
1462
+ content = open(path, "r", encoding="utf-8").read()
1463
+ if total + len(content) > max_total:
1464
+ continue
1465
+ total += len(content)
1466
+ parts.append(f"### `{path}`\n```\n{content}\n```")
1467
+ except Exception:
1468
+ continue
1469
+ if not parts:
1470
+ return ""
1471
+ return "## Pre-loaded file contents (do NOT re-read these files)\n\n" + "\n\n".join(parts)
1472
+
1473
+
1474
+ async def execute_plan_steps(session: Session, executor_factory) -> str | None:
1475
+ """Execute plan steps one by one with live progress tracking.
1476
+
1477
+ Shows a checkbox progress panel that updates as each step completes.
1478
+ Each step runs as a separate executor call with full context.
1479
+ """
1480
+ # Pre-load files mentioned in the plan to avoid redundant reads per step
1481
+ plan_files = _extract_plan_file_paths(session.current_plan)
1482
+ file_context = _build_file_context(plan_files)
1483
+
1484
+ if not session.plan_steps:
1485
+ # No structured steps — fall back to single execution
1486
+ executor = executor_factory()
1487
+ exec_prompt = (
1488
+ f"Execute the following plan step by step.\n\n"
1489
+ f"## Task\n{session.plan_task}\n\n"
1490
+ f"## Plan\n{session.current_plan}"
1491
+ )
1492
+ run_result = await run_agent_capture(executor, exec_prompt, session, lightweight=True)
1493
+ return run_result.with_tools_summary()
1494
+
1495
+ all_results = []
1496
+ completed_context = ""
1497
+
1498
+ for step in session.plan_steps:
1499
+ # Show current progress
1500
+ console.print()
1501
+ console.print(Panel(
1502
+ Text.from_markup(session.render_plan_progress()),
1503
+ title="[bold]Plan Progress[/bold]",
1504
+ border_style="blue",
1505
+ padding=(0, 1),
1506
+ ))
1507
+ console.print()
1508
+
1509
+ # Mark step as in progress
1510
+ step.status = "in_progress"
1511
+ console.print(f"[bold yellow]>>> Step {step.index}:[/bold yellow] {step.description}")
1512
+
1513
+ # Build step-specific prompt — compact to save tokens
1514
+ # Only show current step + compact progress (not full descriptions of other steps)
1515
+ compact_progress = session.render_compact_progress(step.index)
1516
+ step_prompt_parts = [
1517
+ f"## Task: {session.plan_task}\n",
1518
+ f"## Current Step ({step.index}/{len(session.plan_steps)})\n{step.description}\n",
1519
+ f"## Progress\n{compact_progress}\n",
1520
+ "IMPORTANT: Just execute this step. Do NOT repeat completed steps or summarize.",
1521
+ ]
1522
+ if file_context:
1523
+ step_prompt_parts.insert(1, file_context)
1524
+ step_prompt = "\n".join(step_prompt_parts)
1525
+
1526
+ # Reset task store for each new step
1527
+ from aru.tools.tasklist import reset_task_store
1528
+ reset_task_store()
1529
+
1530
+ # Execute this step (lightweight=True to skip tree/git/history)
1531
+ executor = executor_factory()
1532
+ try:
1533
+ run_result = await run_agent_capture(executor, step_prompt, session, lightweight=True)
1534
+ content = run_result.content
1535
+
1536
+ # Check task store as ground truth for step completion
1537
+ from aru.tools.tasklist import get_task_store
1538
+ store = get_task_store()
1539
+ all_tasks = store.get_all()
1540
+ tasks_completed = sum(1 for t in all_tasks if t["status"] == "completed")
1541
+ tasks_failed = sum(1 for t in all_tasks if t["status"] == "failed")
1542
+ tasks_total = len(all_tasks)
1543
+ tasks_all_done = tasks_total > 0 and (tasks_completed + tasks_failed == tasks_total)
1544
+
1545
+ # Determine step outcome: task store takes precedence over content
1546
+ step_failed = False
1547
+ if tasks_all_done:
1548
+ if tasks_failed > 0 and tasks_completed == 0:
1549
+ step_failed = True
1550
+ # else: at least some tasks completed → step succeeded
1551
+ elif content:
1552
+ step_failed = (
1553
+ content.startswith("Error")
1554
+ or "Error from OpenAI API" in content
1555
+ or "Error in Agent run" in content
1556
+ )
1557
+
1558
+ if step_failed:
1559
+ step.status = "failed"
1560
+ fail_msg = content[:200] if content else f"{tasks_failed}/{tasks_total} subtasks failed"
1561
+ console.print(f"\n[red]Step {step.index} failed: {fail_msg}[/red]")
1562
+ if not get_skip_permissions() and not ask_yes_no("Continue with remaining steps?"):
1563
+ break
1564
+ elif content or tasks_all_done:
1565
+ step.status = "completed"
1566
+ # Build step result text
1567
+ summary = content or f"All {tasks_completed} subtasks completed."
1568
+ step_text = f"### Step {step.index}: {step.description}\n{summary}"
1569
+ if run_result.tool_calls:
1570
+ tools_str = ", ".join(run_result.tool_calls)
1571
+ step_text += f"\nTools: {tools_str}"
1572
+ all_results.append(step_text)
1573
+ completed_context += f"\n- Step {step.index} ({step.description}): Done"
1574
+ else:
1575
+ step.status = "completed"
1576
+ completed_context += f"\n- Step {step.index} ({step.description}): Done (no output)"
1577
+ except (KeyboardInterrupt, asyncio.CancelledError):
1578
+ step.status = "failed"
1579
+ console.print(f"\n[yellow]Step {step.index} interrupted.[/yellow]")
1580
+ # Ask if user wants to continue with remaining steps
1581
+ if not get_skip_permissions() and not ask_yes_no("Continue with remaining steps?"):
1582
+ break
1583
+ except Exception as e:
1584
+ step.status = "failed"
1585
+ console.print(f"\n[red]Step {step.index} failed: {e}[/red]")
1586
+ if not get_skip_permissions() and not ask_yes_no("Continue with remaining steps?"):
1587
+ break
1588
+
1589
+ # Final progress display
1590
+ console.print()
1591
+ console.print(Panel(
1592
+ Text.from_markup(session.render_plan_progress()),
1593
+ title="[bold]Plan Complete[/bold]",
1594
+ border_style="green" if all(s.status == "completed" for s in session.plan_steps) else "yellow",
1595
+ padding=(0, 1),
1596
+ ))
1597
+
1598
+ return "\n\n".join(all_results) if all_results else None
1599
+
1600
+
1601
+ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
1602
+ """Main REPL loop."""
1603
+ from aru.tools.codebase import set_console, set_model_id, set_small_model_ref, set_skip_permissions, reset_allowed_actions, set_permission_rules, set_on_file_mutation
1604
+ set_console(console)
1605
+ set_skip_permissions(skip_permissions)
1606
+
1607
+ store = SessionStore()
1608
+
1609
+ def _sync_model(sess: Session):
1610
+ """Sync the model IDs to the tools module from the session's model_ref."""
1611
+ set_model_id(sess.model_id)
1612
+ # Determine small model for sub-agents based on provider
1613
+ small_ref = config.model_defaults.get("small") if config else None
1614
+ if not small_ref:
1615
+ provider_key, _ = resolve_model_ref(sess.model_ref)
1616
+ # Use same provider but pick a small/fast model
1617
+ _small_defaults = {
1618
+ "anthropic": "anthropic/claude-haiku-4-5",
1619
+ "openai": "openai/gpt-4o-mini",
1620
+ "groq": "groq/llama-3.1-8b-instant",
1621
+ "deepseek": "deepseek/deepseek-chat",
1622
+ "ollama": "ollama/llama3.1",
1623
+ }
1624
+ small_ref = _small_defaults.get(provider_key, sess.model_ref)
1625
+ set_small_model_ref(small_ref)
1626
+
1627
+ # Load project configuration (AGENTS.md, .agents/commands, .agents/skills)
1628
+ config = load_config()
1629
+ if config.agents_md:
1630
+ console.print("[dim]Loaded AGENTS.md[/dim]")
1631
+ if config.commands:
1632
+ console.print(f"[dim]Loaded {len(config.commands)} custom command(s): {', '.join(f'/{k}' for k in config.commands)}[/dim]")
1633
+ if config.skills:
1634
+ console.print(f"[dim]Loaded {len(config.skills)} skill(s): {', '.join(config.skills.keys())}[/dim]")
1635
+ permission_allow = config.permissions.get("allow", [])
1636
+ if permission_allow:
1637
+ set_permission_rules(permission_allow)
1638
+ console.print(f"[dim]Loaded {len(permission_allow)} permission rule(s)[/dim]")
1639
+
1640
+ extra_instructions = config.get_extra_instructions()
1641
+
1642
+ # Resume or create session
1643
+ if resume_id:
1644
+ if resume_id == "last":
1645
+ session = store.load_last()
1646
+ else:
1647
+ session = store.load(resume_id)
1648
+ if session is None:
1649
+ console.print(f"[red]Session not found: {resume_id}[/red]")
1650
+ return
1651
+ console.print(Markdown(f"# aru - Resuming session `{session.session_id}`"))
1652
+ console.print(f"[dim]Title: {session.title}[/dim]")
1653
+ console.print(f"[dim]Messages: {len(session.history)} | Created: {session.created_at}[/dim]")
1654
+ if session.history:
1655
+ console.print(f"[green]Session loaded — {len(session.history)} messages restored.[/green]")
1656
+ if session.current_plan:
1657
+ console.print(f"[dim]Active plan: {session.plan_task}[/dim]")
1658
+ if session.plan_steps:
1659
+ completed = sum(1 for s in session.plan_steps if s.status == "completed")
1660
+ console.print(f"[dim]Steps: {completed}/{len(session.plan_steps)} completed[/dim]")
1661
+ # Restore model
1662
+ _sync_model(session)
1663
+ else:
1664
+ session = Session()
1665
+ # Apply default model from config if set
1666
+ if config.model_defaults.get("default"):
1667
+ session.model_ref = config.model_defaults["default"]
1668
+ _render_home(session, skip_permissions)
1669
+
1670
+ # Wire file-mutation callback so context cache (tree/git) is invalidated
1671
+ set_on_file_mutation(session.invalidate_context_cache)
1672
+
1673
+ planner = None
1674
+ executor = None
1675
+ paste_state = PasteState()
1676
+ prompt_session = _create_prompt_session(paste_state, config)
1677
+
1678
+ # Startup: load MCP tools
1679
+ from aru.tools.codebase import load_mcp_tools
1680
+ await load_mcp_tools()
1681
+
1682
+ while True:
1683
+ try:
1684
+ paste_state.clear()
1685
+ _render_input_separator()
1686
+ model_tb = session.model_display
1687
+ user_text = (
1688
+ await asyncio.to_thread(
1689
+ prompt_session.prompt,
1690
+ HTML('<b><ansigreen>❯</ansigreen></b> '),
1691
+ multiline=False,
1692
+ bottom_toolbar=HTML(
1693
+ f' <style fg="ansigray">{model_tb}</style>'
1694
+ f' <style fg="ansigray">│</style>'
1695
+ f' <style fg="ansigray">/help</style>'
1696
+ f' <style fg="ansigray">│</style>'
1697
+ f' <style fg="ansigray">Esc+Enter newline</style>'
1698
+ ),
1699
+ )
1700
+ ).strip()
1701
+ _render_input_separator()
1702
+ except (EOFError, KeyboardInterrupt, asyncio.CancelledError):
1703
+ store.save(session)
1704
+ console.print(f"\n[dim]Session saved: {session.session_id}[/dim]")
1705
+ console.print(f"[dim]Resume with:[/dim] [bold cyan]aru --resume {session.session_id}[/bold cyan]")
1706
+ console.print("[dim]Bye![/dim]")
1707
+ from aru.tools.mcp_client import cleanup_mcp
1708
+ await cleanup_mcp()
1709
+ break
1710
+
1711
+ user_input = _sanitize_input(paste_state.build_message(user_text))
1712
+
1713
+ # Resolve @file mentions
1714
+ resolved, injected = _resolve_mentions(user_input, os.getcwd())
1715
+ if resolved != user_input:
1716
+ console.print(f"[dim]Attached {injected} file(s) from @ mentions[/dim]")
1717
+ user_input = resolved
1718
+
1719
+ if paste_state.pasted_content and user_text:
1720
+ console.print(
1721
+ f"[dim] {paste_state.line_count} lines pasted[/dim] [cyan]{user_text}[/cyan]"
1722
+ )
1723
+ elif paste_state.pasted_content:
1724
+ console.print(
1725
+ f"[dim] {paste_state.line_count} lines pasted[/dim]"
1726
+ )
1727
+
1728
+ if not user_input:
1729
+ continue
1730
+
1731
+ # Reset "allow all" approvals for each new user message
1732
+ reset_allowed_actions()
1733
+
1734
+ if user_input.lower() in ("/quit", "/exit", "quit", "exit"):
1735
+ store.save(session)
1736
+ console.print(f"[dim]Session saved: {session.session_id}[/dim]")
1737
+ console.print(f"[dim]Resume with:[/dim] [bold cyan]aru --resume {session.session_id}[/bold cyan]")
1738
+ console.print("[dim]Bye![/dim]")
1739
+ from aru.tools.mcp_client import cleanup_mcp
1740
+ await cleanup_mcp()
1741
+ break
1742
+
1743
+ if user_input == "/model" or user_input.startswith("/model "):
1744
+ arg = user_input[6:].strip()
1745
+ if not arg:
1746
+ console.print(f"[bold]Current model:[/bold] {session.model_display} ({session.model_id})")
1747
+ console.print()
1748
+ # Show model aliases from aru.json
1749
+ if config.model_defaults:
1750
+ non_default = {k: v for k, v in config.model_defaults.items() if k != "default"}
1751
+ if non_default:
1752
+ console.print("[bold]Model aliases (aru.json):[/bold]")
1753
+ for alias, ref in non_default.items():
1754
+ console.print(f" [cyan]{alias}[/cyan] → {ref}")
1755
+ console.print()
1756
+ console.print("[bold]Aliases:[/bold]")
1757
+ for alias, ref in MODEL_ALIASES.items():
1758
+ console.print(f" [cyan]{alias}[/cyan] → {ref}")
1759
+ console.print()
1760
+ console.print("[bold]Providers:[/bold]")
1761
+ for pkey, pconfig in list_providers().items():
1762
+ dflt = pconfig.default_model or "—"
1763
+ console.print(f" [cyan]{pkey}[/cyan] ({pconfig.name}) — default: {dflt}")
1764
+ console.print()
1765
+ console.print("[dim]Usage: /model <provider/model> (e.g., /model ollama/llama3.1, /model openai/gpt-4o)[/dim]")
1766
+ else:
1767
+ arg_lower = arg.lower()
1768
+ try:
1769
+ # Resolve config aliases (aru.json "models" section) first
1770
+ resolved_ref = config.model_defaults.get(arg_lower, arg_lower) if config.model_defaults else arg_lower
1771
+ # Validate the model reference resolves to a known provider
1772
+ provider_key, model_name = resolve_model_ref(resolved_ref)
1773
+ from aru.providers import get_provider
1774
+ provider = get_provider(provider_key)
1775
+ if provider is None:
1776
+ available = ", ".join(sorted(list_providers().keys()))
1777
+ console.print(f"[yellow]Unknown provider '{provider_key}'. Available: {available}[/yellow]")
1778
+ else:
1779
+ session.model_ref = resolved_ref if "/" in resolved_ref else (
1780
+ MODEL_ALIASES.get(resolved_ref, resolved_ref)
1781
+ )
1782
+ _sync_model(session)
1783
+ planner = None
1784
+ executor = None
1785
+ console.print(f"[bold green]Switched to {session.model_display}[/bold green] ({session.model_id})")
1786
+ except Exception as e:
1787
+ console.print(f"[yellow]Error: {e}[/yellow]")
1788
+ continue
1789
+
1790
+ if user_input.lower() in ("/sessions", "/list"):
1791
+ sessions = store.list_sessions()
1792
+ if not sessions:
1793
+ console.print("[dim]No saved sessions.[/dim]")
1794
+ else:
1795
+ console.print("[bold]Recent sessions:[/bold]\n")
1796
+ for s in sessions:
1797
+ sid = s["session_id"]
1798
+ title = s["title"][:50]
1799
+ msgs = s["messages"]
1800
+ updated = s["updated_at"]
1801
+ model = s["model"]
1802
+ is_current = " [green](current)[/green]" if sid == session.session_id else ""
1803
+ console.print(f" [bold cyan]{sid}[/bold cyan] {title} [dim]({msgs} msgs, {model}, {updated})[/dim]{is_current}")
1804
+ console.print(f"\n[dim]Resume with: aru --resume <id>[/dim]")
1805
+ continue
1806
+
1807
+ if user_input.lower() == "/commands":
1808
+ if not config.commands:
1809
+ console.print("[dim]No custom commands found. Add .md files to .agents/commands/[/dim]")
1810
+ else:
1811
+ console.print("[bold]Custom commands:[/bold]\n")
1812
+ for name, cmd_def in config.commands.items():
1813
+ console.print(f" [bold cyan]/{name}[/bold cyan] [dim]{cmd_def.description}[/dim]")
1814
+ console.print(f"\n[dim]Source: .agents/commands/[/dim]")
1815
+ continue
1816
+
1817
+ if user_input.lower() == "/skills":
1818
+ if not config.skills:
1819
+ console.print("[dim]No skills found. Add .md files to .agents/skills/[/dim]")
1820
+ else:
1821
+ console.print("[bold]Available skills:[/bold]\n")
1822
+ for name, skill in config.skills.items():
1823
+ console.print(f" [bold cyan]{name}[/bold cyan] [dim]{skill.description}[/dim]")
1824
+ console.print(f"\n[dim]Source: .agents/skills/[/dim]")
1825
+ console.print(f"\n[dim]Source: .agents/skills/[/dim]")
1826
+ continue
1827
+
1828
+ if user_input.lower() == "/mcp":
1829
+ from aru.tools.codebase import ALL_TOOLS
1830
+ from agno.tools import Function
1831
+ mcp_tools = [t for t in ALL_TOOLS if isinstance(t, Function) and getattr(t, "name", "").count("__") > 0]
1832
+ if not mcp_tools:
1833
+ console.print("[dim]No MCP tools loaded. Check aru.mcp.json config.[/dim]")
1834
+ else:
1835
+ console.print(f"[bold]Loaded MCP Tools ({len(mcp_tools)}):[/bold]\n")
1836
+ for t in mcp_tools:
1837
+ console.print(f" [bold cyan]{t.name}[/bold cyan] [dim]{t.description}[/dim]")
1838
+ continue
1839
+
1840
+ if user_input.lower() == "/help":
1841
+ _show_help(config)
1842
+ continue
1843
+
1844
+ if user_input.startswith("! "):
1845
+ cmd = user_input[2:].strip()
1846
+ if not cmd:
1847
+ console.print("[yellow]Usage: ! <command>[/yellow]")
1848
+ continue
1849
+ run_shell(cmd)
1850
+
1851
+ elif user_input.startswith("/plan "):
1852
+ task = user_input[6:].strip()
1853
+ if not task:
1854
+ console.print("[yellow]Usage: /plan <task description>[/yellow]")
1855
+ continue
1856
+
1857
+ console.print("[bold magenta]Planning...[/bold magenta]")
1858
+ if planner is None:
1859
+ planner = create_planner(session.model_ref, extra_instructions)
1860
+
1861
+ # No need to manually inject session context into prompt; run_agent_capture will do it.
1862
+ prompt = task
1863
+
1864
+ plan_result = await run_agent_capture(planner, prompt, session, lightweight=True)
1865
+ plan_content = plan_result.content
1866
+
1867
+ if plan_content and config and config.plan_reviewer:
1868
+ console.print("[dim]Reviewing scope...[/dim]")
1869
+ reviewed = await review_plan(task, plan_content)
1870
+ if reviewed != plan_content:
1871
+ plan_content = reviewed
1872
+ console.print(Markdown(plan_content))
1873
+
1874
+ if plan_content:
1875
+ session.set_plan(task, plan_content)
1876
+ session.add_message("user", f"/plan {task}")
1877
+ session.add_message("assistant", f"[Plan]\n{plan_content}")
1878
+
1879
+ # Show parsed steps
1880
+ if session.plan_steps:
1881
+ console.print(f"\n[bold]{len(session.plan_steps)} steps detected.[/bold]")
1882
+
1883
+ if get_skip_permissions() or ask_yes_no("Execute this plan?"):
1884
+ console.print("[bold green]Executing plan...[/bold green]")
1885
+
1886
+ # Use lightweight instructions for executor steps (skip README.md)
1887
+ light_instructions = config.get_extra_instructions(lightweight=True) if config else ""
1888
+
1889
+ def make_executor():
1890
+ return create_executor(session.model_ref, light_instructions)
1891
+
1892
+ result = await execute_plan_steps(session, make_executor)
1893
+ if result:
1894
+ session.add_message("assistant", f"[Execution]\n{result}")
1895
+
1896
+ session.clear_plan()
1897
+
1898
+ elif user_input.startswith("/") and not user_input.startswith("//"):
1899
+ # Check for custom commands from .agents/commands/
1900
+ parts = user_input[1:].split(None, 1)
1901
+ cmd_name = parts[0].lower()
1902
+ cmd_args = parts[1] if len(parts) > 1 else ""
1903
+
1904
+ if cmd_name in config.commands:
1905
+ cmd_def = config.commands[cmd_name]
1906
+ prompt = render_command_template(cmd_def.template, cmd_args)
1907
+ console.print(f"[bold magenta]Running /{cmd_name}...[/bold magenta]")
1908
+
1909
+ agent = create_general_agent(session, config)
1910
+ session.add_message("user", user_input)
1911
+ run_result = await run_agent_capture(agent, prompt, session)
1912
+ if run_result.content:
1913
+ session.add_message("assistant", run_result.with_tools_summary())
1914
+ else:
1915
+ console.print(f"[yellow]Unknown command: /{cmd_name}[/yellow]")
1916
+ console.print(f"[dim]Built-in: /plan, /model, /sessions, /commands, /skills, /quit[/dim]")
1917
+ if config.commands:
1918
+ console.print(f"[dim]Custom: {', '.join(f'/{k}' for k in config.commands)}[/dim]")
1919
+
1920
+ else:
1921
+ agent = create_general_agent(session, config)
1922
+ session.add_message("user", user_input)
1923
+ run_result = await run_agent_capture(agent, user_input, session)
1924
+ if run_result.content:
1925
+ session.add_message("assistant", run_result.with_tools_summary())
1926
+
1927
+ # Show token usage and auto-save
1928
+ if session.token_summary:
1929
+ console.print(f"[dim]{session.token_summary}[/dim]")
1930
+ store.save(session)
1931
+
1932
+
1933
+ def _list_sessions_and_exit():
1934
+ """Print saved sessions and exit."""
1935
+ store = SessionStore()
1936
+ sessions = store.list_sessions()
1937
+ if not sessions:
1938
+ console.print("[dim]No saved sessions.[/dim]")
1939
+ return
1940
+ console.print("[bold]Recent sessions:[/bold]\n")
1941
+ for s in sessions:
1942
+ sid = s["session_id"]
1943
+ title = s["title"][:50]
1944
+ msgs = s["messages"]
1945
+ updated = s["updated_at"]
1946
+ model = s["model"]
1947
+ console.print(f" [bold cyan]{sid}[/bold cyan] {title} [dim]({msgs} msgs, {model}, {updated})[/dim]")
1948
+ console.print(f"\n[dim]Resume with: aru --resume <id>[/dim]")
1949
+
1950
+
1951
+ def main():
1952
+ """Entry point for the aru CLI."""
1953
+ from dotenv import load_dotenv
1954
+
1955
+ load_dotenv()
1956
+ args = sys.argv[1:]
1957
+ skip_permissions = "--dangerously-skip-permissions" in args
1958
+
1959
+ # --list: show sessions and exit
1960
+ if "--list" in args:
1961
+ _list_sessions_and_exit()
1962
+ return
1963
+
1964
+ # --resume [id]: resume a session (or "last" if no id given)
1965
+ resume_id = None
1966
+ if "--resume" in args:
1967
+ idx = args.index("--resume")
1968
+ if idx + 1 < len(args) and not args[idx + 1].startswith("--"):
1969
+ resume_id = args[idx + 1]
1970
+ else:
1971
+ resume_id = "last"
1972
+
1973
+ try:
1974
+ asyncio.run(run_cli(skip_permissions=skip_permissions, resume_id=resume_id))
1975
+ except (KeyboardInterrupt, asyncio.CancelledError, SystemExit):
1976
+ _graceful_exit()
1977
+ except Exception as e:
1978
+ from rich.markup import escape
1979
+ console.print(f"\n[bold red]Fatal error: {escape(str(e))}[/bold red]")
1980
+ _graceful_exit()
1981
+
1982
+
1983
+ def _graceful_exit():
1984
+ """Save session and show resume hint on exit."""
1985
+ try:
1986
+ store = SessionStore()
1987
+ last = store.load_last()
1988
+ if last:
1989
+ console.print(f"\n[dim]Session saved: {last.session_id}[/dim]")
1990
+ console.print(f"[dim]Resume with:[/dim] [bold cyan]aru --resume {last.session_id}[/bold cyan]")
1991
+ except Exception:
1992
+ pass
1993
+ console.print("[dim]Bye![/dim]")