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.
- ata_coder/__init__.py +1 -0
- ata_coder/agent.py +874 -0
- ata_coder/agent_compact.py +190 -0
- ata_coder/agent_controller.py +218 -0
- ata_coder/agent_extension.py +69 -0
- ata_coder/agent_routing.py +105 -0
- ata_coder/agent_subsystems.py +72 -0
- ata_coder/agent_tools.py +318 -0
- ata_coder/agent_undo.py +63 -0
- ata_coder/anthropic_client.py +465 -0
- ata_coder/change_tracker.py +368 -0
- ata_coder/clawd_integration.py +574 -0
- ata_coder/commands/__init__.py +128 -0
- ata_coder/commands/_core.py +184 -0
- ata_coder/commands/_safety.py +95 -0
- ata_coder/commands/_settings.py +241 -0
- ata_coder/commands/_workflow.py +451 -0
- ata_coder/commands.py +974 -0
- ata_coder/config.py +257 -0
- ata_coder/core/__init__.py +35 -0
- ata_coder/core/events.py +73 -0
- ata_coder/core/queue.py +85 -0
- ata_coder/core/state.py +17 -0
- ata_coder/event_queue.py +5 -0
- ata_coder/extension.py +654 -0
- ata_coder/extensions/__init__.py +1 -0
- ata_coder/extensions/hello_skill.py +47 -0
- ata_coder/fool_proof.py +295 -0
- ata_coder/git_workflow.py +371 -0
- ata_coder/gui.py +511 -0
- ata_coder/llm_client.py +543 -0
- ata_coder/main.py +814 -0
- ata_coder/mcp_client.py +1095 -0
- ata_coder/memory.py +539 -0
- ata_coder/model_registry.py +134 -0
- ata_coder/model_router.py +105 -0
- ata_coder/permissions.py +274 -0
- ata_coder/privilege.py +464 -0
- ata_coder/project.py +273 -0
- ata_coder/prompt_template.py +423 -0
- ata_coder/prompts/auto-mode.md +7 -0
- ata_coder/prompts/coding-rules.md +40 -0
- ata_coder/prompts/execution-guardrails.md +14 -0
- ata_coder/prompts/memory-system.md +24 -0
- ata_coder/prompts/output-style.md +23 -0
- ata_coder/prompts/safety.md +17 -0
- ata_coder/prompts/slash-commands.md +24 -0
- ata_coder/prompts/sub-agents.md +38 -0
- ata_coder/prompts/system-reminders.md +17 -0
- ata_coder/prompts/system.md +105 -0
- ata_coder/prompts/tool-policy.md +46 -0
- ata_coder/repl_theme.py +99 -0
- ata_coder/repl_tracker.py +89 -0
- ata_coder/repl_ui.py +1214 -0
- ata_coder/safety_guard.py +434 -0
- ata_coder/self_correct.py +346 -0
- ata_coder/server.py +882 -0
- ata_coder/server_session.py +159 -0
- ata_coder/server_shell.py +129 -0
- ata_coder/session.py +431 -0
- ata_coder/settings.py +439 -0
- ata_coder/setup_wizard.py +136 -0
- ata_coder/skill_extension.py +92 -0
- ata_coder/skills/architect/SKILL.md +42 -0
- ata_coder/skills/code-reviewer/SKILL.md +37 -0
- ata_coder/skills/codecraft/SKILL.md +452 -0
- ata_coder/skills/debugger/SKILL.md +45 -0
- ata_coder/skills/doc-writer/SKILL.md +36 -0
- ata_coder/skills/general-coder/SKILL.md +76 -0
- ata_coder/skills/math-calculator/README.md +40 -0
- ata_coder/skills/math-calculator/SKILL.md +59 -0
- ata_coder/skills/math-calculator/handler.py +103 -0
- ata_coder/skills/math-calculator/prompts/system.md +8 -0
- ata_coder/skills/math-calculator/requirements.txt +2 -0
- ata_coder/skills/math-calculator/resources/constants.json +8 -0
- ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
- ata_coder/skills/security-auditor/SKILL.md +40 -0
- ata_coder/skills/test-writer/SKILL.md +36 -0
- ata_coder/skills/weather-skill/README.md +45 -0
- ata_coder/skills/weather-skill/handler.py +76 -0
- ata_coder/skills/weather-skill/manifest.json +48 -0
- ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
- ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
- ata_coder/skills/weather-skill/requirements.txt +1 -0
- ata_coder/skills/weather-skill/resources/city_list.json +17 -0
- ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
- ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
- ata_coder/skills/weather-skill/weather_utils.py +50 -0
- ata_coder/skills.py +1014 -0
- ata_coder/sub_agent.py +273 -0
- ata_coder/sub_agent_manager.py +203 -0
- ata_coder/system_prompt_builder.py +146 -0
- ata_coder/task_planner.py +391 -0
- ata_coder/terminal.py +318 -0
- ata_coder/test_runner.py +219 -0
- ata_coder/thread_supervisor.py +195 -0
- ata_coder/tool_defs.py +335 -0
- ata_coder/tools/__init__.py +11 -0
- ata_coder/tools/definitions.py +335 -0
- ata_coder/tools/executor.py +1036 -0
- ata_coder/tools/result.py +26 -0
- ata_coder/tools/subagent.py +332 -0
- ata_coder/tools/web.py +361 -0
- ata_coder/tools.py +1576 -0
- ata_coder/types.py +92 -0
- ata_coder/utils.py +113 -0
- ata_coder/web/css/style.css +180 -0
- ata_coder/web/index.html +84 -0
- ata_coder/web/js/app.js +489 -0
- ata_coder/web/package-lock.json +25 -0
- ata_coder/web/package.json +10 -0
- ata_coder/web/tsconfig.json +13 -0
- ata_coder-2.4.2.dist-info/METADATA +799 -0
- ata_coder-2.4.2.dist-info/RECORD +118 -0
- ata_coder-2.4.2.dist-info/WHEEL +5 -0
- ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
- ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
- 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)"
|