hanuscode 1.0.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.
- hanus/__init__.py +5 -0
- hanus/__main__.py +10 -0
- hanus/action_handlers.py +76 -0
- hanus/action_parser.py +82 -0
- hanus/agent_runner.py +1445 -0
- hanus/analysis/__init__.py +5 -0
- hanus/analysis/debt.py +702 -0
- hanus/analysis/dependencies.py +475 -0
- hanus/cache/__init__.py +5 -0
- hanus/cache/response_cache.py +560 -0
- hanus/config.py +401 -0
- hanus/connectors/__init__.py +19 -0
- hanus/connectors/base.py +114 -0
- hanus/connectors/claude_connector.py +146 -0
- hanus/connectors/gemini_connector.py +141 -0
- hanus/connectors/glm_connector.py +160 -0
- hanus/connectors/ollama_connector.py +174 -0
- hanus/connectors/openai_connector.py +122 -0
- hanus/connectors/registry.py +26 -0
- hanus/context/__init__.py +7 -0
- hanus/context/manager.py +837 -0
- hanus/context/selective.py +626 -0
- hanus/error_recovery/__init__.py +5 -0
- hanus/error_recovery/auto_fix.py +605 -0
- hanus/hooks/__init__.py +5 -0
- hanus/hooks/manager.py +247 -0
- hanus/instincts/__init__.py +44 -0
- hanus/instincts/cli.py +372 -0
- hanus/instincts/detector.py +281 -0
- hanus/instincts/evolver.py +361 -0
- hanus/instincts/manager.py +343 -0
- hanus/instincts/types.py +253 -0
- hanus/logger.py +81 -0
- hanus/memory/__init__.py +8 -0
- hanus/memory/manager.py +265 -0
- hanus/memory/types.py +119 -0
- hanus/monitor.py +341 -0
- hanus/parallel/__init__.py +5 -0
- hanus/parallel/executor.py +300 -0
- hanus/permissions.py +182 -0
- hanus/plan/__init__.py +8 -0
- hanus/plan/mode.py +267 -0
- hanus/plan/models.py +152 -0
- hanus/plugin_manager.py +754 -0
- hanus/plugin_registry.py +391 -0
- hanus/plugins/__init__.py +1 -0
- hanus/plugins/arena.py +630 -0
- hanus/plugins/code_review.py +123 -0
- hanus/plugins/cortex.py +1750 -0
- hanus/plugins/deps_check.py +27 -0
- hanus/plugins/git_ops.py +33 -0
- hanus/plugins/metasploit.py +530 -0
- hanus/plugins/notes.py +583 -0
- hanus/plugins/search_code.py +59 -0
- hanus/plugins/searchsploit.py +495 -0
- hanus/plugins/strategist.py +175 -0
- hanus/plugins/webui.py +5200 -0
- hanus/profiles.py +479 -0
- hanus/profiles_builtin/__init__.py +0 -0
- hanus/profiles_builtin/architect/profile.yaml +12 -0
- hanus/profiles_builtin/architect/system_prompt.txt +71 -0
- hanus/profiles_builtin/deep/profile.yaml +12 -0
- hanus/profiles_builtin/deep/system_prompt.txt +66 -0
- hanus/profiles_builtin/developer/__init__.py +0 -0
- hanus/profiles_builtin/developer/profile.yaml +9 -0
- hanus/profiles_builtin/developer/system_prompt.txt +176 -0
- hanus/profiles_builtin/speed/profile.yaml +12 -0
- hanus/profiles_builtin/speed/system_prompt.txt +51 -0
- hanus/project_tools.py +177 -0
- hanus/query_engine.py +1594 -0
- hanus/rules/__init__.py +237 -0
- hanus/search/__init__.py +5 -0
- hanus/search/semantic.py +596 -0
- hanus/session_manager.py +547 -0
- hanus/skill_manager.py +702 -0
- hanus/skills/__init__.py +4 -0
- hanus/subagent/__init__.py +8 -0
- hanus/subagent/agents/__init__.py +253 -0
- hanus/subagent/manager.py +309 -0
- hanus/subagent/types.py +266 -0
- hanus/suggestions/__init__.py +5 -0
- hanus/suggestions/proactive.py +451 -0
- hanus/tasks/__init__.py +8 -0
- hanus/tasks/manager.py +330 -0
- hanus/tasks/models.py +106 -0
- hanus/terminal_prompt.py +166 -0
- hanus/tools.py +1849 -0
- hanus/ui.py +939 -0
- hanuscode-1.0.0.dist-info/METADATA +1151 -0
- hanuscode-1.0.0.dist-info/RECORD +93 -0
- hanuscode-1.0.0.dist-info/WHEEL +5 -0
- hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
- hanuscode-1.0.0.dist-info/top_level.txt +1 -0
hanus/ui.py
ADDED
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
# hanus/ui.py — Professional Terminal Interface
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import sys
|
|
4
|
+
import textwrap as _tw
|
|
5
|
+
import time
|
|
6
|
+
import re
|
|
7
|
+
from typing import List, Dict, Optional, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from colorama import Fore, Style
|
|
11
|
+
except ImportError:
|
|
12
|
+
# Fallback for environments without colorama
|
|
13
|
+
class _FallbackFore:
|
|
14
|
+
CYAN = BLUE = GREEN = YELLOW = RED = MAGENTA = WHITE = ""
|
|
15
|
+
class _FallbackStyle:
|
|
16
|
+
RESET_ALL = DIM = BRIGHT = ""
|
|
17
|
+
BRIGHT = ""
|
|
18
|
+
Fore = _FallbackFore()
|
|
19
|
+
Style = _FallbackStyle()
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from hanus.config import HanusConfig
|
|
23
|
+
from hanus.plugin_manager import PluginManager
|
|
24
|
+
from hanus.session_manager import SessionData
|
|
25
|
+
from hanus.permissions import RiskLevel, PermissionDecision
|
|
26
|
+
|
|
27
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
28
|
+
# PROFESSIONAL COLOR PALETTE
|
|
29
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
30
|
+
|
|
31
|
+
class P:
|
|
32
|
+
R = Style.RESET_ALL
|
|
33
|
+
DIM = Style.DIM
|
|
34
|
+
BOLD = Style.BRIGHT
|
|
35
|
+
|
|
36
|
+
# Primary colors
|
|
37
|
+
CYAN = Fore.CYAN + Style.BRIGHT
|
|
38
|
+
BLUE = Fore.BLUE + Style.BRIGHT
|
|
39
|
+
GREEN = Fore.GREEN + Style.BRIGHT
|
|
40
|
+
YELLOW = Fore.YELLOW + Style.BRIGHT
|
|
41
|
+
RED = Fore.RED + Style.BRIGHT
|
|
42
|
+
MAGENTA = Fore.MAGENTA + Style.BRIGHT
|
|
43
|
+
WHITE = Fore.WHITE + Style.BRIGHT
|
|
44
|
+
|
|
45
|
+
# Semantic colors
|
|
46
|
+
HEADER = Fore.CYAN + Style.BRIGHT
|
|
47
|
+
ACCENT = Fore.BLUE + Style.BRIGHT
|
|
48
|
+
LABEL = Fore.WHITE + Style.BRIGHT
|
|
49
|
+
OK = Fore.GREEN + Style.BRIGHT
|
|
50
|
+
WARN = Fore.YELLOW + Style.BRIGHT
|
|
51
|
+
ERR = Fore.RED + Style.BRIGHT
|
|
52
|
+
MUTED = Fore.WHITE + Style.DIM
|
|
53
|
+
DIM_C = Fore.CYAN + Style.DIM
|
|
54
|
+
TOOL = Fore.CYAN + Style.BRIGHT
|
|
55
|
+
PLUG = Fore.MAGENTA + Style.BRIGHT
|
|
56
|
+
AGENT = Fore.BLUE + Style.BRIGHT
|
|
57
|
+
INPUT = Fore.WHITE + Style.BRIGHT
|
|
58
|
+
|
|
59
|
+
# Aliases for clarity
|
|
60
|
+
SUCCESS = Fore.GREEN + Style.BRIGHT
|
|
61
|
+
INFO = Fore.CYAN + Style.DIM
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
W = 88 # UI width
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
68
|
+
# ICONS
|
|
69
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
70
|
+
|
|
71
|
+
class Icon:
|
|
72
|
+
# Status
|
|
73
|
+
SUCCESS = "✓"
|
|
74
|
+
ERROR = "✗"
|
|
75
|
+
WARNING = "⚠"
|
|
76
|
+
INFO = "ℹ"
|
|
77
|
+
LOADING = "◐"
|
|
78
|
+
RUNNING = "▶"
|
|
79
|
+
DONE = "■"
|
|
80
|
+
|
|
81
|
+
# File operations
|
|
82
|
+
READ = "📖"
|
|
83
|
+
WRITE = "📝"
|
|
84
|
+
EDIT = "✏️"
|
|
85
|
+
CREATE = "📄"
|
|
86
|
+
DELETE = "🗑️"
|
|
87
|
+
|
|
88
|
+
# Execution
|
|
89
|
+
EXEC = "⚡"
|
|
90
|
+
CMD = "⟩"
|
|
91
|
+
GIT = "📦"
|
|
92
|
+
SEARCH = "🔍"
|
|
93
|
+
GLOB = "📁"
|
|
94
|
+
GREP = "🔎"
|
|
95
|
+
|
|
96
|
+
# Tasks
|
|
97
|
+
TASK_NEW = "⊕"
|
|
98
|
+
TASK_DONE = "☑"
|
|
99
|
+
TASK_TODO = "☐"
|
|
100
|
+
TASK_LIST = "☰"
|
|
101
|
+
|
|
102
|
+
# Memory
|
|
103
|
+
MEM_SAVE = "💾"
|
|
104
|
+
MEM_LOAD = "📂"
|
|
105
|
+
MEM_LIST = "📋"
|
|
106
|
+
|
|
107
|
+
# Web
|
|
108
|
+
WEB_FETCH = "🌐"
|
|
109
|
+
WEB_SEARCH = "🔎"
|
|
110
|
+
|
|
111
|
+
# Tools
|
|
112
|
+
TOOL = "🔧"
|
|
113
|
+
PLUGIN = "🔌"
|
|
114
|
+
BINARY = "⚙"
|
|
115
|
+
NOTEBOOK = "📓"
|
|
116
|
+
OUTPUT = "📊"
|
|
117
|
+
|
|
118
|
+
# UI elements
|
|
119
|
+
ARROW = "→"
|
|
120
|
+
BULLET = "•"
|
|
121
|
+
DIVIDER = "─"
|
|
122
|
+
CORNER = "┌┐└┘├┤"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
126
|
+
# HELPERS
|
|
127
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
128
|
+
|
|
129
|
+
def _sec(label: str, icon: str = "") -> str:
|
|
130
|
+
"""Create a section header."""
|
|
131
|
+
fill = max(0, W - len(label) - len(icon) - 6)
|
|
132
|
+
prefix = f"{icon} " if icon else ""
|
|
133
|
+
return f"── {prefix}{label} {'─' * fill}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _tool_label(name: str) -> str:
|
|
137
|
+
"""Get icon and label for a tool."""
|
|
138
|
+
mapping = {
|
|
139
|
+
"read_file": f"{Icon.READ} READ ",
|
|
140
|
+
"write_file": f"{Icon.WRITE} WRITE ",
|
|
141
|
+
"create_file": f"{Icon.CREATE} CREATE ",
|
|
142
|
+
"append_to_file": f"{Icon.WRITE} APPEND ",
|
|
143
|
+
"file_edit": f"{Icon.EDIT} EDIT ",
|
|
144
|
+
"edit_file": f"{Icon.EDIT} EDIT ",
|
|
145
|
+
"exec_cmd": f"{Icon.EXEC} EXEC ",
|
|
146
|
+
"exec_file": f"{Icon.RUNNING} RUN ",
|
|
147
|
+
"glob_search": f"{Icon.GLOB} GLOB ",
|
|
148
|
+
"grep_search": f"{Icon.GREP} GREP ",
|
|
149
|
+
"list_files": f"{Icon.GLOB} LIST ",
|
|
150
|
+
"git_status": f"{Icon.GIT} GIT ",
|
|
151
|
+
"git_diff": f"{Icon.GIT} DIFF ",
|
|
152
|
+
"git_log": f"{Icon.GIT} LOG ",
|
|
153
|
+
"git_commit": f"{Icon.GIT} COMMIT ",
|
|
154
|
+
"git_push": f"{Icon.GIT} PUSH ",
|
|
155
|
+
"git_reset": f"{Icon.GIT} RESET ",
|
|
156
|
+
"git_branch": f"{Icon.GIT} BRANCH ",
|
|
157
|
+
"web_fetch": f"{Icon.WEB_FETCH} FETCH ",
|
|
158
|
+
"web_search": f"{Icon.WEB_SEARCH} SEARCH ",
|
|
159
|
+
"structured_output":f"{Icon.OUTPUT} OUTPUT ",
|
|
160
|
+
"notebook_edit": f"{Icon.NOTEBOOK} NOTE ",
|
|
161
|
+
"binsmasher": f"{Icon.BINARY} BINARY ",
|
|
162
|
+
"task_create": f"{Icon.TASK_NEW} TASK+ ",
|
|
163
|
+
"task_update": f"{Icon.TASK_DONE} TASK✓ ",
|
|
164
|
+
"task_list": f"{Icon.TASK_LIST} TASKS ",
|
|
165
|
+
"task_get": f"{Icon.TASK_TODO} TASK? ",
|
|
166
|
+
"memory_save": f"{Icon.MEM_SAVE} MEM+ ",
|
|
167
|
+
"memory_search": f"{Icon.MEM_LOAD} MEM? ",
|
|
168
|
+
"memory_list": f"{Icon.MEM_LIST} MEMS ",
|
|
169
|
+
"run_plugin": f"{Icon.PLUGIN} PLUGIN ",
|
|
170
|
+
"ask_user": f"❓ ASK ",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# Handle plugin: prefix (e.g., "plugin:hackerone" -> "🔌 HACKERONE")
|
|
174
|
+
if name.startswith("plugin:"):
|
|
175
|
+
plugin_name = name[7:].upper() # Remove "plugin:" prefix
|
|
176
|
+
return f"{Icon.PLUGIN} {plugin_name:<8}"
|
|
177
|
+
|
|
178
|
+
return mapping.get(name, f"{Icon.TOOL} TOOL ")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _fmt_tool_desc(tool_name: str, args: Dict) -> str:
|
|
182
|
+
"""Generate a readable description of what the tool does."""
|
|
183
|
+
from pathlib import Path
|
|
184
|
+
|
|
185
|
+
# Handle plugin: prefix
|
|
186
|
+
if tool_name.startswith("plugin:"):
|
|
187
|
+
plugin_args = args.get("args", "")
|
|
188
|
+
if plugin_args:
|
|
189
|
+
return plugin_args[:60]
|
|
190
|
+
return ""
|
|
191
|
+
|
|
192
|
+
if tool_name in ("write_file", "create_file", "append_to_file"):
|
|
193
|
+
path = args.get("path", "?")
|
|
194
|
+
filename = Path(path).name if path else "?"
|
|
195
|
+
content = args.get("content", "")
|
|
196
|
+
ext = Path(path).suffix.lower() if path else ""
|
|
197
|
+
|
|
198
|
+
size = len(content)
|
|
199
|
+
lang_map = {
|
|
200
|
+
".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
|
|
201
|
+
".sh": "Shell", ".bash": "Bash", ".zsh": "Zsh",
|
|
202
|
+
".c": "C", ".cpp": "C++", ".h": "Header",
|
|
203
|
+
".md": "Markdown", ".rst": "reStructuredText",
|
|
204
|
+
".yaml": "YAML", ".yml": "YAML", ".json": "JSON", ".toml": "TOML",
|
|
205
|
+
".html": "HTML", ".css": "CSS", ".scss": "SCSS",
|
|
206
|
+
".sql": "SQL", ".go": "Go", ".rs": "Rust", ".rb": "Ruby",
|
|
207
|
+
}
|
|
208
|
+
lang = lang_map.get(ext, "File")
|
|
209
|
+
return f"{filename} ({size:,} chars) • {lang}"
|
|
210
|
+
|
|
211
|
+
if tool_name in ("edit_file", "file_edit"):
|
|
212
|
+
path = args.get("path", "?")
|
|
213
|
+
filename = Path(path).name if path else "?"
|
|
214
|
+
old = args.get("old_str", args.get("search", ""))
|
|
215
|
+
new = args.get("new_str", args.get("replace", ""))
|
|
216
|
+
old_preview = old[:30].replace("\n", " ") if old else "?"
|
|
217
|
+
new_preview = new[:30].replace("\n", " ") if new else "?"
|
|
218
|
+
if old and new:
|
|
219
|
+
return f"{filename} • '{old_preview}...' → '{new_preview}...'"
|
|
220
|
+
return f"{filename} • Editing"
|
|
221
|
+
|
|
222
|
+
if tool_name == "read_file":
|
|
223
|
+
path = args.get("path", "?")
|
|
224
|
+
filename = Path(path).name if path else "?"
|
|
225
|
+
start = args.get("start_line")
|
|
226
|
+
end = args.get("end_line")
|
|
227
|
+
if start and end:
|
|
228
|
+
return f"{filename} (lines {start}-{end})"
|
|
229
|
+
return filename
|
|
230
|
+
|
|
231
|
+
if tool_name == "exec_cmd":
|
|
232
|
+
cmd = args.get("cmd", "?")
|
|
233
|
+
if len(cmd) > 60:
|
|
234
|
+
cmd = cmd[:57] + "..."
|
|
235
|
+
return cmd
|
|
236
|
+
|
|
237
|
+
if tool_name in ("grep_search", "glob_search"):
|
|
238
|
+
pattern = args.get("pattern", "?")
|
|
239
|
+
directory = args.get("dir", ".")
|
|
240
|
+
return f"'{pattern}' in {directory}"
|
|
241
|
+
|
|
242
|
+
if tool_name == "binsmasher":
|
|
243
|
+
binary = args.get("binary", "?")
|
|
244
|
+
action = args.get("action", "analyze")
|
|
245
|
+
filename = Path(binary).name if binary else "?"
|
|
246
|
+
return f"{filename} • {action}"
|
|
247
|
+
|
|
248
|
+
if tool_name == "task_create":
|
|
249
|
+
subject = args.get("subject", "?")
|
|
250
|
+
return f"New: {subject[:50]}"
|
|
251
|
+
|
|
252
|
+
if tool_name == "task_update":
|
|
253
|
+
task_id = args.get("taskId", "?")
|
|
254
|
+
status = args.get("status", "?")
|
|
255
|
+
status_icon = {"completed": "✓", "in_progress": "○", "pending": "·"}.get(status, "?")
|
|
256
|
+
return f"Task {task_id} → {status_icon} {status}"
|
|
257
|
+
|
|
258
|
+
if tool_name == "run_plugin":
|
|
259
|
+
name = args.get("name", "?")
|
|
260
|
+
plugin_args = args.get("args", "")
|
|
261
|
+
if plugin_args:
|
|
262
|
+
return f"{name}: {plugin_args[:40]}"
|
|
263
|
+
return name
|
|
264
|
+
|
|
265
|
+
if tool_name.startswith("memory_"):
|
|
266
|
+
name = args.get("name", args.get("query", "?"))
|
|
267
|
+
return name[:50]
|
|
268
|
+
|
|
269
|
+
if tool_name == "ask_user":
|
|
270
|
+
question = args.get("question", "?")
|
|
271
|
+
options = args.get("options", [])
|
|
272
|
+
multi = args.get("multiSelect", False)
|
|
273
|
+
mode = "multi" if multi else "single"
|
|
274
|
+
num_opts = len(options)
|
|
275
|
+
q_preview = question[:30] + "..." if len(question) > 30 else question
|
|
276
|
+
return f"{q_preview} ({num_opts} opts, {mode})"
|
|
277
|
+
|
|
278
|
+
# Default: show main arguments
|
|
279
|
+
parts = []
|
|
280
|
+
for k, v in list(args.items())[:2]:
|
|
281
|
+
if k in ("content", "old_str", "new_str", "search", "replace"):
|
|
282
|
+
continue
|
|
283
|
+
s = str(v)
|
|
284
|
+
if len(s) > 40:
|
|
285
|
+
s = s[:37] + "..."
|
|
286
|
+
parts.append(f"{k}={s!r}")
|
|
287
|
+
return " ".join(parts) if parts else ""
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _strip_xml(text: str) -> str:
|
|
291
|
+
"""Remove XML tool blocks from text."""
|
|
292
|
+
BLOCK = (r"write_file|create_file|append_to_file|exec_cmd|exec_file|"
|
|
293
|
+
r"read_file|file_edit|add_note|read_note|list_note|run_plugin")
|
|
294
|
+
text = re.sub(rf'<({BLOCK})\b[^>]*>.*?</\1>', '', text, flags=re.DOTALL | re.I)
|
|
295
|
+
text = re.sub(rf'<({BLOCK})\b[^>]*/?>', '', text, flags=re.I)
|
|
296
|
+
return "\n".join(l for l in text.splitlines() if l.strip())
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
300
|
+
# UI CLASS
|
|
301
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
302
|
+
|
|
303
|
+
class UI:
|
|
304
|
+
|
|
305
|
+
def __init__(self):
|
|
306
|
+
self._streaming: bool = False
|
|
307
|
+
self._stream_line_len: int = 0
|
|
308
|
+
self._streaming_enabled: bool = False
|
|
309
|
+
self._multiline_mode: bool = False
|
|
310
|
+
self._processing: bool = False
|
|
311
|
+
self._processing_thread = None
|
|
312
|
+
|
|
313
|
+
def toggle_streaming(self) -> bool:
|
|
314
|
+
"""Toggle streaming mode. Returns new state."""
|
|
315
|
+
self._streaming_enabled = not self._streaming_enabled
|
|
316
|
+
return self._streaming_enabled
|
|
317
|
+
|
|
318
|
+
def is_streaming_enabled(self) -> bool:
|
|
319
|
+
"""Check if streaming is enabled."""
|
|
320
|
+
return self._streaming_enabled
|
|
321
|
+
|
|
322
|
+
def start_processing(self):
|
|
323
|
+
"""Start processing indicator (when streaming is OFF)."""
|
|
324
|
+
if self._streaming_enabled:
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
self._processing = True
|
|
328
|
+
self._show_processing_animation()
|
|
329
|
+
|
|
330
|
+
def _show_processing_animation(self):
|
|
331
|
+
"""Show processing animation - static to allow background typing."""
|
|
332
|
+
import threading
|
|
333
|
+
import time
|
|
334
|
+
|
|
335
|
+
def animate():
|
|
336
|
+
# Show a static message that doesn't clear the line
|
|
337
|
+
# This allows the user to type while agent works
|
|
338
|
+
print(f"\n{P.CYAN} ⏳ Working... {P.DIM}(type to queue input for next turn){P.R}\n")
|
|
339
|
+
# Just wait until processing is done
|
|
340
|
+
while self._processing:
|
|
341
|
+
time.sleep(0.2)
|
|
342
|
+
# Clear when done
|
|
343
|
+
print("\r" + " " * 60 + "\r", end="", flush=True)
|
|
344
|
+
|
|
345
|
+
self._processing_thread = threading.Thread(target=animate, daemon=True)
|
|
346
|
+
self._processing_thread.start()
|
|
347
|
+
|
|
348
|
+
def stop_processing(self):
|
|
349
|
+
"""Stop processing indicator."""
|
|
350
|
+
self._processing = False
|
|
351
|
+
if self._processing_thread:
|
|
352
|
+
self._processing_thread.join(timeout=0.5)
|
|
353
|
+
self._processing_thread = None
|
|
354
|
+
|
|
355
|
+
# ── Banner ─────────────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
def banner(self):
|
|
358
|
+
"""Display startup banner."""
|
|
359
|
+
print()
|
|
360
|
+
print(f"{P.HEADER}{'═' * W}{P.R}")
|
|
361
|
+
|
|
362
|
+
# Title
|
|
363
|
+
title = "HANUSCODE"
|
|
364
|
+
print(f"{P.HEADER}║{P.R} {P.LABEL}{title.center(W - 4)}{P.R} {P.HEADER}║{P.R}")
|
|
365
|
+
|
|
366
|
+
print(f"{P.HEADER}║{P.R} {P.MUTED}{'─' * (W - 4)}{P.R} {P.HEADER}║{P.R}")
|
|
367
|
+
print(f"{P.HEADER}║{P.R} {P.CYAN}{'Autonomous Programming Agent'.center(W - 4)}{P.R} {P.HEADER}║{P.R}")
|
|
368
|
+
print(f"{P.HEADER}{'═' * W}{P.R}")
|
|
369
|
+
print()
|
|
370
|
+
|
|
371
|
+
# ── Status ─────────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
def show_status(self, config: "HanusConfig", pm: "PluginManager",
|
|
374
|
+
session: Optional["SessionData"] = None):
|
|
375
|
+
"""Display current status."""
|
|
376
|
+
print(f"{P.HEADER}{'─' * 20} STATUS {'─' * (W - 29)}{P.R}")
|
|
377
|
+
|
|
378
|
+
# Model info with icon
|
|
379
|
+
model_icon = "🤖"
|
|
380
|
+
print(f" {P.ACCENT}{model_icon} Model:{P.R} {P.LABEL}{config.provider}{P.R}/{P.WHITE}{config.model_id}{P.R}")
|
|
381
|
+
|
|
382
|
+
# Permissions with icon
|
|
383
|
+
perm_icon = {"bypass": "🔓", "default": "🔒", "plan": "📋"}.get(config.permission_mode, "⚙")
|
|
384
|
+
print(f" {P.ACCENT}{perm_icon} Permissions:{P.R} {P.LABEL}{config.permission_mode.upper()}{P.R}")
|
|
385
|
+
|
|
386
|
+
# Directory with icon
|
|
387
|
+
print(f" {P.ACCENT}📁 Directory:{P.R} {P.MUTED}{str(config.root_dir)[:50]}{P.R}")
|
|
388
|
+
|
|
389
|
+
# Session info
|
|
390
|
+
if session:
|
|
391
|
+
cost = session.total_cost_usd
|
|
392
|
+
tokens = session.total_input_tokens + session.total_output_tokens
|
|
393
|
+
print(f" {P.ACCENT}💾 Session:{P.R} {P.LABEL}{session.name}{P.R}")
|
|
394
|
+
print(f" {P.ACCENT}💰 Cost:{P.R} {P.GREEN}${cost:.4f}{P.R} {P.MUTED}({tokens:,} tokens){P.R}")
|
|
395
|
+
print()
|
|
396
|
+
|
|
397
|
+
def show_commands(self, pm: "PluginManager", skill_mgr=None):
|
|
398
|
+
"""Display available commands."""
|
|
399
|
+
print(f"{P.HEADER}{'─' * 20} COMMANDS {'─' * (W - 31)}{P.R}")
|
|
400
|
+
print()
|
|
401
|
+
|
|
402
|
+
# Main commands
|
|
403
|
+
print(f" {P.LABEL}⚡ Main:{P.R}")
|
|
404
|
+
print(f" {P.INPUT}/profile{P.R} View/switch profile")
|
|
405
|
+
print(f" {P.INPUT}/model{P.R} Change model")
|
|
406
|
+
print(f" {P.INPUT}/stream{P.R} Toggle streaming")
|
|
407
|
+
print(f" {P.INPUT}/ask{P.R} Simple chat (no tools)")
|
|
408
|
+
print()
|
|
409
|
+
|
|
410
|
+
# Plugins
|
|
411
|
+
print(f" {P.LABEL}🔌 Plugins:{P.R}")
|
|
412
|
+
print(f" {P.INPUT}/plugins{P.R} List all plugins")
|
|
413
|
+
print(f" {P.INPUT}/plugins enable/disable/toggle <name>{P.R}")
|
|
414
|
+
print(f" {P.INPUT}/plugins reload{P.R}")
|
|
415
|
+
print()
|
|
416
|
+
|
|
417
|
+
# Tasks
|
|
418
|
+
print(f" {P.LABEL}📋 Tasks:{P.R}")
|
|
419
|
+
print(f" {P.INPUT}/tasks{P.R} List tasks")
|
|
420
|
+
print(f" {P.INPUT}/task_create{P.R} Create task")
|
|
421
|
+
print()
|
|
422
|
+
|
|
423
|
+
# Session
|
|
424
|
+
print(f" {P.LABEL}💾 Session:{P.R}")
|
|
425
|
+
print(f" {P.INPUT}/clear{P.R} Clear conversation")
|
|
426
|
+
print(f" {P.INPUT}/new{P.R} New session")
|
|
427
|
+
print(f" {P.INPUT}/save{P.R} Save session")
|
|
428
|
+
print(f" {P.INPUT}/sessions{P.R} List sessions")
|
|
429
|
+
print(f" {P.INPUT}/resume{P.R} Resume session")
|
|
430
|
+
print()
|
|
431
|
+
|
|
432
|
+
# Utilities
|
|
433
|
+
print(f" {P.LABEL}🔧 Utilities:{P.R}")
|
|
434
|
+
print(f" {P.INPUT}/files{P.R} List files")
|
|
435
|
+
print(f" {P.INPUT}/status{P.R} Show status")
|
|
436
|
+
print(f" {P.INPUT}/logs{P.R} View logs")
|
|
437
|
+
print(f" {P.INPUT}/budget{P.R} View budget")
|
|
438
|
+
print(f" {P.INPUT}/multiline{P.R} Multi-line input")
|
|
439
|
+
print()
|
|
440
|
+
|
|
441
|
+
# Skills
|
|
442
|
+
print(f" {P.LABEL}🎯 Skills:{P.R}")
|
|
443
|
+
print(f" {P.INPUT}/skill list{P.R} List skills")
|
|
444
|
+
print(f" {P.INPUT}/skill install{P.R} Install from URL")
|
|
445
|
+
print(f" {P.INPUT}/skill create{P.R} Create new")
|
|
446
|
+
print(f" {P.INPUT}/skill info{P.R} Show details")
|
|
447
|
+
print(f" {P.MUTED} Use /skillname to invoke directly{P.R}")
|
|
448
|
+
print()
|
|
449
|
+
|
|
450
|
+
# Monitor
|
|
451
|
+
print(f" {P.LABEL}📊 Monitor:{P.R}")
|
|
452
|
+
print(f" {P.INPUT}/monitor start{P.R} Start process")
|
|
453
|
+
print(f" {P.INPUT}/monitor list{P.R} List active")
|
|
454
|
+
print()
|
|
455
|
+
|
|
456
|
+
# Exit hint
|
|
457
|
+
print(f" {P.MUTED}Type 'exit' or 'q' to quit{P.R}")
|
|
458
|
+
|
|
459
|
+
# Plugins section
|
|
460
|
+
if pm.plugins:
|
|
461
|
+
print()
|
|
462
|
+
enabled_plugins = [n for n, p in pm.plugins.items() if p.enabled]
|
|
463
|
+
disabled_plugins = [n for n, p in pm.plugins.items() if not p.enabled]
|
|
464
|
+
|
|
465
|
+
if enabled_plugins:
|
|
466
|
+
plugins_list = ", ".join(f"{P.PLUG}{name}{P.R}" for name in enabled_plugins)
|
|
467
|
+
print(f" {P.PLUG}🔌 Enabled:{P.R} {plugins_list}")
|
|
468
|
+
|
|
469
|
+
if disabled_plugins:
|
|
470
|
+
plugins_list = ", ".join(f"{P.DIM}{name}{P.R}" for name in disabled_plugins)
|
|
471
|
+
print(f" {P.DIM}⊘ Disabled:{P.R} {plugins_list}")
|
|
472
|
+
|
|
473
|
+
# Skills section
|
|
474
|
+
if skill_mgr and skill_mgr.skills:
|
|
475
|
+
skills = skill_mgr.list_skills()
|
|
476
|
+
shown = sorted(skills, key=lambda s: s.name)[:8]
|
|
477
|
+
skill_names = [f"/{s.name}" for s in shown]
|
|
478
|
+
more = f" {P.MUTED}(+{len(skills)-8} more){P.R}" if len(skills) > 8 else ""
|
|
479
|
+
skills_str = ", ".join(f"{P.CYAN}{n}{P.R}" for n in skill_names)
|
|
480
|
+
print(f" {P.CYAN}🎯 Skills:{P.R} {skills_str}{more}")
|
|
481
|
+
print()
|
|
482
|
+
|
|
483
|
+
def show_profile(self, profile, all_profiles=None):
|
|
484
|
+
"""Display profile information."""
|
|
485
|
+
from hanus.profiles import ProfileMeta
|
|
486
|
+
|
|
487
|
+
if all_profiles is not None:
|
|
488
|
+
# List all profiles
|
|
489
|
+
print(f"{P.HEADER}{'─' * 20} PROFILES {'─' * (W - 30)}{P.R}")
|
|
490
|
+
if not all_profiles:
|
|
491
|
+
print(f" {P.MUTED}No profiles found.{P.R}")
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
active_name = profile.name if profile else ""
|
|
495
|
+
for meta in all_profiles:
|
|
496
|
+
marker = f"{P.OK}●{P.R}" if meta.name == active_name else f"{P.DIM}○{P.R}"
|
|
497
|
+
print(f" {marker} {P.LABEL}{meta.name}{P.R} {P.MUTED}— {meta.display}{P.R}")
|
|
498
|
+
if meta.tags:
|
|
499
|
+
tags = " ".join(f"[{t}]" for t in meta.tags[:4])
|
|
500
|
+
print(f" {P.DIM}{tags}{P.R}")
|
|
501
|
+
print()
|
|
502
|
+
print(f" {P.MUTED}Use '/profile <name>' to switch.{P.R}")
|
|
503
|
+
print(f" {P.MUTED}Use '/profile new <name>' to create.{P.R}")
|
|
504
|
+
print()
|
|
505
|
+
return
|
|
506
|
+
|
|
507
|
+
if not profile:
|
|
508
|
+
print(f" {P.ERR}No active profile.{P.R}")
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
m = profile.meta
|
|
512
|
+
print()
|
|
513
|
+
print(f"{P.HEADER}{'─' * 20} PROFILE: {m.display} {'─' * (W - 32 - len(m.display))}{P.R}")
|
|
514
|
+
print(f" {P.ACCENT}📛 Name:{P.R} {m.name}")
|
|
515
|
+
print(f" {P.ACCENT}📝 Description:{P.R} {m.description}")
|
|
516
|
+
if m.tags:
|
|
517
|
+
print(f" {P.ACCENT}🏷️ Tags:{P.R} {', '.join(m.tags)}")
|
|
518
|
+
if m.provider:
|
|
519
|
+
prov_str = f"{m.provider}" + (f"/{m.model_id}" if m.model_id else "")
|
|
520
|
+
print(f" {P.ACCENT}🤖 Model:{P.R} {prov_str}")
|
|
521
|
+
if m.permission_mode:
|
|
522
|
+
print(f" {P.ACCENT}🔒 Permissions:{P.R} {m.permission_mode}")
|
|
523
|
+
print()
|
|
524
|
+
prompt_preview = profile.system_prompt.strip().replace("\n", " ")[:100]
|
|
525
|
+
print(f" {P.DIM}Prompt:{P.R} {P.MUTED}{prompt_preview}...{P.R}")
|
|
526
|
+
print(f" {P.DIM}File:{P.R} ~/.hanus/profiles/{m.name}/system_prompt.txt")
|
|
527
|
+
print()
|
|
528
|
+
|
|
529
|
+
def show_streaming_status(self):
|
|
530
|
+
"""Display streaming status."""
|
|
531
|
+
status = f"{P.OK}ON{P.R}" if self._streaming_enabled else f"{P.ERR}OFF{P.R}"
|
|
532
|
+
print(f" Streaming: {status}")
|
|
533
|
+
print(f" {P.MUTED}Use /stream to toggle{P.R}")
|
|
534
|
+
|
|
535
|
+
# ── Agent Response (streaming) ─────────────────────────────────────────────
|
|
536
|
+
|
|
537
|
+
def stream_start(self):
|
|
538
|
+
"""Start streaming mode."""
|
|
539
|
+
if not self._streaming_enabled:
|
|
540
|
+
return
|
|
541
|
+
self._streaming = True
|
|
542
|
+
self._stream_line_len = 0
|
|
543
|
+
print()
|
|
544
|
+
print(f"{P.AGENT}┌{'─' * W}┐{P.R}")
|
|
545
|
+
print(f"{P.AGENT}│{P.R}")
|
|
546
|
+
print(f"{P.AGENT}├{'─' * W}┤{P.R}")
|
|
547
|
+
|
|
548
|
+
def stream_token(self, token: str):
|
|
549
|
+
"""Display streaming token."""
|
|
550
|
+
if not self._streaming_enabled:
|
|
551
|
+
return
|
|
552
|
+
if not self._streaming:
|
|
553
|
+
self.stream_start()
|
|
554
|
+
print(token, end="", flush=True)
|
|
555
|
+
|
|
556
|
+
def stream_end(self):
|
|
557
|
+
"""End streaming mode."""
|
|
558
|
+
if self._streaming:
|
|
559
|
+
print()
|
|
560
|
+
print(f"{P.AGENT}└{'─' * W}┘{P.R}")
|
|
561
|
+
print()
|
|
562
|
+
self._streaming = False
|
|
563
|
+
self._stream_line_len = 0
|
|
564
|
+
|
|
565
|
+
def thinking(self, text: str):
|
|
566
|
+
"""Display thinking/explanation text before tool execution."""
|
|
567
|
+
# Clean the text from XML tags
|
|
568
|
+
clean = _strip_xml(text)
|
|
569
|
+
if not clean.strip():
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
print()
|
|
573
|
+
print(f"{P.CYAN}💭 Thinking...{P.R}")
|
|
574
|
+
print(f"{P.DIM}{'─' * 40}{P.R}")
|
|
575
|
+
|
|
576
|
+
# Show the cleaned text, wrapped
|
|
577
|
+
for line in (clean.strip().splitlines() or [""]):
|
|
578
|
+
if line.strip():
|
|
579
|
+
wrapped = _tw.wrap(line, W - 4)
|
|
580
|
+
for wl in wrapped:
|
|
581
|
+
print(f" {P.MUTED}{wl}{P.R}")
|
|
582
|
+
else:
|
|
583
|
+
print()
|
|
584
|
+
|
|
585
|
+
def agent_response(self, text: str):
|
|
586
|
+
"""Display complete response when streaming is off."""
|
|
587
|
+
print()
|
|
588
|
+
print(f"{P.AGENT}┌{'─' * W}┐{P.R}")
|
|
589
|
+
print(f"{P.AGENT}│{P.R}")
|
|
590
|
+
print(f"{P.AGENT}├{'─' * W}┤{P.R}")
|
|
591
|
+
clean = _strip_xml(text)
|
|
592
|
+
for line in (clean.strip().splitlines() or [""]):
|
|
593
|
+
wrapped = _tw.wrap(line, W - 4) if line.strip() else [""]
|
|
594
|
+
for wl in wrapped:
|
|
595
|
+
print(f" {wl}")
|
|
596
|
+
print(f"{P.AGENT}└{'─' * W}┘{P.R}")
|
|
597
|
+
print()
|
|
598
|
+
|
|
599
|
+
# ── Cost Bar ───────────────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
def show_cost_bar(self, cost: float, inp: int, out: int,
|
|
602
|
+
session_cost: float, budget: float):
|
|
603
|
+
"""Display cost information bar."""
|
|
604
|
+
print()
|
|
605
|
+
cost_color = P.OK
|
|
606
|
+
if budget > 0:
|
|
607
|
+
pct = session_cost / budget
|
|
608
|
+
cost_color = P.WARN if pct > 0.5 else P.OK
|
|
609
|
+
if pct > 0.8:
|
|
610
|
+
cost_color = P.ERR
|
|
611
|
+
|
|
612
|
+
print(f" {P.DIM}┌{'─' * 40}┐{P.R}")
|
|
613
|
+
print(f" {P.DIM}│{P.R} {P.ACCENT}📊 Tokens:{P.R} ↑{inp:,} ↓{out:,}")
|
|
614
|
+
print(f" {P.DIM}│{P.R} {P.ACCENT}💰 Cost:{P.R} ${cost:.5f} (session: {cost_color}${session_cost:.4f}{P.R})")
|
|
615
|
+
if budget > 0:
|
|
616
|
+
remaining = budget - session_cost
|
|
617
|
+
print(f" {P.DIM}│{P.R} {P.ACCENT}💵 Budget:{P.R} ${budget:.2f} (${remaining:.2f} left)")
|
|
618
|
+
print(f" {P.DIM}└{'─' * 40}┘{P.R}")
|
|
619
|
+
|
|
620
|
+
# ── Tools ─────────────────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
def tool_start(self, name: str, args: Dict):
|
|
623
|
+
"""Display tool execution start."""
|
|
624
|
+
label = _tool_label(name)
|
|
625
|
+
desc = _fmt_tool_desc(name, args)
|
|
626
|
+
print()
|
|
627
|
+
print(f" {P.TOOL}▶ {label}{P.R} {P.MUTED}{desc}{P.R}")
|
|
628
|
+
|
|
629
|
+
def tool_end(self, name: str, result: str, success: bool):
|
|
630
|
+
"""Display tool execution end."""
|
|
631
|
+
lines = result.strip().splitlines()
|
|
632
|
+
|
|
633
|
+
if len(lines) > 5:
|
|
634
|
+
lines = lines[:5]
|
|
635
|
+
lines.append(f"{P.MUTED}... ({len(result.strip().splitlines()) - 5} more lines){P.R}")
|
|
636
|
+
|
|
637
|
+
if success:
|
|
638
|
+
print(f" {P.OK}✓{P.R} {lines[0][:70]}")
|
|
639
|
+
for l in lines[1:5]:
|
|
640
|
+
print(f" {P.MUTED}{l[:75]}{P.R}")
|
|
641
|
+
else:
|
|
642
|
+
print(f" {P.ERR}✗{P.R} {lines[0][:70]}")
|
|
643
|
+
for l in lines[1:5]:
|
|
644
|
+
print(f" {P.ERR}{l[:75]}{P.R}")
|
|
645
|
+
|
|
646
|
+
def plugin_result(self, name: str, result: str):
|
|
647
|
+
"""Display plugin result."""
|
|
648
|
+
print()
|
|
649
|
+
print(f" {P.PLUG}🔌 PLUGIN: {name}{P.R}")
|
|
650
|
+
print(f" {P.DIM}{'─' * 40}{P.R}")
|
|
651
|
+
|
|
652
|
+
# No truncation - show full output for plugin results
|
|
653
|
+
for line in result.strip().splitlines():
|
|
654
|
+
print(f" {line}")
|
|
655
|
+
print()
|
|
656
|
+
|
|
657
|
+
# ── Permission Dialog ─────────────────────────────────────────────────────
|
|
658
|
+
|
|
659
|
+
def ask_permission(self, tool: str, description: str, risk, args: Dict) -> "PermissionDecision":
|
|
660
|
+
"""Ask user for permission."""
|
|
661
|
+
from hanus.permissions import PermissionDecision, RiskLevel
|
|
662
|
+
|
|
663
|
+
rc = {RiskLevel.LOW: P.OK, RiskLevel.MEDIUM: P.WARN, RiskLevel.HIGH: P.ERR}.get(risk, P.WARN)
|
|
664
|
+
rn = {RiskLevel.LOW: "LOW", RiskLevel.MEDIUM: "MEDIUM", RiskLevel.HIGH: "HIGH"}.get(risk, "?")
|
|
665
|
+
W2 = 54
|
|
666
|
+
|
|
667
|
+
print(f"\n {P.WARN}┌{'─' * W2}┐{P.R}")
|
|
668
|
+
print(f" {P.WARN}│{P.R} ⚠️ PERMISSION REQUIRED{' ' * (W2 - 23)}{P.WARN}│{P.R}")
|
|
669
|
+
print(f" {P.WARN}├{'─' * W2}┤{P.R}")
|
|
670
|
+
print(f" {P.WARN}│{P.R} 🔧 Tool {P.LABEL}{tool:<(W2 - 15)}{P.R}{P.WARN}│{P.R}")
|
|
671
|
+
print(f" {P.WARN}│{P.R} ⚡ Risk {rc}{rn:<(W2 - 15)}{P.R}{P.WARN}│{P.R}")
|
|
672
|
+
desc_s = description[:(W2 - 15)]
|
|
673
|
+
print(f" {P.WARN}│{P.R} 📝 Action {P.MUTED}{desc_s:<(W2 - 15)}{P.R}{P.WARN}│{P.R}")
|
|
674
|
+
print(f" {P.WARN}├{'─' * W2}┤{P.R}")
|
|
675
|
+
opts = "[y] Yes [n] No [Y] Always"
|
|
676
|
+
print(f" {P.WARN}│{P.R} {opts}{' ' * (W2 - len(opts) - 2)}{P.WARN}│{P.R}")
|
|
677
|
+
print(f" {P.WARN}└{'─' * W2}┘{P.R}")
|
|
678
|
+
|
|
679
|
+
try:
|
|
680
|
+
ans = input(f" Choice [y/n/Y]: ").strip()
|
|
681
|
+
except (EOFError, KeyboardInterrupt):
|
|
682
|
+
ans = "n"
|
|
683
|
+
|
|
684
|
+
if ans == "Y":
|
|
685
|
+
return PermissionDecision(True, "Approved always in session", remember=True)
|
|
686
|
+
if ans.lower() == "y":
|
|
687
|
+
return PermissionDecision(True, "Approved by user")
|
|
688
|
+
return PermissionDecision(False, "Rejected by user")
|
|
689
|
+
|
|
690
|
+
# ── Ask User Question (Interactive) ───────────────────────────────────────
|
|
691
|
+
|
|
692
|
+
def ask_user_question(self, question: str, header: str, options: List[Dict], multi_select: bool) -> Dict:
|
|
693
|
+
"""
|
|
694
|
+
Muestra una pregunta interactiva con opciones seleccionables.
|
|
695
|
+
|
|
696
|
+
Args:
|
|
697
|
+
question: La pregunta completa
|
|
698
|
+
header: Etiqueta corta (opcional)
|
|
699
|
+
options: Lista de dicts con 'label' y 'description'
|
|
700
|
+
multi_select: Si permitir múltiples selecciones
|
|
701
|
+
|
|
702
|
+
Returns:
|
|
703
|
+
Dict con 'selected' (lista), 'custom_input' (str), 'cancelled' (bool)
|
|
704
|
+
"""
|
|
705
|
+
W2 = min(80, max(60, len(question) + 10))
|
|
706
|
+
|
|
707
|
+
print()
|
|
708
|
+
print(f" {P.CYAN}┌{'─' * W2}┐{P.R}")
|
|
709
|
+
|
|
710
|
+
# Header
|
|
711
|
+
if header:
|
|
712
|
+
header_pad = (W2 - len(header) - 4) // 2
|
|
713
|
+
print(f" {P.CYAN}│{P.R} {P.LABEL}{' ' * header_pad}[{header}]{P.R}{' ' * (W2 - header_pad - len(header) - 6)} {P.CYAN}│{P.R}")
|
|
714
|
+
print(f" {P.CYAN}├{'─' * W2}┤{P.R}")
|
|
715
|
+
|
|
716
|
+
# Question (wrapped)
|
|
717
|
+
print(f" {P.CYAN}│{P.R}")
|
|
718
|
+
wrapped = _tw.wrap(question, W2 - 4)
|
|
719
|
+
for line in wrapped:
|
|
720
|
+
print(f" {P.CYAN}│{P.R} {P.WHITE}{line}{P.R}")
|
|
721
|
+
print(f" {P.CYAN}│{P.R}")
|
|
722
|
+
print(f" {P.CYAN}├{'─' * W2}┤{P.R}")
|
|
723
|
+
|
|
724
|
+
# Options
|
|
725
|
+
for i, opt in enumerate(options, 1):
|
|
726
|
+
label = opt.get("label", f"Option {i}")
|
|
727
|
+
desc = opt.get("description", "")
|
|
728
|
+
|
|
729
|
+
# Marca para multi-select
|
|
730
|
+
checkbox = "☐" if multi_select else "○"
|
|
731
|
+
print(f" {P.CYAN}│{P.R} {P.ACCENT}{checkbox} {i}.{P.R} {P.LABEL}{label}{P.R}")
|
|
732
|
+
if desc:
|
|
733
|
+
desc_wrap = _tw.wrap(desc, W2 - 10)
|
|
734
|
+
for dline in desc_wrap:
|
|
735
|
+
print(f" {P.CYAN}│{P.R} {P.MUTED}{dline}{P.R}")
|
|
736
|
+
|
|
737
|
+
# Other option
|
|
738
|
+
other_num = len(options) + 1
|
|
739
|
+
print(f" {P.CYAN}│{P.R}")
|
|
740
|
+
print(f" {P.CYAN}│{P.R} {P.DIM}○ {other_num}. Other (custom input){P.R}")
|
|
741
|
+
|
|
742
|
+
# Instructions
|
|
743
|
+
print(f" {P.CYAN}│{P.R}")
|
|
744
|
+
if multi_select:
|
|
745
|
+
print(f" {P.CYAN}│{P.R} {P.DIM}Enter numbers separated by comma (e.g., '1,3') or '{other_num}' for custom{P.R}")
|
|
746
|
+
else:
|
|
747
|
+
print(f" {P.CYAN}│{P.R} {P.DIM}Enter a number (1-{other_num}) or type your answer{P.R}")
|
|
748
|
+
print(f" {P.CYAN}└{'─' * W2}┘{P.R}")
|
|
749
|
+
|
|
750
|
+
# Get input
|
|
751
|
+
try:
|
|
752
|
+
ans = input(f" {P.INPUT}Your answer: {P.R}").strip()
|
|
753
|
+
except (EOFError, KeyboardInterrupt):
|
|
754
|
+
print()
|
|
755
|
+
return {"selected": [], "custom_input": "", "cancelled": True}
|
|
756
|
+
|
|
757
|
+
# Parse input
|
|
758
|
+
if not ans:
|
|
759
|
+
return {"selected": [], "custom_input": "", "cancelled": True}
|
|
760
|
+
|
|
761
|
+
# Check if it's a number selection
|
|
762
|
+
try:
|
|
763
|
+
if multi_select:
|
|
764
|
+
# Parse comma-separated numbers
|
|
765
|
+
nums = [int(x.strip()) for x in ans.split(",")]
|
|
766
|
+
selected = []
|
|
767
|
+
for n in nums:
|
|
768
|
+
if 1 <= n <= len(options):
|
|
769
|
+
selected.append(options[n-1]["label"])
|
|
770
|
+
elif n == other_num:
|
|
771
|
+
# Ask for custom input
|
|
772
|
+
try:
|
|
773
|
+
custom = input(f" {P.INPUT}Enter your input: {P.R}").strip()
|
|
774
|
+
except (EOFError, KeyboardInterrupt):
|
|
775
|
+
custom = ""
|
|
776
|
+
return {"selected": [], "custom_input": custom, "cancelled": False}
|
|
777
|
+
if selected:
|
|
778
|
+
return {"selected": selected, "custom_input": "", "cancelled": False}
|
|
779
|
+
else:
|
|
780
|
+
# Single selection
|
|
781
|
+
n = int(ans)
|
|
782
|
+
if 1 <= n <= len(options):
|
|
783
|
+
return {"selected": [options[n-1]["label"]], "custom_input": "", "cancelled": False}
|
|
784
|
+
elif n == other_num:
|
|
785
|
+
try:
|
|
786
|
+
custom = input(f" {P.INPUT}Enter your input: {P.R}").strip()
|
|
787
|
+
except (EOFError, KeyboardInterrupt):
|
|
788
|
+
custom = ""
|
|
789
|
+
return {"selected": [], "custom_input": custom, "cancelled": False}
|
|
790
|
+
except ValueError:
|
|
791
|
+
pass
|
|
792
|
+
|
|
793
|
+
# Non-numeric input - treat as custom input
|
|
794
|
+
return {"selected": [], "custom_input": ans, "cancelled": False}
|
|
795
|
+
|
|
796
|
+
# ── Listings ──────────────────────────────────────────────────────────────
|
|
797
|
+
|
|
798
|
+
def show_files(self, files: list):
|
|
799
|
+
"""Display file list."""
|
|
800
|
+
print(f"\n{P.HEADER}{_sec(f'FILES ({len(files)})', Icon.GLOB)}{P.R}")
|
|
801
|
+
for f in files[:100]:
|
|
802
|
+
print(f" {P.MUTED}{Icon.BULLET}{P.R} {f}")
|
|
803
|
+
if len(files) > 100:
|
|
804
|
+
print(f" {P.MUTED}... and {len(files) - 100} more{P.R}")
|
|
805
|
+
print()
|
|
806
|
+
|
|
807
|
+
def show_plugins(self, pm: "PluginManager"):
|
|
808
|
+
"""Display plugin list with status."""
|
|
809
|
+
print(f"\n{P.HEADER}{_sec('PLUGINS', Icon.PLUGIN)}{P.R}")
|
|
810
|
+
if not pm.plugins:
|
|
811
|
+
print(f" {P.MUTED}No plugins in '{pm.plugins_dir}'{P.R}")
|
|
812
|
+
print()
|
|
813
|
+
|
|
814
|
+
# Separate enabled and disabled
|
|
815
|
+
enabled = [(n, p) for n, p in pm.plugins.items() if p.enabled]
|
|
816
|
+
disabled = [(n, p) for n, p in pm.plugins.items() if not p.enabled]
|
|
817
|
+
|
|
818
|
+
if enabled:
|
|
819
|
+
print(f" {P.OK}✓ Enabled:{P.R}")
|
|
820
|
+
for name, p in sorted(enabled):
|
|
821
|
+
print(f" {P.PLUG}{Icon.PLUGIN} {name:<20}{P.R} {P.MUTED}{p.description}{P.R}")
|
|
822
|
+
if p.usage:
|
|
823
|
+
print(f" {P.DIM}Usage: {p.usage}{P.R}")
|
|
824
|
+
|
|
825
|
+
if disabled:
|
|
826
|
+
print(f"\n {P.ERR}✗ Disabled:{P.R}")
|
|
827
|
+
for name, p in sorted(disabled):
|
|
828
|
+
print(f" {P.DIM}{Icon.PLUGIN} {name:<20} {p.description}{P.R}")
|
|
829
|
+
|
|
830
|
+
print(f"\n {P.DIM}Use '/plugins enable/disable <name>' to manage{P.R}")
|
|
831
|
+
print()
|
|
832
|
+
|
|
833
|
+
def show_history(self, messages: List[Dict]):
|
|
834
|
+
"""Display message history."""
|
|
835
|
+
print(f"\n{P.HEADER}{_sec(f'HISTORY ({len(messages)} messages)', '📜')}{P.R}")
|
|
836
|
+
for i, m in enumerate(messages):
|
|
837
|
+
color = {
|
|
838
|
+
"system": P.MUTED,
|
|
839
|
+
"user": P.ACCENT,
|
|
840
|
+
"assistant": P.AGENT,
|
|
841
|
+
}.get(m["role"], P.LABEL)
|
|
842
|
+
content = str(m.get("content", ""))
|
|
843
|
+
preview = content.replace("\n", " ")[:90]
|
|
844
|
+
if len(content) > 90:
|
|
845
|
+
preview += "..."
|
|
846
|
+
print(f" {color}[{i:02d}] {m['role']:<11}{P.R} {P.MUTED}{preview}{P.R}")
|
|
847
|
+
print()
|
|
848
|
+
|
|
849
|
+
def show_sessions(self, sessions: List[Dict]):
|
|
850
|
+
"""Display saved sessions."""
|
|
851
|
+
print(f"\n{P.HEADER}{_sec('SAVED SESSIONS', '💾')}{P.R}")
|
|
852
|
+
if not sessions:
|
|
853
|
+
print(f" {P.MUTED}No saved sessions.{P.R}")
|
|
854
|
+
for s in sessions:
|
|
855
|
+
print(f" {P.LABEL}{s['id']}{P.R}")
|
|
856
|
+
print(f" {P.MUTED}{s['name']} │ {s['model']} │ ${s['cost']:.4f} │ {s['messages']} msgs │ {s['updated']}{P.R}")
|
|
857
|
+
print()
|
|
858
|
+
|
|
859
|
+
def show_audit(self, log: List[Dict]):
|
|
860
|
+
"""Display permission audit log."""
|
|
861
|
+
print(f"\n{P.HEADER}{_sec('PERMISSION LOG', '📋')}{P.R}")
|
|
862
|
+
if not log:
|
|
863
|
+
print(f" {P.MUTED}No entries.{P.R}")
|
|
864
|
+
for e in log[-25:]:
|
|
865
|
+
icon = f"{P.OK}✓{P.R}" if e["approved"] else f"{P.ERR}✗{P.R}"
|
|
866
|
+
ts = time.strftime("%H:%M:%S", time.localtime(e["ts"]))
|
|
867
|
+
print(f" {icon} {ts} {e['tool']:<22} {P.MUTED}{e['reason']}{P.R}")
|
|
868
|
+
print()
|
|
869
|
+
|
|
870
|
+
def show_stats(self, session: Optional["SessionData"]):
|
|
871
|
+
"""Display session statistics."""
|
|
872
|
+
if not session:
|
|
873
|
+
self.info("No active session.")
|
|
874
|
+
return
|
|
875
|
+
print(f"\n{P.HEADER}{_sec('STATISTICS', '📊')}{P.R}")
|
|
876
|
+
rows = [
|
|
877
|
+
("📁 Session", session.name),
|
|
878
|
+
("🤖 Model", f"{session.model_provider}/{session.model_id}"),
|
|
879
|
+
("↑ Input tokens", f"{session.total_input_tokens:,}"),
|
|
880
|
+
("↓ Output tokens", f"{session.total_output_tokens:,}"),
|
|
881
|
+
("💰 Total cost", f"${session.total_cost_usd:.4f} USD"),
|
|
882
|
+
("💬 Messages", str(len(session.messages))),
|
|
883
|
+
("📝 Files modified", str(len(session.files_modified))),
|
|
884
|
+
("⚡ Commands", str(len(session.commands_executed))),
|
|
885
|
+
]
|
|
886
|
+
for label, val in rows:
|
|
887
|
+
print(f" {P.ACCENT}{label:<20}{P.R} {val}")
|
|
888
|
+
for f in session.files_modified[:10]:
|
|
889
|
+
print(f" {P.MUTED}{Icon.BULLET} {f}{P.R}")
|
|
890
|
+
print()
|
|
891
|
+
|
|
892
|
+
def show_models(self, connector):
|
|
893
|
+
"""Display available models."""
|
|
894
|
+
print(f"\n{P.HEADER}{_sec(f'MODELS — {connector.provider_name}', '🤖')}{P.R}")
|
|
895
|
+
for m in connector.available_models:
|
|
896
|
+
if m == connector.model_id:
|
|
897
|
+
print(f" {P.OK}►{P.R} {P.LABEL}{m}{P.R} {P.MUTED}← active{P.R}")
|
|
898
|
+
else:
|
|
899
|
+
print(f" {m}")
|
|
900
|
+
print()
|
|
901
|
+
|
|
902
|
+
# ── Messages ──────────────────────────────────────────────────────────────
|
|
903
|
+
|
|
904
|
+
def success(self, msg: str):
|
|
905
|
+
"""Display success message."""
|
|
906
|
+
print(f" {P.OK}{Icon.SUCCESS}{P.R} {msg}")
|
|
907
|
+
|
|
908
|
+
def error(self, msg: str):
|
|
909
|
+
"""Display error message."""
|
|
910
|
+
print(f" {P.ERR}{Icon.ERROR} ERROR:{P.R} {msg}")
|
|
911
|
+
|
|
912
|
+
def warning(self, msg: str):
|
|
913
|
+
"""Display warning message."""
|
|
914
|
+
print(f" {P.WARN}{Icon.WARNING}{P.R} {msg}")
|
|
915
|
+
|
|
916
|
+
def info(self, msg: str):
|
|
917
|
+
"""Display info message."""
|
|
918
|
+
print(f" {P.INFO}{Icon.INFO}{P.R} {P.MUTED}{msg}{P.R}")
|
|
919
|
+
|
|
920
|
+
def goodbye(self):
|
|
921
|
+
"""Display goodbye message."""
|
|
922
|
+
print()
|
|
923
|
+
print(f" {P.HEADER}{'─' * 20}{P.R}")
|
|
924
|
+
print(f" {P.HEADER}👋 Goodbye!{P.R}")
|
|
925
|
+
print(f" {P.HEADER}{'─' * 20}{P.R}")
|
|
926
|
+
print()
|
|
927
|
+
|
|
928
|
+
def prompt(self) -> str:
|
|
929
|
+
"""Read user input."""
|
|
930
|
+
from hanus.terminal_prompt import get_prompt
|
|
931
|
+
tp = get_prompt()
|
|
932
|
+
tp.streaming_enabled = self._streaming_enabled
|
|
933
|
+
return tp.read_line()
|
|
934
|
+
|
|
935
|
+
def prompt_multiline(self, prompt_text: str = "") -> str:
|
|
936
|
+
"""Read multiline input."""
|
|
937
|
+
from hanus.terminal_prompt import get_prompt
|
|
938
|
+
tp = get_prompt()
|
|
939
|
+
return tp.read_multiline(prompt_text)
|