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.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
))
|