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/tui.py ADDED
@@ -0,0 +1,766 @@
1
+ """Terminal UI components — header bar, status bar, logo, response panels.
2
+
3
+ Provides a polished TUI experience using Rich:
4
+ - ASCII logo/brand
5
+ - Header bar with model, session, git branch
6
+ - Status bar with tokens, cost, turn count
7
+ - Response panels with borders
8
+ - Tool use panels with icons
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from rich.console import Console
16
+ from rich.panel import Panel
17
+ from rich.table import Table
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Axion ASCII Logo
21
+ # ---------------------------------------------------------------------------
22
+
23
+ # Neural network / node graph logo — multi-layer connected nodes
24
+ AXION_LOGO_SMALL = (
25
+ "[#8892b0] ⬡[/#8892b0][#00d4aa]━━━━━━━[/#00d4aa][#8892b0]⬡[/#8892b0]\n"
26
+ "[#00d4aa] ╱ ╲ ╱ ╲[/#00d4aa]\n"
27
+ "[#8892b0] ⬡[/#8892b0][#00d4aa]───╲─────╱───[/#00d4aa][#8892b0]⬡[/#8892b0]\n"
28
+ "[#00d4aa] ╲ ╲ ╱ ╱[/#00d4aa]\n"
29
+ "[#00d4aa] ╲ [bold #64ffda]⬢[/bold #64ffda] ╱[/#00d4aa]\n"
30
+ "[#00d4aa] ╱ ╱ ╲ ╲[/#00d4aa]\n"
31
+ "[#8892b0] ⬡[/#8892b0][#00d4aa]───╱─────╲───[/#00d4aa][#8892b0]⬡[/#8892b0]\n"
32
+ "[#00d4aa] ╲ ╱ ╲ ╱[/#00d4aa]\n"
33
+ "[#8892b0] ⬡[/#8892b0][#00d4aa]━━━━━━━[/#00d4aa][#8892b0]⬡[/#8892b0]\n"
34
+ "\n"
35
+ "[bold #64ffda] A X I O N[/bold #64ffda]"
36
+ )
37
+
38
+ AXION_LOGO_MINI = "[bold #00d4aa]◆ AXION[/bold #00d4aa]"
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Header bar
43
+ # ---------------------------------------------------------------------------
44
+
45
+ def render_header(
46
+ console: Console,
47
+ version: str,
48
+ model: str,
49
+ session_id: str,
50
+ permission_mode: str = "allow",
51
+ git_branch: str | None = None,
52
+ plan_mode: bool = False,
53
+ ) -> None:
54
+ """Render the top header bar with model, session, and git info."""
55
+ # Left: logo + version
56
+ left = f"[bold cyan]◆ AXION[/bold cyan] [dim]v{version}[/dim]"
57
+
58
+ # Center: model
59
+ model_display = f"[bold white]{model}[/bold white]"
60
+ if plan_mode:
61
+ model_display += " [bold #00d4aa]⚡PLAN[/bold #00d4aa]"
62
+
63
+ # Right: session + branch
64
+ right_parts = [f"[dim]{session_id[:8]}[/dim]"]
65
+ if git_branch:
66
+ right_parts.append(f"[dim cyan]{git_branch}[/dim cyan]")
67
+ right = " │ ".join(right_parts)
68
+
69
+ # Build the header table (single row, 3 columns)
70
+ header = Table(show_header=False, show_edge=False, box=None, padding=0, expand=True)
71
+ header.add_column(ratio=1)
72
+ header.add_column(ratio=1, justify="center")
73
+ header.add_column(ratio=1, justify="right")
74
+ header.add_row(left, model_display, right)
75
+
76
+ console.print(Panel(header, style="dim", border_style="#00d4aa", padding=(0, 1)))
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Status bar (bottom)
81
+ # ---------------------------------------------------------------------------
82
+
83
+ def render_status_bar(
84
+ console: Console,
85
+ tokens: int = 0,
86
+ cost: float = 0.0,
87
+ turn: int = 0,
88
+ permission_mode: str = "allow",
89
+ plan_mode: bool = False,
90
+ ) -> None:
91
+ """Render the bottom status bar with tokens, cost, and turn info."""
92
+ parts: list[str] = []
93
+
94
+ if tokens > 0:
95
+ parts.append(f"[dim]Tokens: {tokens:,}[/dim]")
96
+ if cost > 0:
97
+ parts.append(f"[dim]Cost: ${cost:.4f}[/dim]")
98
+ if turn > 0:
99
+ parts.append(f"[dim]Turn {turn}[/dim]")
100
+
101
+ parts.append(f"[dim]{permission_mode}[/dim]")
102
+
103
+ if plan_mode:
104
+ parts.append("[bold #00d4aa]PLAN MODE[/bold #00d4aa]")
105
+
106
+ bar_text = " │ ".join(parts) if parts else "[dim]Ready[/dim]"
107
+ console.print(f"[dim]{'─' * 60}[/dim]")
108
+ console.print(f" {bar_text}")
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Welcome screen
113
+ # ---------------------------------------------------------------------------
114
+
115
+ def render_welcome_screen(
116
+ console: Console,
117
+ version: str,
118
+ model: str,
119
+ session_id: str,
120
+ permission_mode: str = "allow",
121
+ git_branch: str | None = None,
122
+ resumed: bool = False,
123
+ message_count: int = 0,
124
+ cwd: str = "",
125
+ auth_mode: str = "",
126
+ ) -> None:
127
+ """Render the Claude Code-style welcome screen with two columns."""
128
+ import os
129
+ import random
130
+
131
+ console.print()
132
+
133
+ # Network graph matching the logo
134
+ mascot = (
135
+ "[#8892b0] ⬡[/#8892b0][#00d4aa]━━━[/#00d4aa][#8892b0]⬡[/#8892b0]\n"
136
+ "[#00d4aa] ╱ ╲ ╱ ╲[/#00d4aa]\n"
137
+ "[#8892b0] ⬡[/#8892b0][#00d4aa]─[bold #64ffda]⬢[/bold #64ffda]─[/#00d4aa][#8892b0]⬡[/#8892b0]\n"
138
+ "[#00d4aa] ╲ ╱ ╲ ╱[/#00d4aa]\n"
139
+ "[#8892b0] ⬡[/#8892b0][#00d4aa]━━━[/#00d4aa][#8892b0]⬡[/#8892b0]"
140
+ )
141
+
142
+ # Left column: version + mascot
143
+ left_lines = [
144
+ f"[dim]- - -[/dim] [bold]Axion Code[/bold] v{version} [dim]- - -[/dim]",
145
+ "",
146
+ ]
147
+ if resumed:
148
+ left_lines.append(" Welcome back!")
149
+ else:
150
+ left_lines.append(" Welcome!")
151
+ left_lines.append("")
152
+ left_lines.append(mascot)
153
+ left_lines.append("")
154
+ # Render model with auth mode badge.
155
+ if auth_mode == "subscription":
156
+ sub_label = "ChatGPT" if "codex" in model.lower() else "Pro/Max"
157
+ model_line = f" [bold]{model}[/bold] [bold #64ffda]· {sub_label}[/bold #64ffda]"
158
+ elif auth_mode == "local":
159
+ model_line = f" [bold]{model}[/bold] [dim cyan]· local[/dim cyan]"
160
+ elif auth_mode == "api":
161
+ model_line = f" [bold]{model}[/bold] [yellow]· API[/yellow]"
162
+ else:
163
+ model_line = f" [bold]{model}[/bold]"
164
+ left_lines.append(model_line)
165
+ left_lines.append(f" [dim]{cwd or os.getcwd()}[/dim]")
166
+
167
+ # Right column: tips + recent activity
168
+ right_lines = [
169
+ "[bold #00d4aa]Quick start[/bold #00d4aa]",
170
+ ]
171
+
172
+ tips = [
173
+ '"Fix the bug in auth.py"',
174
+ '"Add tests for the API"',
175
+ '"Explain this codebase"',
176
+ '"Refactor this function"',
177
+ '"Search for all TODO comments"',
178
+ '"Read package.json and summarize"',
179
+ ]
180
+ selected_tips = random.sample(tips, min(3, len(tips)))
181
+ for tip in selected_tips:
182
+ right_lines.append(f" [dim]Try:[/dim] {tip}")
183
+
184
+ right_lines.append("")
185
+ right_lines.append("[bold #00d4aa]Commands[/bold #00d4aa]")
186
+ right_lines.append(" [bold]/plan[/bold] [dim]Design before coding[/dim]")
187
+ right_lines.append(" [bold]/model[/bold] [dim]Switch AI model[/dim]")
188
+ right_lines.append(" [bold]/cost[/bold] [dim]See token usage[/dim]")
189
+ right_lines.append(" [bold]/export[/bold] [dim]Save transcript[/dim]")
190
+ right_lines.append(" [dim]... /help for more[/dim]")
191
+
192
+ if resumed:
193
+ right_lines.append("")
194
+ right_lines.append("[bold #00d4aa]Session[/bold #00d4aa]")
195
+ right_lines.append(f" [dim]Resumed {message_count} messages[/dim]")
196
+ right_lines.append(f" [dim]ID: {session_id[:12]}[/dim]")
197
+
198
+ # Build two-column layout
199
+ left_text = "\n".join(left_lines)
200
+ right_text = "\n".join(right_lines)
201
+
202
+ # Use a table for side-by-side layout
203
+ layout = Table(show_header=False, show_edge=False, box=None, padding=(0, 3), expand=True)
204
+ layout.add_column(ratio=1, justify="center")
205
+ layout.add_column(ratio=1)
206
+ layout.add_row(left_text, right_text)
207
+
208
+ console.print(Panel(
209
+ layout,
210
+ border_style="#0a192f",
211
+ padding=(1, 2),
212
+ ))
213
+ console.print()
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Response formatting
218
+ # ---------------------------------------------------------------------------
219
+
220
+ def render_assistant_response(console: Console, text: str) -> None:
221
+ """Render an assistant response in a panel."""
222
+ if not text.strip():
223
+ return
224
+ from rich.markdown import Markdown
225
+ console.print()
226
+ console.print(Markdown(text))
227
+ console.print()
228
+
229
+
230
+ def render_tool_call_inline(
231
+ console: Console,
232
+ tool_name: str,
233
+ params: dict[str, Any],
234
+ ) -> None:
235
+ """Render a tool invocation as a compact inline bullet (Claude Code style).
236
+
237
+ Format: `● ToolName(args)` — one line, no panel, no border.
238
+ For Edit/Write, also shows an inline mini-diff (additions/removals)
239
+ so you can see what's changing without expanding a panel.
240
+ """
241
+ args_str = _format_tool_args(tool_name, params)
242
+ if args_str:
243
+ console.print(f"[bold #00d4aa]●[/bold #00d4aa] [bold]{tool_name}[/bold]({args_str})")
244
+ else:
245
+ console.print(f"[bold #00d4aa]●[/bold #00d4aa] [bold]{tool_name}[/bold]")
246
+
247
+ # Show inline diff for Edit/Write so the user can see what changed
248
+ if tool_name == "Edit":
249
+ _render_edit_diff(console, params)
250
+ elif tool_name == "Write":
251
+ _render_write_preview(console, params)
252
+
253
+
254
+ # Maximum number of diff lines to show before truncating
255
+ _MAX_DIFF_LINES = 14
256
+
257
+
258
+ def _find_line_number_in_file(file_path: str, search_str: str) -> int:
259
+ """Find the 1-based line number where search_str starts in file_path.
260
+
261
+ Returns 1 if the file can't be read or the string isn't found.
262
+ """
263
+ if not file_path or not search_str:
264
+ return 1
265
+ try:
266
+ from pathlib import Path as _P
267
+ p = _P(file_path)
268
+ if not p.exists():
269
+ return 1
270
+ content = p.read_text(encoding="utf-8", errors="replace")
271
+ pos = content.find(search_str)
272
+ if pos < 0:
273
+ # If old_string isn't there anymore (already replaced), search for new_string
274
+ return 1
275
+ return content[:pos].count("\n") + 1
276
+ except (OSError, UnicodeDecodeError):
277
+ return 1
278
+
279
+
280
+ def _render_edit_diff(console: Console, params: dict[str, Any]) -> None:
281
+ """Render an inline diff for Edit calls with line numbers and bg colors.
282
+
283
+ Style matches Claude Code:
284
+ 4 -Software engineering has undergone... (red bg)
285
+ 4 +Software engineering has undergone... (green bg)
286
+ """
287
+ old_str = params.get("old_string", "") or ""
288
+ new_str = params.get("new_string", "") or ""
289
+ file_path = params.get("file_path", "") or ""
290
+ if not old_str and not new_str:
291
+ return
292
+
293
+ old_lines = old_str.splitlines() if old_str else []
294
+ new_lines = new_str.splitlines() if new_str else []
295
+
296
+ # Anchor line: where old_str (or new_str if already applied) starts
297
+ start_line = _find_line_number_in_file(file_path, old_str) if old_str else _find_line_number_in_file(file_path, new_str)
298
+
299
+ shown_old = old_lines[:_MAX_DIFF_LINES]
300
+ shown_new = new_lines[:_MAX_DIFF_LINES]
301
+
302
+ # Print removed lines with red background
303
+ for i, line in enumerate(shown_old):
304
+ ln = start_line + i
305
+ text = _truncate_line(line)
306
+ console.print(f" [dim]{ln:>4}[/dim] [white on red] -{text} [/white on red]")
307
+ if len(old_lines) > _MAX_DIFF_LINES:
308
+ hidden = len(old_lines) - _MAX_DIFF_LINES
309
+ console.print(f" [dim] ... {hidden} more removed line(s)[/dim]")
310
+
311
+ # Print added lines with green background — same anchor line so alignment matches
312
+ for i, line in enumerate(shown_new):
313
+ ln = start_line + i
314
+ text = _truncate_line(line)
315
+ console.print(f" [dim]{ln:>4}[/dim] [white on green] +{text} [/white on green]")
316
+ if len(new_lines) > _MAX_DIFF_LINES:
317
+ hidden = len(new_lines) - _MAX_DIFF_LINES
318
+ console.print(f" [dim] ... {hidden} more added line(s)[/dim]")
319
+
320
+
321
+ def _render_write_preview(console: Console, params: dict[str, Any]) -> None:
322
+ """Render the new file's content with line numbers (Claude Code style).
323
+
324
+ 1 The Evolution of Software Engineering: A...
325
+ 2
326
+ 3 Software engineering has undergone...
327
+ ... +67 lines (ctrl+o to expand)
328
+ """
329
+ content = params.get("content", "") or ""
330
+ if not content:
331
+ return
332
+ lines = content.splitlines()
333
+ shown = lines[:_MAX_DIFF_LINES]
334
+ for i, line in enumerate(shown, start=1):
335
+ text = _truncate_line(line)
336
+ console.print(f" [dim]{i:>4}[/dim] [green]{text}[/green]")
337
+ if len(lines) > _MAX_DIFF_LINES:
338
+ hidden = len(lines) - _MAX_DIFF_LINES
339
+ console.print(f" [dim] ... +{hidden} lines (ctrl+o to expand)[/dim]")
340
+
341
+
342
+ def _truncate_line(line: str, max_chars: int = 200) -> str:
343
+ """Truncate a line for inline display, escaping Rich markup."""
344
+ # Escape Rich tag characters to avoid mis-rendering
345
+ escaped = line.replace("[", r"\[")
346
+ if len(escaped) > max_chars:
347
+ return escaped[:max_chars] + "..."
348
+ return escaped
349
+
350
+
351
+ def render_tool_result_inline(
352
+ console: Console,
353
+ tool_name: str,
354
+ output: str,
355
+ is_error: bool = False,
356
+ ) -> None:
357
+ """Render a tool result as an indented continuation line.
358
+
359
+ Format: ` └ summary text` — compact, one or two lines max.
360
+ Errors are shown in red.
361
+ """
362
+ if is_error:
363
+ first_line = output.strip().splitlines()[0] if output.strip() else "error"
364
+ # Don't duplicate the "Error:" prefix if the message already has it
365
+ if first_line.lower().startswith(("error:", "error ", "exception:", "failed:")):
366
+ console.print(f" [dim]└[/dim] [red]{first_line[:140]}[/red]")
367
+ else:
368
+ console.print(f" [dim]└[/dim] [red]Error: {first_line[:140]}[/red]")
369
+ return
370
+
371
+ summary = _summarize_tool_output(tool_name, output)
372
+ console.print(f" [dim]└[/dim] [dim]{summary}[/dim]")
373
+
374
+
375
+ def _format_tool_args(tool_name: str, params: dict[str, Any]) -> str:
376
+ """Build a one-line argument display for a tool call."""
377
+ if tool_name == "Bash":
378
+ cmd = params.get("command", "")
379
+ return cmd[:120] + ("..." if len(cmd) > 120 else "")
380
+ if tool_name == "Read":
381
+ return params.get("file_path", "")
382
+ if tool_name in ("Write", "Edit"):
383
+ return params.get("file_path", "")
384
+ if tool_name == "Glob":
385
+ return params.get("pattern", "")
386
+ if tool_name == "Grep":
387
+ pattern = params.get("pattern", "")
388
+ path = params.get("path", "")
389
+ return f'"{pattern}"' + (f" in {path}" if path else "")
390
+ if tool_name == "WebSearch":
391
+ return f'"{params.get("query", "")[:100]}"'
392
+ if tool_name == "WebFetch":
393
+ return params.get("url", "")
394
+ if tool_name == "Agent":
395
+ desc = params.get("description", "")
396
+ return desc[:100]
397
+ if tool_name == "TodoWrite":
398
+ todos = params.get("todos", [])
399
+ return f"{len(todos)} task(s)"
400
+ if tool_name == "NotebookEdit":
401
+ return params.get("notebook_path", "")
402
+ if tool_name == "Skill":
403
+ return params.get("skill", "")
404
+ # Generic: first param value
405
+ for v in params.values():
406
+ s = str(v)
407
+ return s[:100] + ("..." if len(s) > 100 else "")
408
+ return ""
409
+
410
+
411
+ def _summarize_tool_output(tool_name: str, output: str) -> str:
412
+ """One-line summary of tool output for the inline result display."""
413
+ if not output.strip():
414
+ return "done"
415
+ line_count = len(output.splitlines())
416
+
417
+ if tool_name == "Bash":
418
+ # First non-empty line + count if multi-line
419
+ lines = [ln for ln in output.splitlines() if ln.strip()]
420
+ first = lines[0][:120] if lines else "(no output)"
421
+ return f"{first}" + (f" [dim]({line_count} lines)[/dim]" if line_count > 1 else "")
422
+ if tool_name == "Read":
423
+ return f"Read {line_count} lines"
424
+ if tool_name in ("Write", "Edit"):
425
+ first = output.splitlines()[0][:120] if output.strip() else "done"
426
+ return first
427
+ if tool_name == "Glob":
428
+ # Output: "Found N file(s) in Xms:" + paths
429
+ first_line = output.splitlines()[0] if output.strip() else "0 results"
430
+ return first_line[:120]
431
+ if tool_name == "Grep":
432
+ first_line = output.splitlines()[0] if output.strip() else "0 matches"
433
+ return first_line[:120]
434
+ if tool_name == "WebSearch":
435
+ return f"Found {max(line_count - 2, 0)} result(s)"
436
+ if tool_name == "WebFetch":
437
+ return f"Fetched {len(output):,} chars"
438
+ if tool_name == "Agent":
439
+ first = output.splitlines()[0] if output.strip() else "done"
440
+ return first[:120]
441
+ if tool_name == "TodoWrite":
442
+ return "Updated"
443
+ # Generic
444
+ first = output.splitlines()[0][:120] if output.strip() else "done"
445
+ return first
446
+
447
+
448
+ def render_tool_panel(
449
+ console: Console,
450
+ tool_name: str,
451
+ params: dict[str, Any],
452
+ is_start: bool = True,
453
+ ) -> None:
454
+ """Render a tool invocation with diff-style display for Edit/Write."""
455
+ if not is_start:
456
+ return
457
+
458
+ icon = _tool_icon(tool_name)
459
+ title = f"{icon} {tool_name}"
460
+ lines: list[str] = []
461
+
462
+ if tool_name == "Edit" and "file_path" in params:
463
+ # Show edit as a diff — background colored, real line numbers from file
464
+ file_path = params.get("file_path", "")
465
+ old_str = params.get("old_string", "")
466
+ new_str = params.get("new_string", "")
467
+ old_lines_list = old_str.splitlines() if old_str else []
468
+ new_lines_list = new_str.splitlines() if new_str else []
469
+
470
+ lines.append(f" [dim]file:[/dim] {file_path}")
471
+ count_info = f"{len(new_lines_list)} addition{'s' if len(new_lines_list) != 1 else ''} and {len(old_lines_list)} removal{'s' if len(old_lines_list) != 1 else ''}"
472
+ lines.append(f" [dim]{count_info}[/dim]")
473
+ lines.append("")
474
+
475
+ # Try to find real line number where old_string starts in the file
476
+ start_line = 1
477
+ try:
478
+ from pathlib import Path as _Path
479
+ fp = _Path(file_path)
480
+ if fp.exists() and old_str:
481
+ file_content = fp.read_text(encoding="utf-8", errors="replace")
482
+ pos = file_content.find(old_str)
483
+ if pos >= 0:
484
+ start_line = file_content[:pos].count("\n") + 1
485
+ # If old_str not found (already replaced), try new_str
486
+ elif new_str:
487
+ pos = file_content.find(new_str)
488
+ if pos >= 0:
489
+ start_line = file_content[:pos].count("\n") + 1
490
+ except Exception:
491
+ pass
492
+
493
+ line_num = start_line
494
+ # Show removed lines with RED background
495
+ for old_line in old_lines_list[:10]:
496
+ lines.append(f" [dim]{line_num:>4}[/dim] [on red] {old_line} [/on red]")
497
+ line_num += 1
498
+ # Show added lines with GREEN background
499
+ line_num = start_line # Reset — new lines replace at same position
500
+ for new_line in new_lines_list[:10]:
501
+ lines.append(f" [dim]{line_num:>4}[/dim] [on green] {new_line} [/on green]")
502
+ line_num += 1
503
+
504
+ total = len(old_lines_list) + len(new_lines_list)
505
+ if total > 10:
506
+ lines.append(f" [dim] ... ({total} lines total)[/dim]")
507
+
508
+ elif tool_name == "Write" and "file_path" in params:
509
+ # Show write with GREEN background lines + line numbers
510
+ file_path = params.get("file_path", "")
511
+ content = params.get("content", "")
512
+ content_lines = content.splitlines() if content else []
513
+ line_count = len(content_lines)
514
+
515
+ lines.append(f" [dim]file:[/dim] {file_path}")
516
+ lines.append(f" [dim]{line_count} line{'s' if line_count != 1 else ''}[/dim]")
517
+ lines.append("")
518
+
519
+ for i, cl in enumerate(content_lines[:8], 1):
520
+ lines.append(f" [dim]{i:>4}[/dim] [on green] {cl} [/on green]")
521
+ if line_count > 8:
522
+ lines.append(f" [dim] ... ({line_count} lines total)[/dim]")
523
+
524
+ elif tool_name == "Bash":
525
+ cmd = params.get("command", "")
526
+ desc = params.get("description", "")
527
+ if desc:
528
+ lines.append(f" [dim]{desc}[/dim]")
529
+ lines.append(f" [bold]$ {cmd}[/bold]")
530
+
531
+ elif tool_name == "Read":
532
+ file_path = params.get("file_path", "")
533
+ lines.append(f" [dim]file:[/dim] {file_path}")
534
+
535
+ else:
536
+ # Generic display
537
+ for key, value in list(params.items())[:5]:
538
+ val_str = str(value)
539
+ if len(val_str) > 150:
540
+ val_str = val_str[:150] + "..."
541
+ lines.append(f" [dim]{key}:[/dim] {val_str}")
542
+
543
+ content = "\n".join(lines) if lines else "[dim]No parameters[/dim]"
544
+ console.print(Panel(
545
+ content,
546
+ title=f"[bold #00d4aa]{title}[/bold #00d4aa]",
547
+ title_align="left",
548
+ border_style="#00d4aa",
549
+ padding=(0, 1),
550
+ ))
551
+
552
+
553
+ def render_tool_result_panel(
554
+ console: Console,
555
+ tool_name: str,
556
+ output: str,
557
+ is_error: bool = False,
558
+ ) -> None:
559
+ """Render a tool result with appropriate styling.
560
+
561
+ - Edit/Write results: show with dim text (not bold)
562
+ - Read results: show with line numbers faded
563
+ - Bash results: stdout normal, stderr red
564
+ - Errors: red border
565
+ """
566
+ icon = "✗" if is_error else "✓"
567
+ color = "red" if is_error else "green"
568
+
569
+ # Truncate long output
570
+ display = output
571
+ if len(display) > 1200:
572
+ display = display[:1200] + "\n[dim]... (truncated)[/dim]"
573
+
574
+ # Style based on tool type
575
+ if tool_name in ("Edit", "Write") and not is_error:
576
+ # Show edit/write result in dim (it's just a confirmation)
577
+ display = f"[dim]{display}[/dim]"
578
+ elif tool_name == "Read" and not is_error:
579
+ # Show with dim line numbers, normal content text
580
+ styled_lines = []
581
+ for line in display.splitlines()[:40]:
582
+ if "\t" in line:
583
+ num, rest = line.split("\t", 1)
584
+ styled_lines.append(f" [dim]{num:>4}[/dim] {rest}")
585
+ else:
586
+ styled_lines.append(f" {line}")
587
+ display = "\n".join(styled_lines)
588
+ if len(output.splitlines()) > 40:
589
+ display += f"\n [dim] ... ({len(output.splitlines())} lines total)[/dim]"
590
+ elif tool_name == "Bash" and not is_error:
591
+ # Highlight stderr in red within bash output
592
+ styled_lines = []
593
+ in_stderr = False
594
+ for line in display.splitlines():
595
+ if line.startswith("STDERR:"):
596
+ in_stderr = True
597
+ styled_lines.append(f"[red]{line}[/red]")
598
+ elif in_stderr and line.startswith("Exit code:"):
599
+ styled_lines.append(f"[yellow]{line}[/yellow]")
600
+ in_stderr = False
601
+ elif in_stderr:
602
+ styled_lines.append(f"[red]{line}[/red]")
603
+ else:
604
+ styled_lines.append(line)
605
+ display = "\n".join(styled_lines)
606
+
607
+ console.print(Panel(
608
+ display,
609
+ title=f"[bold {color}]{icon} {tool_name}[/bold {color}]",
610
+ title_align="left",
611
+ border_style=color,
612
+ padding=(0, 1),
613
+ ))
614
+
615
+
616
+ def _tool_icon(tool_name: str) -> str:
617
+ """Get an icon for a tool name."""
618
+ icons = {
619
+ "Bash": "⚡",
620
+ "Read": "📄",
621
+ "Write": "✏️",
622
+ "Edit": "📝",
623
+ "Glob": "🔍",
624
+ "Grep": "🔎",
625
+ "WebSearch": "🌐",
626
+ "WebFetch": "🌍",
627
+ "Agent": "🤖",
628
+ "TodoWrite": "📋",
629
+ "NotebookEdit": "📓",
630
+ "Skill": "⚙️",
631
+ "ToolSearch": "🔧",
632
+ }
633
+ return icons.get(tool_name, "⚡")
634
+
635
+
636
+ # ---------------------------------------------------------------------------
637
+ # Cost display
638
+ # ---------------------------------------------------------------------------
639
+
640
+ def render_turn_cost(
641
+ console: Console,
642
+ tokens: int,
643
+ cost: float,
644
+ turn: int,
645
+ auth_mode: str = "",
646
+ ) -> None:
647
+ """Render the cost line after a turn."""
648
+ if tokens <= 0:
649
+ return
650
+ if auth_mode == "subscription":
651
+ # No per-token billing — show subscription badge instead of $cost
652
+ cost_part = "[dim #64ffda]Pro/Max[/dim #64ffda]"
653
+ else:
654
+ cost_part = f"[dim cyan]${cost:.4f}[/dim cyan]"
655
+ console.print(
656
+ f" [dim]---[/dim] [dim cyan]Tokens: {tokens:,}[/dim cyan]"
657
+ f" [dim]|[/dim] {cost_part}"
658
+ f" [dim]|[/dim] [dim]Turn {turn}[/dim]"
659
+ )
660
+
661
+
662
+ # ---------------------------------------------------------------------------
663
+ # Permission prompt styling
664
+ # ---------------------------------------------------------------------------
665
+
666
+ def render_session_history(
667
+ console: Console,
668
+ messages: list[Any],
669
+ ) -> None:
670
+ """Replay the full conversation exactly as it looked when it was live.
671
+
672
+ Uses the same render_tool_panel / render_tool_result_panel / Markdown
673
+ rendering that the live REPL uses, so resuming a session looks identical
674
+ to scrolling up in the original conversation.
675
+ """
676
+ import json as _json
677
+
678
+ from rich.markdown import Markdown
679
+
680
+ if not messages:
681
+ return
682
+
683
+ for msg in messages:
684
+ role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
685
+
686
+ # ---- User message ----
687
+ if role == "user":
688
+ # Check if this is a tool-result message (user role with ToolResultBlocks)
689
+ has_tool_results = any(
690
+ hasattr(b, "tool_name") and hasattr(b, "output")
691
+ for b in msg.blocks
692
+ )
693
+ if has_tool_results:
694
+ # Compact inline tool result lines
695
+ for block in msg.blocks:
696
+ if hasattr(block, "tool_name") and hasattr(block, "output"):
697
+ render_tool_result_inline(
698
+ console,
699
+ block.tool_name,
700
+ block.output,
701
+ getattr(block, "is_error", False),
702
+ )
703
+ continue
704
+
705
+ # Regular user text message — render like the prompt line
706
+ text_parts: list[str] = []
707
+ for block in msg.blocks:
708
+ if hasattr(block, "text") and block.text:
709
+ text_parts.append(block.text)
710
+ if not text_parts:
711
+ continue
712
+ user_text = "\n".join(text_parts)
713
+ # Skip internal turn triggers
714
+ if user_text.startswith("__RUN_TURN__:"):
715
+ continue
716
+ console.print()
717
+ console.print(f"[bold #00d4aa]> [/bold #00d4aa]{user_text}")
718
+
719
+ # ---- Assistant message → text + inline tool calls ----
720
+ elif role == "assistant":
721
+ for block in msg.blocks:
722
+ if hasattr(block, "text") and block.text:
723
+ console.print()
724
+ console.print(Markdown(block.text))
725
+
726
+ elif hasattr(block, "name") and hasattr(block, "id"):
727
+ # ToolUseBlock — compact inline display
728
+ tool_name = block.name
729
+ raw_input = block.input if isinstance(block.input, str) else str(block.input)
730
+ try:
731
+ params = _json.loads(raw_input) if raw_input else {}
732
+ except (_json.JSONDecodeError, TypeError):
733
+ params = {"input": raw_input[:200]} if raw_input else {}
734
+ render_tool_call_inline(console, tool_name, params)
735
+
736
+ console.print()
737
+ console.print("[bold #64ffda] Continuing session...[/bold #64ffda]")
738
+ console.print()
739
+
740
+
741
+ def render_permission_panel(
742
+ console: Console,
743
+ tool_name: str,
744
+ mode: str,
745
+ required: str,
746
+ reason: str = "",
747
+ input_preview: str = "",
748
+ ) -> None:
749
+ """Render a permission prompt in a styled panel."""
750
+ lines = [
751
+ f" [bold]Tool:[/bold] {tool_name}",
752
+ f" [bold]Mode:[/bold] {mode} → needs {required}",
753
+ ]
754
+ if reason:
755
+ lines.append(f" [bold]Reason:[/bold] {reason}")
756
+ if input_preview:
757
+ display = input_preview[:250] + "..." if len(input_preview) > 250 else input_preview
758
+ lines.append(f" [bold]Input:[/bold] [dim]{display}[/dim]")
759
+
760
+ console.print(Panel(
761
+ "\n".join(lines),
762
+ title="[bold #00d4aa]⚠ Permission Required[/bold #00d4aa]",
763
+ title_align="left",
764
+ border_style="#00d4aa",
765
+ padding=(0, 1),
766
+ ))