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.
Files changed (93) hide show
  1. hanus/__init__.py +5 -0
  2. hanus/__main__.py +10 -0
  3. hanus/action_handlers.py +76 -0
  4. hanus/action_parser.py +82 -0
  5. hanus/agent_runner.py +1445 -0
  6. hanus/analysis/__init__.py +5 -0
  7. hanus/analysis/debt.py +702 -0
  8. hanus/analysis/dependencies.py +475 -0
  9. hanus/cache/__init__.py +5 -0
  10. hanus/cache/response_cache.py +560 -0
  11. hanus/config.py +401 -0
  12. hanus/connectors/__init__.py +19 -0
  13. hanus/connectors/base.py +114 -0
  14. hanus/connectors/claude_connector.py +146 -0
  15. hanus/connectors/gemini_connector.py +141 -0
  16. hanus/connectors/glm_connector.py +160 -0
  17. hanus/connectors/ollama_connector.py +174 -0
  18. hanus/connectors/openai_connector.py +122 -0
  19. hanus/connectors/registry.py +26 -0
  20. hanus/context/__init__.py +7 -0
  21. hanus/context/manager.py +837 -0
  22. hanus/context/selective.py +626 -0
  23. hanus/error_recovery/__init__.py +5 -0
  24. hanus/error_recovery/auto_fix.py +605 -0
  25. hanus/hooks/__init__.py +5 -0
  26. hanus/hooks/manager.py +247 -0
  27. hanus/instincts/__init__.py +44 -0
  28. hanus/instincts/cli.py +372 -0
  29. hanus/instincts/detector.py +281 -0
  30. hanus/instincts/evolver.py +361 -0
  31. hanus/instincts/manager.py +343 -0
  32. hanus/instincts/types.py +253 -0
  33. hanus/logger.py +81 -0
  34. hanus/memory/__init__.py +8 -0
  35. hanus/memory/manager.py +265 -0
  36. hanus/memory/types.py +119 -0
  37. hanus/monitor.py +341 -0
  38. hanus/parallel/__init__.py +5 -0
  39. hanus/parallel/executor.py +300 -0
  40. hanus/permissions.py +182 -0
  41. hanus/plan/__init__.py +8 -0
  42. hanus/plan/mode.py +267 -0
  43. hanus/plan/models.py +152 -0
  44. hanus/plugin_manager.py +754 -0
  45. hanus/plugin_registry.py +391 -0
  46. hanus/plugins/__init__.py +1 -0
  47. hanus/plugins/arena.py +630 -0
  48. hanus/plugins/code_review.py +123 -0
  49. hanus/plugins/cortex.py +1750 -0
  50. hanus/plugins/deps_check.py +27 -0
  51. hanus/plugins/git_ops.py +33 -0
  52. hanus/plugins/metasploit.py +530 -0
  53. hanus/plugins/notes.py +583 -0
  54. hanus/plugins/search_code.py +59 -0
  55. hanus/plugins/searchsploit.py +495 -0
  56. hanus/plugins/strategist.py +175 -0
  57. hanus/plugins/webui.py +5200 -0
  58. hanus/profiles.py +479 -0
  59. hanus/profiles_builtin/__init__.py +0 -0
  60. hanus/profiles_builtin/architect/profile.yaml +12 -0
  61. hanus/profiles_builtin/architect/system_prompt.txt +71 -0
  62. hanus/profiles_builtin/deep/profile.yaml +12 -0
  63. hanus/profiles_builtin/deep/system_prompt.txt +66 -0
  64. hanus/profiles_builtin/developer/__init__.py +0 -0
  65. hanus/profiles_builtin/developer/profile.yaml +9 -0
  66. hanus/profiles_builtin/developer/system_prompt.txt +176 -0
  67. hanus/profiles_builtin/speed/profile.yaml +12 -0
  68. hanus/profiles_builtin/speed/system_prompt.txt +51 -0
  69. hanus/project_tools.py +177 -0
  70. hanus/query_engine.py +1594 -0
  71. hanus/rules/__init__.py +237 -0
  72. hanus/search/__init__.py +5 -0
  73. hanus/search/semantic.py +596 -0
  74. hanus/session_manager.py +547 -0
  75. hanus/skill_manager.py +702 -0
  76. hanus/skills/__init__.py +4 -0
  77. hanus/subagent/__init__.py +8 -0
  78. hanus/subagent/agents/__init__.py +253 -0
  79. hanus/subagent/manager.py +309 -0
  80. hanus/subagent/types.py +266 -0
  81. hanus/suggestions/__init__.py +5 -0
  82. hanus/suggestions/proactive.py +451 -0
  83. hanus/tasks/__init__.py +8 -0
  84. hanus/tasks/manager.py +330 -0
  85. hanus/tasks/models.py +106 -0
  86. hanus/terminal_prompt.py +166 -0
  87. hanus/tools.py +1849 -0
  88. hanus/ui.py +939 -0
  89. hanuscode-1.0.0.dist-info/METADATA +1151 -0
  90. hanuscode-1.0.0.dist-info/RECORD +93 -0
  91. hanuscode-1.0.0.dist-info/WHEEL +5 -0
  92. hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
  93. 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)