axion-code 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 (82) hide show
  1. axion/__init__.py +3 -0
  2. axion/api/__init__.py +0 -0
  3. axion/api/anthropic.py +460 -0
  4. axion/api/client.py +259 -0
  5. axion/api/error.py +161 -0
  6. axion/api/ollama.py +597 -0
  7. axion/api/openai_compat.py +805 -0
  8. axion/api/openai_responses.py +627 -0
  9. axion/api/prompt_cache.py +31 -0
  10. axion/api/sse.py +98 -0
  11. axion/api/types.py +451 -0
  12. axion/cli/__init__.py +0 -0
  13. axion/cli/init_cmd.py +50 -0
  14. axion/cli/input.py +290 -0
  15. axion/cli/main.py +2953 -0
  16. axion/cli/render.py +489 -0
  17. axion/cli/tui.py +766 -0
  18. axion/commands/__init__.py +0 -0
  19. axion/commands/handlers/__init__.py +0 -0
  20. axion/commands/handlers/agents.py +51 -0
  21. axion/commands/handlers/builtin_commands.py +367 -0
  22. axion/commands/handlers/mcp.py +59 -0
  23. axion/commands/handlers/models.py +75 -0
  24. axion/commands/handlers/plugins.py +55 -0
  25. axion/commands/handlers/skills.py +61 -0
  26. axion/commands/parsing.py +317 -0
  27. axion/commands/registry.py +166 -0
  28. axion/compat_harness/__init__.py +0 -0
  29. axion/compat_harness/extractor.py +145 -0
  30. axion/plugins/__init__.py +0 -0
  31. axion/plugins/hooks.py +22 -0
  32. axion/plugins/manager.py +391 -0
  33. axion/plugins/manifest.py +270 -0
  34. axion/runtime/__init__.py +0 -0
  35. axion/runtime/bash.py +388 -0
  36. axion/runtime/bootstrap.py +39 -0
  37. axion/runtime/claude_subscription.py +300 -0
  38. axion/runtime/compact.py +233 -0
  39. axion/runtime/config.py +397 -0
  40. axion/runtime/conversation.py +1073 -0
  41. axion/runtime/file_ops.py +613 -0
  42. axion/runtime/git.py +213 -0
  43. axion/runtime/hooks.py +235 -0
  44. axion/runtime/image.py +212 -0
  45. axion/runtime/lanes.py +282 -0
  46. axion/runtime/lsp.py +425 -0
  47. axion/runtime/mcp/__init__.py +0 -0
  48. axion/runtime/mcp/client.py +76 -0
  49. axion/runtime/mcp/lifecycle.py +96 -0
  50. axion/runtime/mcp/stdio.py +318 -0
  51. axion/runtime/mcp/tool_bridge.py +79 -0
  52. axion/runtime/memory.py +196 -0
  53. axion/runtime/oauth.py +329 -0
  54. axion/runtime/openai_subscription.py +346 -0
  55. axion/runtime/permissions.py +247 -0
  56. axion/runtime/plan_mode.py +96 -0
  57. axion/runtime/policy_engine.py +259 -0
  58. axion/runtime/prompt.py +586 -0
  59. axion/runtime/recovery.py +261 -0
  60. axion/runtime/remote.py +28 -0
  61. axion/runtime/sandbox.py +68 -0
  62. axion/runtime/scheduler.py +231 -0
  63. axion/runtime/session.py +365 -0
  64. axion/runtime/sharing.py +159 -0
  65. axion/runtime/skills.py +124 -0
  66. axion/runtime/tasks.py +258 -0
  67. axion/runtime/usage.py +241 -0
  68. axion/runtime/workers.py +186 -0
  69. axion/telemetry/__init__.py +0 -0
  70. axion/telemetry/events.py +67 -0
  71. axion/telemetry/profile.py +49 -0
  72. axion/telemetry/sink.py +60 -0
  73. axion/telemetry/tracer.py +95 -0
  74. axion/tools/__init__.py +0 -0
  75. axion/tools/lane_completion.py +33 -0
  76. axion/tools/registry.py +853 -0
  77. axion/tools/tool_search.py +226 -0
  78. axion_code-1.0.0.dist-info/METADATA +709 -0
  79. axion_code-1.0.0.dist-info/RECORD +82 -0
  80. axion_code-1.0.0.dist-info/WHEEL +4 -0
  81. axion_code-1.0.0.dist-info/entry_points.txt +2 -0
  82. axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
axion/cli/render.py ADDED
@@ -0,0 +1,489 @@
1
+ """Rich-based terminal rendering: markdown, syntax highlighting, spinners.
2
+
3
+ Maps to: rust/crates/rusty-claude-cli/src/render.rs
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import enum
9
+ import json
10
+ from typing import Any
11
+
12
+ from rich.console import Console
13
+ from rich.markdown import Markdown
14
+ from rich.panel import Panel
15
+ from rich.syntax import Syntax
16
+ from rich.table import Table
17
+ from rich.theme import Theme
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Constants (matching Rust render.rs)
21
+ # ---------------------------------------------------------------------------
22
+
23
+ READ_DISPLAY_MAX_LINES = 80
24
+ READ_DISPLAY_MAX_CHARS = 6_000
25
+ TOOL_OUTPUT_DISPLAY_MAX_LINES = 60
26
+ TOOL_OUTPUT_DISPLAY_MAX_CHARS = 4_000
27
+
28
+ SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
29
+ SPINNER_INTERVAL_MS = 80
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Color themes
34
+ # ---------------------------------------------------------------------------
35
+
36
+ class ColorTheme(enum.Enum):
37
+ DEFAULT = "default"
38
+ DARK = "dark"
39
+ LIGHT = "light"
40
+
41
+
42
+ # Navy/Cyan theme inspired by modern terminal UIs
43
+ CLAW_THEME = Theme({
44
+ "axion.prompt": "#00d4aa bold", # Bright teal/cyan for prompt
45
+ "axion.assistant": "#e0e0e0", # Light gray for response text
46
+ "axion.tool_name": "#00d4aa bold", # Teal for tool names
47
+ "axion.tool_border": "#0a192f", # Dark navy for borders
48
+ "axion.tool_input": "#8892b0", # Muted blue-gray for inputs
49
+ "axion.tool_output": "#64ffda", # Mint green for outputs
50
+ "axion.tool_success": "#64ffda bold", # Bright mint for success
51
+ "axion.tool_error": "#ff6b6b bold", # Coral red for errors
52
+ "axion.error": "#ff6b6b bold", # Coral red
53
+ "axion.warning": "#ffd93d", # Warm yellow
54
+ "axion.info": "#8892b0", # Muted blue-gray
55
+ "axion.cost": "#00d4aa dim", # Dim teal for cost
56
+ "axion.status": "#8892b0", # Muted blue-gray
57
+ "axion.heading": "#ccd6f6 bold", # Light blue-white for headings
58
+ "axion.code": "#64ffda", # Mint green for code
59
+ "axion.link": "#00d4aa underline", # Teal links
60
+ "axion.thinking": "#8892b0 italic", # Muted italic for thinking
61
+ "axion.success_text": "#64ffda", # Mint green
62
+ "axion.dim": "#8892b0", # Muted blue-gray for dim text
63
+ })
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Spinner (matching Rust 10-frame braille spinner)
68
+ # ---------------------------------------------------------------------------
69
+
70
+ class Spinner:
71
+ """Animated spinner using Rich (works cross-platform including Windows).
72
+
73
+ Maps to: rust/crates/rusty-claude-cli/src/render.rs::Spinner
74
+ """
75
+
76
+ def __init__(self, console: Console | None = None) -> None:
77
+ self._console = console or Console(stderr=True)
78
+ self._status: Any | None = None
79
+ self._running = False
80
+
81
+ def start(self, label: str = "Thinking...") -> None:
82
+ """Start the spinner animation using Rich Status."""
83
+ self._running = True
84
+ self._status = self._console.status(f"[cyan]{label}[/cyan]", spinner="dots")
85
+ self._status.start()
86
+
87
+ def stop(self, final_label: str | None = None, success: bool = True) -> None:
88
+ """Stop the spinner and optionally show final status."""
89
+ self._running = False
90
+ if self._status:
91
+ self._status.stop()
92
+ self._status = None
93
+ if final_label:
94
+ icon = "[green]✔[/green]" if success else "[red]✘[/red]"
95
+ self._console.print(f"{icon} {final_label}")
96
+
97
+ def finish(self, label: str = "Done") -> None:
98
+ """Stop with success indicator."""
99
+ self.stop(label, success=True)
100
+
101
+ def fail(self, label: str = "Failed") -> None:
102
+ """Stop with failure indicator."""
103
+ self.stop(label, success=False)
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Markdown stream state (for incremental rendering)
108
+ # ---------------------------------------------------------------------------
109
+
110
+ class MarkdownStreamState:
111
+ """Buffers incomplete markdown for safe streaming render.
112
+
113
+ Maps to: rust/crates/rusty-claude-cli/src/render.rs::MarkdownStreamState
114
+ """
115
+
116
+ def __init__(self) -> None:
117
+ self._pending = ""
118
+ self._in_code_fence = False
119
+
120
+ def push(self, renderer: TerminalRenderer, delta: str) -> str | None:
121
+ """Accumulate delta text and render when safe.
122
+
123
+ Returns rendered text if a safe boundary was found, None otherwise.
124
+ """
125
+ self._pending += delta
126
+
127
+ boundary = self._find_safe_boundary()
128
+ if boundary is None:
129
+ return None
130
+
131
+ ready = self._pending[:boundary]
132
+ self._pending = self._pending[boundary:]
133
+
134
+ if ready.strip():
135
+ return ready
136
+ return None
137
+
138
+ def flush(self, renderer: TerminalRenderer) -> str | None:
139
+ """Render any remaining pending text."""
140
+ if not self._pending.strip():
141
+ self._pending = ""
142
+ return None
143
+ result = self._pending
144
+ self._pending = ""
145
+ return result
146
+
147
+ def _find_safe_boundary(self) -> int | None:
148
+ """Find a safe point to split the pending text for rendering.
149
+
150
+ Only splits outside code fences, at blank lines or code block ends.
151
+ """
152
+ in_fence = self._in_code_fence
153
+ last_safe = None
154
+
155
+ lines = self._pending.split("\n")
156
+ pos = 0
157
+
158
+ for i, line in enumerate(lines):
159
+ stripped = line.strip()
160
+ # Check for code fence toggling
161
+ if stripped.startswith("```") or stripped.startswith("~~~"):
162
+ in_fence = not in_fence
163
+ if not in_fence:
164
+ # End of code block — safe boundary after this line
165
+ end_pos = pos + len(line) + 1 # +1 for newline
166
+ if end_pos <= len(self._pending):
167
+ last_safe = end_pos
168
+
169
+ if not in_fence and stripped == "" and i > 0:
170
+ # Blank line outside fence — safe boundary
171
+ end_pos = pos + len(line) + 1
172
+ if end_pos <= len(self._pending):
173
+ last_safe = end_pos
174
+
175
+ pos += len(line) + 1
176
+
177
+ # Update fence state for rendered portion
178
+ if last_safe is not None:
179
+ rendered = self._pending[:last_safe]
180
+ for line in rendered.split("\n"):
181
+ stripped = line.strip()
182
+ if stripped.startswith("```") or stripped.startswith("~~~"):
183
+ self._in_code_fence = not self._in_code_fence
184
+
185
+ return last_safe
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # Tool display formatting
190
+ # ---------------------------------------------------------------------------
191
+
192
+ def format_tool_call_start(tool_name: str, tool_input: str) -> str:
193
+ """Format a tool invocation with box-drawing characters.
194
+
195
+ Maps to: rust/crates/rusty-claude-cli/src/main.rs::format_tool_call_start
196
+ """
197
+ try:
198
+ params = json.loads(tool_input) if tool_input else {}
199
+ except json.JSONDecodeError:
200
+ params = {}
201
+
202
+ # Tool-specific formatting
203
+ detail = ""
204
+ match tool_name:
205
+ case "Bash":
206
+ cmd = params.get("command", "")
207
+ desc = params.get("description", "")
208
+ detail = f" $ {cmd}" if cmd else ""
209
+ if desc:
210
+ detail = f" {desc}\n{detail}"
211
+ case "Read":
212
+ path = params.get("file_path", "")
213
+ offset = params.get("offset", "")
214
+ limit = params.get("limit", "")
215
+ detail = f" 📄 {path}"
216
+ if offset or limit:
217
+ detail += f" (lines {offset or 1}-{(offset or 0) + (limit or '?')})"
218
+ case "Write":
219
+ path = params.get("file_path", "")
220
+ content = params.get("content", "")
221
+ lines = content.count("\n") + 1 if content else 0
222
+ detail = f" ✏️ {path} ({lines} lines)"
223
+ case "Edit":
224
+ path = params.get("file_path", "")
225
+ old = params.get("old_string", "")[:60]
226
+ new = params.get("new_string", "")[:60]
227
+ detail = f" 📝 {path}\n - {old!r}\n + {new!r}"
228
+ case "Glob":
229
+ pattern = params.get("pattern", "")
230
+ path = params.get("path", ".")
231
+ detail = f" 🔍 {pattern} in {path}"
232
+ case "Grep":
233
+ pattern = params.get("pattern", "")
234
+ path = params.get("path", ".")
235
+ detail = f" 🔎 /{pattern}/ in {path}"
236
+ case "Agent":
237
+ desc = params.get("description", "")
238
+ detail = f" 🤖 {desc}" if desc else ""
239
+ case _:
240
+ # Generic tool display
241
+ if params:
242
+ summary = ", ".join(f"{k}={v!r}" for k, v in list(params.items())[:3])
243
+ if len(summary) > 100:
244
+ summary = summary[:100] + "..."
245
+ detail = f" {summary}"
246
+
247
+ header = f"╭─ {tool_name} "
248
+ border_len = max(60 - len(header), 4)
249
+ header += "─" * border_len + "╮"
250
+
251
+ lines = [header]
252
+ if detail:
253
+ for line in detail.split("\n"):
254
+ lines.append(f"│ {line}")
255
+
256
+ return "\n".join(lines)
257
+
258
+
259
+ def format_tool_result(
260
+ tool_name: str, output: str, is_error: bool = False
261
+ ) -> str:
262
+ """Format tool execution result.
263
+
264
+ Maps to: rust/crates/rusty-claude-cli/src/main.rs::format_tool_result
265
+ """
266
+ icon = "✗" if is_error else "✓"
267
+ status = "error" if is_error else "success"
268
+
269
+ # Truncate output
270
+ display = output
271
+ truncated = False
272
+ output_lines = display.split("\n")
273
+
274
+ if len(output_lines) > TOOL_OUTPUT_DISPLAY_MAX_LINES:
275
+ output_lines = output_lines[:TOOL_OUTPUT_DISPLAY_MAX_LINES]
276
+ truncated = True
277
+ display = "\n".join(output_lines)
278
+
279
+ if len(display) > TOOL_OUTPUT_DISPLAY_MAX_CHARS:
280
+ display = display[:TOOL_OUTPUT_DISPLAY_MAX_CHARS]
281
+ truncated = True
282
+
283
+ # Tool-specific result formatting
284
+ match tool_name:
285
+ case "Read":
286
+ line_count = display.count("\n") + 1
287
+ if line_count > READ_DISPLAY_MAX_LINES:
288
+ display_lines = display.split("\n")[:READ_DISPLAY_MAX_LINES]
289
+ display = "\n".join(display_lines)
290
+ truncated = True
291
+ case "Glob":
292
+ # Show file count
293
+ pass
294
+ case "Grep":
295
+ # Show match count
296
+ pass
297
+
298
+ result = f"│ {icon} {tool_name} ({status})"
299
+ if display.strip():
300
+ # Indent output
301
+ indented = "\n".join(f"│ {line}" for line in display.split("\n"))
302
+ result += f"\n{indented}"
303
+
304
+ if truncated:
305
+ result += "\n│ ... (output truncated)"
306
+
307
+ result += f"\n╰{'─' * 58}╯"
308
+ return result
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # Terminal renderer
313
+ # ---------------------------------------------------------------------------
314
+
315
+ class TerminalRenderer:
316
+ """Renders conversation output to the terminal using Rich.
317
+
318
+ Maps to: rust/crates/rusty-claude-cli/src/render.rs::TerminalRenderer
319
+ """
320
+
321
+ def __init__(self, console: Console | None = None) -> None:
322
+ self.console = console or Console(theme=CLAW_THEME)
323
+
324
+ def render_markdown(self, text: str) -> None:
325
+ """Render markdown text to the terminal."""
326
+ md = Markdown(text, code_theme="monokai")
327
+ self.console.print(md)
328
+
329
+ def render_code(self, code: str, language: str = "python") -> None:
330
+ """Render syntax-highlighted code."""
331
+ syntax = Syntax(code, language, theme="monokai", line_numbers=True)
332
+ self.console.print(syntax)
333
+
334
+ def render_text(self, text: str, style: str = "") -> None:
335
+ """Render plain text with optional style."""
336
+ self.console.print(text, style=style, highlight=False)
337
+
338
+ def render_streaming_text(self, text: str) -> None:
339
+ """Render streaming text (no newline)."""
340
+ self.console.print(text, end="", highlight=False)
341
+
342
+ def render_tool_call(self, tool_name: str, tool_input: str) -> None:
343
+ """Render a tool invocation with box-drawing border."""
344
+ formatted = format_tool_call_start(tool_name, tool_input)
345
+ self.console.print(f"[axion.tool_border]{formatted}[/axion.tool_border]")
346
+
347
+ def render_tool_result(self, tool_name: str, output: str, is_error: bool = False) -> None:
348
+ """Render tool execution result."""
349
+ formatted = format_tool_result(tool_name, output, is_error)
350
+ style = "axion.tool_error" if is_error else "axion.tool_border"
351
+ self.console.print(f"[{style}]{formatted}[/{style}]")
352
+
353
+ def render_tool_use_simple(self, tool_name: str, tool_input: str) -> None:
354
+ """Render a simple tool use indicator (no box)."""
355
+ self.console.print(f"[axion.tool_name]⚡ {tool_name}[/axion.tool_name]")
356
+ if tool_input:
357
+ display = tool_input[:500] + "..." if len(tool_input) > 500 else tool_input
358
+ self.console.print(f" [axion.tool_input]{display}[/axion.tool_input]")
359
+
360
+ def render_tool_result_simple(self, output: str, is_error: bool = False) -> None:
361
+ """Render a simple tool result (no box)."""
362
+ if is_error:
363
+ self.console.print(f"[axion.tool_error]✗ {output}[/axion.tool_error]")
364
+ else:
365
+ display = output[:1000] + "\n... (truncated)" if len(output) > 1000 else output
366
+ self.console.print(f"[axion.tool_output]{display}[/axion.tool_output]")
367
+
368
+ def render_thinking(self, text: str) -> None:
369
+ """Render collapsed thinking output."""
370
+ preview = text[:100] + "..." if len(text) > 100 else text
371
+ self.console.print(f"[axion.thinking]💭 Thinking: {preview}[/axion.thinking]")
372
+
373
+ def render_error(self, message: str) -> None:
374
+ """Render an error message."""
375
+ self.console.print(f"[axion.error]Error: {message}[/axion.error]")
376
+
377
+ def render_context_window_error(
378
+ self,
379
+ model: str,
380
+ estimated_tokens: int,
381
+ context_window: int,
382
+ session_id: str | None = None,
383
+ ) -> None:
384
+ """Render a context window exceeded error with details."""
385
+ pct = (estimated_tokens / context_window * 100) if context_window > 0 else 0
386
+ lines = [
387
+ "[axion.error]Context window blocked[/axion.error]",
388
+ f" Model: {model}",
389
+ f" Estimated tokens: {estimated_tokens:,}",
390
+ f" Context window: {context_window:,}",
391
+ f" Usage: {pct:.0f}%",
392
+ ]
393
+ if session_id:
394
+ lines.append(f" Session: {session_id}")
395
+ lines.append(" Try /compact to reduce history or /clear to start fresh.")
396
+ self.console.print("\n".join(lines))
397
+
398
+ def render_warning(self, message: str) -> None:
399
+ """Render a warning message."""
400
+ self.console.print(f"[axion.warning]Warning: {message}[/axion.warning]")
401
+
402
+ def render_info(self, message: str) -> None:
403
+ """Render an info message."""
404
+ self.console.print(f"[axion.info]{message}[/axion.info]")
405
+
406
+ def render_cost(self, cost_line: str) -> None:
407
+ """Render cost information."""
408
+ self.console.print(f"[axion.cost]{cost_line}[/axion.cost]")
409
+
410
+ def render_separator(self) -> None:
411
+ """Render a horizontal separator."""
412
+ self.console.rule(style="dim")
413
+
414
+ def render_welcome(self, version: str, model: str) -> None:
415
+ """Render the welcome banner."""
416
+ self.console.print(f"\n[bold]🐍 Axion Code[/bold] v{version}")
417
+ self.console.print(f"[dim]Model: {model}[/dim]")
418
+ self.console.print("[dim]Type /help for commands, Ctrl+C to interrupt[/dim]\n")
419
+
420
+ def render_status_report(
421
+ self,
422
+ model: str,
423
+ permission_mode: str,
424
+ message_count: int,
425
+ turn_count: int,
426
+ session_id: str,
427
+ cwd: str,
428
+ git_branch: str | None = None,
429
+ estimated_tokens: int = 0,
430
+ ) -> None:
431
+ """Render a full status report."""
432
+ table = Table(show_header=False, box=None, padding=(0, 2))
433
+ table.add_column(style="bold")
434
+ table.add_column()
435
+ table.add_row("Model", model)
436
+ table.add_row("Permissions", permission_mode)
437
+ table.add_row("Messages", str(message_count))
438
+ table.add_row("Turns", str(turn_count))
439
+ table.add_row("Est. Tokens", f"{estimated_tokens:,}")
440
+ table.add_row("Session", session_id)
441
+ table.add_row("Working Dir", cwd)
442
+ if git_branch:
443
+ table.add_row("Git Branch", git_branch)
444
+ self.console.print(Panel(table, title="Status", border_style="dim"))
445
+
446
+ def render_session_list(self, sessions: list[dict[str, Any]]) -> None:
447
+ """Render a list of sessions."""
448
+ if not sessions:
449
+ self.console.print("[dim]No sessions found.[/dim]")
450
+ return
451
+ table = Table(title="Sessions", show_lines=False)
452
+ table.add_column("ID", style="cyan")
453
+ table.add_column("Messages", justify="right")
454
+ table.add_column("Modified")
455
+ table.add_column("Branch")
456
+ for s in sessions:
457
+ table.add_row(
458
+ s.get("id", "?"),
459
+ str(s.get("message_count", 0)),
460
+ s.get("modified", "?"),
461
+ s.get("branch", ""),
462
+ )
463
+ self.console.print(table)
464
+
465
+ def render_json_output(self, data: dict[str, Any]) -> None:
466
+ """Render structured JSON output."""
467
+ self.console.print_json(json.dumps(data, indent=2, default=str))
468
+
469
+ def render_permission_prompt(
470
+ self, tool_name: str, current_mode: str, required_mode: str, reason: str = ""
471
+ ) -> None:
472
+ """Render a permission approval request."""
473
+ self.console.print()
474
+ self.console.print("[bold yellow]Permission approval required[/bold yellow]")
475
+ self.console.print(f" Tool: [bold]{tool_name}[/bold]")
476
+ self.console.print(f" Current mode: {current_mode}")
477
+ self.console.print(f" Required mode: {required_mode}")
478
+ if reason:
479
+ self.console.print(f" Reason: {reason}")
480
+
481
+ def render_auto_compaction_notice(self, removed_count: int) -> None:
482
+ """Render auto-compaction notice."""
483
+ self.console.print(
484
+ f"[dim]Auto-compacted: removed {removed_count} messages to stay within context window.[/dim]"
485
+ )
486
+
487
+ def render_export_success(self, path: str) -> None:
488
+ """Render export success message."""
489
+ self.console.print(f"[green]Transcript exported to: {path}[/green]")