copex 0.8.4__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.
- copex/__init__.py +69 -0
- copex/checkpoint.py +445 -0
- copex/cli.py +1106 -0
- copex/client.py +725 -0
- copex/config.py +311 -0
- copex/mcp.py +561 -0
- copex/metrics.py +383 -0
- copex/models.py +50 -0
- copex/persistence.py +324 -0
- copex/plan.py +358 -0
- copex/ralph.py +247 -0
- copex/tools.py +404 -0
- copex/ui.py +971 -0
- copex-0.8.4.dist-info/METADATA +511 -0
- copex-0.8.4.dist-info/RECORD +18 -0
- copex-0.8.4.dist-info/WHEEL +4 -0
- copex-0.8.4.dist-info/entry_points.txt +2 -0
- copex-0.8.4.dist-info/licenses/LICENSE +21 -0
copex/ui.py
ADDED
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
"""Beautiful CLI UI components for Copex."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.box import ROUNDED
|
|
11
|
+
from rich.console import Console, Group
|
|
12
|
+
from rich.live import Live
|
|
13
|
+
from rich.markdown import Markdown
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from rich.tree import Tree
|
|
18
|
+
|
|
19
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
20
|
+
# Theme and Colors
|
|
21
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
22
|
+
|
|
23
|
+
class Theme:
|
|
24
|
+
"""Color theme for the UI."""
|
|
25
|
+
|
|
26
|
+
# Brand colors
|
|
27
|
+
PRIMARY = "cyan"
|
|
28
|
+
SECONDARY = "blue"
|
|
29
|
+
ACCENT = "magenta"
|
|
30
|
+
|
|
31
|
+
# Status colors
|
|
32
|
+
SUCCESS = "green"
|
|
33
|
+
WARNING = "yellow"
|
|
34
|
+
ERROR = "red"
|
|
35
|
+
INFO = "blue"
|
|
36
|
+
|
|
37
|
+
# Content colors
|
|
38
|
+
REASONING = "dim italic"
|
|
39
|
+
MESSAGE = "white"
|
|
40
|
+
CODE = "bright_white"
|
|
41
|
+
MUTED = "dim"
|
|
42
|
+
|
|
43
|
+
# UI elements
|
|
44
|
+
BORDER = "bright_black"
|
|
45
|
+
BORDER_ACTIVE = "cyan"
|
|
46
|
+
HEADER = "bold cyan"
|
|
47
|
+
SUBHEADER = "bold white"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
THEME_PRESETS = {
|
|
51
|
+
"default": {
|
|
52
|
+
"PRIMARY": "cyan",
|
|
53
|
+
"SECONDARY": "blue",
|
|
54
|
+
"ACCENT": "magenta",
|
|
55
|
+
"SUCCESS": "green",
|
|
56
|
+
"WARNING": "yellow",
|
|
57
|
+
"ERROR": "red",
|
|
58
|
+
"INFO": "blue",
|
|
59
|
+
"REASONING": "dim italic",
|
|
60
|
+
"MESSAGE": "white",
|
|
61
|
+
"CODE": "bright_white",
|
|
62
|
+
"MUTED": "dim",
|
|
63
|
+
"BORDER": "bright_black",
|
|
64
|
+
"BORDER_ACTIVE": "cyan",
|
|
65
|
+
"HEADER": "bold cyan",
|
|
66
|
+
"SUBHEADER": "bold white",
|
|
67
|
+
},
|
|
68
|
+
"midnight": {
|
|
69
|
+
"PRIMARY": "bright_cyan",
|
|
70
|
+
"SECONDARY": "bright_blue",
|
|
71
|
+
"ACCENT": "bright_magenta",
|
|
72
|
+
"SUCCESS": "bright_green",
|
|
73
|
+
"WARNING": "bright_yellow",
|
|
74
|
+
"ERROR": "bright_red",
|
|
75
|
+
"INFO": "bright_blue",
|
|
76
|
+
"REASONING": "dim italic",
|
|
77
|
+
"MESSAGE": "white",
|
|
78
|
+
"CODE": "bright_white",
|
|
79
|
+
"MUTED": "grey70",
|
|
80
|
+
"BORDER": "grey39",
|
|
81
|
+
"BORDER_ACTIVE": "bright_cyan",
|
|
82
|
+
"HEADER": "bold bright_cyan",
|
|
83
|
+
"SUBHEADER": "bold bright_white",
|
|
84
|
+
},
|
|
85
|
+
"mono": {
|
|
86
|
+
"PRIMARY": "white",
|
|
87
|
+
"SECONDARY": "white",
|
|
88
|
+
"ACCENT": "white",
|
|
89
|
+
"SUCCESS": "white",
|
|
90
|
+
"WARNING": "white",
|
|
91
|
+
"ERROR": "white",
|
|
92
|
+
"INFO": "white",
|
|
93
|
+
"REASONING": "dim",
|
|
94
|
+
"MESSAGE": "white",
|
|
95
|
+
"CODE": "white",
|
|
96
|
+
"MUTED": "dim",
|
|
97
|
+
"BORDER": "grey66",
|
|
98
|
+
"BORDER_ACTIVE": "white",
|
|
99
|
+
"HEADER": "bold white",
|
|
100
|
+
"SUBHEADER": "bold white",
|
|
101
|
+
},
|
|
102
|
+
"sunset": {
|
|
103
|
+
"PRIMARY": "bright_yellow",
|
|
104
|
+
"SECONDARY": "bright_red",
|
|
105
|
+
"ACCENT": "bright_magenta",
|
|
106
|
+
"SUCCESS": "green",
|
|
107
|
+
"WARNING": "yellow",
|
|
108
|
+
"ERROR": "red",
|
|
109
|
+
"INFO": "bright_yellow",
|
|
110
|
+
"REASONING": "dim italic",
|
|
111
|
+
"MESSAGE": "white",
|
|
112
|
+
"CODE": "bright_white",
|
|
113
|
+
"MUTED": "grey70",
|
|
114
|
+
"BORDER": "grey39",
|
|
115
|
+
"BORDER_ACTIVE": "bright_yellow",
|
|
116
|
+
"HEADER": "bold bright_yellow",
|
|
117
|
+
"SUBHEADER": "bold bright_white",
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
123
|
+
# Icons and Symbols
|
|
124
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
125
|
+
|
|
126
|
+
class Icons:
|
|
127
|
+
"""Unicode icons for the UI."""
|
|
128
|
+
|
|
129
|
+
# Status
|
|
130
|
+
THINKING = "◐"
|
|
131
|
+
DONE = "✓"
|
|
132
|
+
ERROR = "✗"
|
|
133
|
+
WARNING = "⚠"
|
|
134
|
+
INFO = "ℹ"
|
|
135
|
+
|
|
136
|
+
# Actions
|
|
137
|
+
TOOL = "⚡"
|
|
138
|
+
FILE_READ = "📖"
|
|
139
|
+
FILE_WRITE = "📝"
|
|
140
|
+
FILE_CREATE = "📄"
|
|
141
|
+
SEARCH = "🔍"
|
|
142
|
+
TERMINAL = "💻"
|
|
143
|
+
GLOBE = "🌐"
|
|
144
|
+
|
|
145
|
+
# Navigation
|
|
146
|
+
ARROW_RIGHT = "→"
|
|
147
|
+
ARROW_DOWN = "↓"
|
|
148
|
+
BULLET = "•"
|
|
149
|
+
|
|
150
|
+
# Misc
|
|
151
|
+
SPARKLE = "✨"
|
|
152
|
+
BRAIN = "🧠"
|
|
153
|
+
ROBOT = "🤖"
|
|
154
|
+
LIGHTNING = "⚡"
|
|
155
|
+
CLOCK = "⏱"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
159
|
+
# Data Classes
|
|
160
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
161
|
+
|
|
162
|
+
class ActivityType(str, Enum):
|
|
163
|
+
"""Types of activities to display."""
|
|
164
|
+
THINKING = "thinking"
|
|
165
|
+
REASONING = "reasoning"
|
|
166
|
+
RESPONDING = "responding"
|
|
167
|
+
TOOL_CALL = "tool_call"
|
|
168
|
+
WAITING = "waiting"
|
|
169
|
+
DONE = "done"
|
|
170
|
+
ERROR = "error"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class ToolCallInfo:
|
|
175
|
+
"""Information about a tool call."""
|
|
176
|
+
name: str
|
|
177
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
178
|
+
result: str | None = None
|
|
179
|
+
status: str = "running" # running, success, error
|
|
180
|
+
duration: float | None = None
|
|
181
|
+
started_at: float = field(default_factory=time.time)
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def icon(self) -> str:
|
|
185
|
+
"""Get appropriate icon for the tool."""
|
|
186
|
+
name_lower = self.name.lower()
|
|
187
|
+
if "read" in name_lower or "view" in name_lower:
|
|
188
|
+
return Icons.FILE_READ
|
|
189
|
+
elif "write" in name_lower or "edit" in name_lower:
|
|
190
|
+
return Icons.FILE_WRITE
|
|
191
|
+
elif "create" in name_lower:
|
|
192
|
+
return Icons.FILE_CREATE
|
|
193
|
+
elif "search" in name_lower or "grep" in name_lower or "glob" in name_lower:
|
|
194
|
+
return Icons.SEARCH
|
|
195
|
+
elif "shell" in name_lower or "bash" in name_lower or "powershell" in name_lower:
|
|
196
|
+
return Icons.TERMINAL
|
|
197
|
+
elif "web" in name_lower or "fetch" in name_lower:
|
|
198
|
+
return Icons.GLOBE
|
|
199
|
+
return Icons.TOOL
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def elapsed(self) -> float:
|
|
203
|
+
if self.duration is not None:
|
|
204
|
+
return self.duration
|
|
205
|
+
return time.time() - self.started_at
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@dataclass
|
|
209
|
+
class HistoryEntry:
|
|
210
|
+
"""A single conversation turn."""
|
|
211
|
+
role: str # "user" or "assistant"
|
|
212
|
+
content: str
|
|
213
|
+
reasoning: str | None = None
|
|
214
|
+
tool_calls: list[ToolCallInfo] = field(default_factory=list)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class UIState:
|
|
219
|
+
"""Current state of the UI."""
|
|
220
|
+
activity: ActivityType = ActivityType.WAITING
|
|
221
|
+
reasoning: str = ""
|
|
222
|
+
message: str = ""
|
|
223
|
+
tool_calls: list[ToolCallInfo] = field(default_factory=list)
|
|
224
|
+
start_time: float = field(default_factory=time.time)
|
|
225
|
+
model: str = ""
|
|
226
|
+
retries: int = 0
|
|
227
|
+
last_update: float = field(default_factory=time.time)
|
|
228
|
+
history: list[HistoryEntry] = field(default_factory=list)
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def elapsed(self) -> float:
|
|
232
|
+
return time.time() - self.start_time
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def elapsed_str(self) -> str:
|
|
236
|
+
elapsed = self.elapsed
|
|
237
|
+
if elapsed < 60:
|
|
238
|
+
return f"{elapsed:.1f}s"
|
|
239
|
+
minutes = int(elapsed // 60)
|
|
240
|
+
seconds = elapsed % 60
|
|
241
|
+
return f"{minutes}m {seconds:.0f}s"
|
|
242
|
+
|
|
243
|
+
@property
|
|
244
|
+
def idle(self) -> float:
|
|
245
|
+
return time.time() - self.last_update
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def idle_str(self) -> str:
|
|
249
|
+
idle = self.idle
|
|
250
|
+
if idle < 60:
|
|
251
|
+
return f"{idle:.1f}s"
|
|
252
|
+
minutes = int(idle // 60)
|
|
253
|
+
seconds = idle % 60
|
|
254
|
+
return f"{minutes}m {seconds:.0f}s"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
258
|
+
# UI Components
|
|
259
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
260
|
+
|
|
261
|
+
class CopexUI:
|
|
262
|
+
"""Beautiful UI for Copex CLI."""
|
|
263
|
+
|
|
264
|
+
def __init__(
|
|
265
|
+
self,
|
|
266
|
+
console: Console | None = None,
|
|
267
|
+
*,
|
|
268
|
+
theme: str = "default",
|
|
269
|
+
density: str = "extended",
|
|
270
|
+
show_all_tools: bool = False,
|
|
271
|
+
):
|
|
272
|
+
self.console = console or Console()
|
|
273
|
+
self.set_theme(theme)
|
|
274
|
+
self.density = density
|
|
275
|
+
self.state = UIState()
|
|
276
|
+
self._dirty = True
|
|
277
|
+
self._live: Live | None = None
|
|
278
|
+
self._spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
279
|
+
self._spinner_idx = 0
|
|
280
|
+
self._last_frame_at = 0.0
|
|
281
|
+
self._dot_frames = [".", "..", "..."]
|
|
282
|
+
self.show_all_tools = show_all_tools
|
|
283
|
+
self._max_live_message_chars = 2000 if density == "extended" else 900
|
|
284
|
+
self._max_live_reasoning_chars = 800 if density == "extended" else 320
|
|
285
|
+
|
|
286
|
+
def _get_spinner(self) -> str:
|
|
287
|
+
"""Get current spinner frame."""
|
|
288
|
+
return self._spinners[self._spinner_idx]
|
|
289
|
+
|
|
290
|
+
def _get_dots(self) -> str:
|
|
291
|
+
"""Get current dot animation frame."""
|
|
292
|
+
return self._dot_frames[self._spinner_idx % len(self._dot_frames)]
|
|
293
|
+
|
|
294
|
+
def _advance_frame(self) -> None:
|
|
295
|
+
"""Advance animation frame."""
|
|
296
|
+
now = time.time()
|
|
297
|
+
if now - self._last_frame_at < 0.08:
|
|
298
|
+
return
|
|
299
|
+
self._last_frame_at = now
|
|
300
|
+
self._spinner_idx = (self._spinner_idx + 1) % len(self._spinners)
|
|
301
|
+
|
|
302
|
+
def _build_header(self) -> Text:
|
|
303
|
+
"""Build the header with model and status."""
|
|
304
|
+
header = Text()
|
|
305
|
+
header.append(f"{Icons.ROBOT} ", style=Theme.PRIMARY)
|
|
306
|
+
header.append("Copex", style=Theme.HEADER)
|
|
307
|
+
if self.state.model:
|
|
308
|
+
header.append(f" • {self.state.model}", style=Theme.MUTED)
|
|
309
|
+
header.append(f" • {self.state.elapsed_str}", style=Theme.MUTED)
|
|
310
|
+
if self.state.retries > 0:
|
|
311
|
+
header.append(f" • {self.state.retries} retries", style=Theme.WARNING)
|
|
312
|
+
return header
|
|
313
|
+
|
|
314
|
+
def _build_activity_indicator(self) -> Text:
|
|
315
|
+
"""Build the current activity indicator with fixed width to prevent shifting."""
|
|
316
|
+
indicator = Text()
|
|
317
|
+
dots = self._get_dots()
|
|
318
|
+
spinner = self._get_spinner()
|
|
319
|
+
|
|
320
|
+
# Fixed width for activity text to prevent elapsed time from shifting
|
|
321
|
+
# "Executing tools" is longest at 15 chars + "..." = 18 chars
|
|
322
|
+
activity_width = 18
|
|
323
|
+
|
|
324
|
+
if self.state.activity == ActivityType.THINKING:
|
|
325
|
+
indicator.append(f" {spinner} ", style=f"bold {Theme.PRIMARY}")
|
|
326
|
+
label = f"Thinking{dots}"
|
|
327
|
+
indicator.append(label.ljust(activity_width), style=Theme.PRIMARY)
|
|
328
|
+
elif self.state.activity == ActivityType.REASONING:
|
|
329
|
+
indicator.append(f" {spinner} ", style=f"bold {Theme.ACCENT}")
|
|
330
|
+
label = f"Reasoning{dots}"
|
|
331
|
+
indicator.append(label.ljust(activity_width), style=Theme.ACCENT)
|
|
332
|
+
elif self.state.activity == ActivityType.RESPONDING:
|
|
333
|
+
indicator.append(f" {spinner} ", style=f"bold {Theme.SUCCESS}")
|
|
334
|
+
label = f"Responding{dots}"
|
|
335
|
+
indicator.append(label.ljust(activity_width), style=Theme.SUCCESS)
|
|
336
|
+
elif self.state.activity == ActivityType.TOOL_CALL:
|
|
337
|
+
indicator.append(f" {spinner} ", style=f"bold {Theme.WARNING}")
|
|
338
|
+
label = f"Executing tools{dots}"
|
|
339
|
+
indicator.append(label.ljust(activity_width), style=Theme.WARNING)
|
|
340
|
+
elif self.state.activity == ActivityType.DONE:
|
|
341
|
+
indicator.append(f" {Icons.DONE} ", style=f"bold {Theme.SUCCESS}")
|
|
342
|
+
indicator.append("Complete".ljust(activity_width), style=Theme.SUCCESS)
|
|
343
|
+
elif self.state.activity == ActivityType.ERROR:
|
|
344
|
+
indicator.append(f" {Icons.ERROR} ", style=f"bold {Theme.ERROR}")
|
|
345
|
+
indicator.append("Error".ljust(activity_width), style=Theme.ERROR)
|
|
346
|
+
else:
|
|
347
|
+
indicator.append(f" {spinner} ", style=Theme.MUTED)
|
|
348
|
+
label = f"Waiting{dots}"
|
|
349
|
+
indicator.append(label.ljust(activity_width), style=Theme.MUTED)
|
|
350
|
+
|
|
351
|
+
return indicator
|
|
352
|
+
|
|
353
|
+
def _build_reasoning_panel(self) -> Panel | None:
|
|
354
|
+
"""Build the reasoning panel if there's reasoning content."""
|
|
355
|
+
if not self.state.reasoning:
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
# Truncate for live display
|
|
359
|
+
reasoning = self.state.reasoning
|
|
360
|
+
if len(reasoning) > self._max_live_reasoning_chars:
|
|
361
|
+
reasoning = "..." + reasoning[-self._max_live_reasoning_chars:]
|
|
362
|
+
|
|
363
|
+
content = Text(reasoning, style=Theme.REASONING)
|
|
364
|
+
if self.state.activity == ActivityType.REASONING:
|
|
365
|
+
content.append("▌", style=f"bold {Theme.ACCENT}")
|
|
366
|
+
|
|
367
|
+
return Panel(
|
|
368
|
+
content,
|
|
369
|
+
title=f"[{Theme.ACCENT}]{Icons.BRAIN} Reasoning[/{Theme.ACCENT}]",
|
|
370
|
+
title_align="left",
|
|
371
|
+
border_style=Theme.BORDER_ACTIVE if self.state.activity == ActivityType.REASONING else Theme.BORDER,
|
|
372
|
+
padding=(0, 1),
|
|
373
|
+
box=ROUNDED,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def _build_tool_calls_panel(self) -> Panel | None:
|
|
377
|
+
"""Build the tool calls panel."""
|
|
378
|
+
if not self.state.tool_calls:
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
spinner = self._get_spinner()
|
|
382
|
+
running = sum(1 for t in self.state.tool_calls if t.status == "running")
|
|
383
|
+
successful = sum(1 for t in self.state.tool_calls if t.status == "success")
|
|
384
|
+
failed = sum(1 for t in self.state.tool_calls if t.status == "error")
|
|
385
|
+
title_parts = [f"{Icons.TOOL} Tools"]
|
|
386
|
+
if running:
|
|
387
|
+
title_parts.append(f"{running} running")
|
|
388
|
+
if successful:
|
|
389
|
+
title_parts.append(f"{successful} ok")
|
|
390
|
+
if failed:
|
|
391
|
+
title_parts.append(f"{failed} failed")
|
|
392
|
+
title = f"[{Theme.WARNING}]{' • '.join(title_parts)}[/{Theme.WARNING}]"
|
|
393
|
+
|
|
394
|
+
tree = Tree(f"[{Theme.WARNING}]{Icons.TOOL} Tool Calls[/{Theme.WARNING}]")
|
|
395
|
+
|
|
396
|
+
max_tools = 5 if self.density == "extended" else 3
|
|
397
|
+
tools_to_show = self.state.tool_calls if self.show_all_tools else self.state.tool_calls[-max_tools:]
|
|
398
|
+
for tool in tools_to_show:
|
|
399
|
+
status_style = {
|
|
400
|
+
"running": Theme.WARNING,
|
|
401
|
+
"success": Theme.SUCCESS,
|
|
402
|
+
"error": Theme.ERROR,
|
|
403
|
+
}.get(tool.status, Theme.MUTED)
|
|
404
|
+
|
|
405
|
+
# Build tool info
|
|
406
|
+
tool_text = Text()
|
|
407
|
+
status_icon = spinner if tool.status == "running" else (
|
|
408
|
+
Icons.DONE if tool.status == "success" else Icons.ERROR
|
|
409
|
+
)
|
|
410
|
+
tool_text.append(f"{status_icon} ", style=status_style)
|
|
411
|
+
tool_text.append(f"{tool.icon} ", style=status_style)
|
|
412
|
+
tool_text.append(tool.name, style=f"bold {status_style}")
|
|
413
|
+
|
|
414
|
+
# Add key arguments (truncated)
|
|
415
|
+
if tool.arguments and self.density == "extended":
|
|
416
|
+
args_preview = self._format_args_preview(tool.arguments)
|
|
417
|
+
if args_preview:
|
|
418
|
+
tool_text.append(f" {args_preview}", style=Theme.MUTED)
|
|
419
|
+
|
|
420
|
+
if tool.status == "running":
|
|
421
|
+
tool_text.append(f" ({tool.elapsed:5.1f}s)", style=Theme.MUTED)
|
|
422
|
+
elif tool.duration:
|
|
423
|
+
tool_text.append(f" ({tool.duration:5.1f}s)", style=Theme.MUTED)
|
|
424
|
+
|
|
425
|
+
branch = tree.add(tool_text)
|
|
426
|
+
|
|
427
|
+
# Add result preview if available
|
|
428
|
+
if tool.result and tool.status != "running":
|
|
429
|
+
result_preview = tool.result[:100]
|
|
430
|
+
if len(tool.result) > 100:
|
|
431
|
+
result_preview += "..."
|
|
432
|
+
branch.add(Text(result_preview, style=Theme.MUTED))
|
|
433
|
+
|
|
434
|
+
if len(self.state.tool_calls) > max_tools:
|
|
435
|
+
if self.show_all_tools:
|
|
436
|
+
tree.add(Text("Showing all tools (use /tools to collapse)", style=Theme.MUTED))
|
|
437
|
+
else:
|
|
438
|
+
tree.add(Text(
|
|
439
|
+
f"... and {len(self.state.tool_calls) - max_tools} more (use /tools to expand)",
|
|
440
|
+
style=Theme.MUTED,
|
|
441
|
+
))
|
|
442
|
+
|
|
443
|
+
border_style = Theme.BORDER
|
|
444
|
+
if self.state.activity == ActivityType.TOOL_CALL or running:
|
|
445
|
+
border_style = Theme.BORDER_ACTIVE
|
|
446
|
+
if failed:
|
|
447
|
+
border_style = Theme.ERROR
|
|
448
|
+
|
|
449
|
+
return Panel(
|
|
450
|
+
tree,
|
|
451
|
+
title=title,
|
|
452
|
+
title_align="left",
|
|
453
|
+
border_style=border_style,
|
|
454
|
+
padding=(0, 1),
|
|
455
|
+
box=ROUNDED,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def _format_args_preview(self, args: dict[str, Any], max_len: int = 60) -> str:
|
|
459
|
+
"""Format arguments for preview."""
|
|
460
|
+
if not args:
|
|
461
|
+
return ""
|
|
462
|
+
|
|
463
|
+
parts = []
|
|
464
|
+
for key, value in args.items():
|
|
465
|
+
if key in ("path", "file", "command", "pattern", "query"):
|
|
466
|
+
val_str = str(value)[:40]
|
|
467
|
+
if len(str(value)) > 40:
|
|
468
|
+
val_str += "..."
|
|
469
|
+
parts.append(f"{key}={val_str}")
|
|
470
|
+
|
|
471
|
+
result = " ".join(parts)
|
|
472
|
+
if len(result) > max_len:
|
|
473
|
+
result = result[:max_len] + "..."
|
|
474
|
+
return result
|
|
475
|
+
|
|
476
|
+
def _build_message_panel(self) -> Panel | None:
|
|
477
|
+
"""Build the message panel."""
|
|
478
|
+
if not self.state.message:
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
# Show full message content (no truncation) so box expands with content
|
|
482
|
+
content = Text(self.state.message, style=Theme.MESSAGE)
|
|
483
|
+
if self.state.activity == ActivityType.RESPONDING:
|
|
484
|
+
content.append("▌", style=f"bold {Theme.PRIMARY}")
|
|
485
|
+
|
|
486
|
+
return Panel(
|
|
487
|
+
content,
|
|
488
|
+
title=f"[{Theme.PRIMARY}]{Icons.ROBOT} Response[/{Theme.PRIMARY}]",
|
|
489
|
+
title_align="left",
|
|
490
|
+
border_style=Theme.BORDER_ACTIVE if self.state.activity == ActivityType.RESPONDING else Theme.BORDER,
|
|
491
|
+
padding=(0, 1),
|
|
492
|
+
box=ROUNDED,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
def _build_status_panel(self) -> Panel:
|
|
496
|
+
"""Build a status panel with live progress details."""
|
|
497
|
+
activity = self._build_activity_indicator()
|
|
498
|
+
message_chars = len(self.state.message)
|
|
499
|
+
reasoning_chars = len(self.state.reasoning)
|
|
500
|
+
running_tools = sum(1 for t in self.state.tool_calls if t.status == "running")
|
|
501
|
+
successful_tools = sum(1 for t in self.state.tool_calls if t.status == "success")
|
|
502
|
+
failed_tools = sum(1 for t in self.state.tool_calls if t.status == "error")
|
|
503
|
+
|
|
504
|
+
message_text = Text()
|
|
505
|
+
message_text.append(f"{Icons.ROBOT} ", style=Theme.PRIMARY)
|
|
506
|
+
message_text.append(f"{message_chars} chars", style=Theme.PRIMARY)
|
|
507
|
+
|
|
508
|
+
reasoning_text = Text()
|
|
509
|
+
reasoning_text.append(f"{Icons.BRAIN} ", style=Theme.ACCENT)
|
|
510
|
+
reasoning_text.append(f"{reasoning_chars} chars", style=Theme.ACCENT)
|
|
511
|
+
|
|
512
|
+
tools_text = Text()
|
|
513
|
+
tools_text.append(f"{Icons.TOOL} ", style=Theme.WARNING)
|
|
514
|
+
if not self.state.tool_calls:
|
|
515
|
+
tools_text.append("no tools", style=Theme.MUTED)
|
|
516
|
+
else:
|
|
517
|
+
parts = []
|
|
518
|
+
if running_tools:
|
|
519
|
+
parts.append(f"{running_tools} running")
|
|
520
|
+
if successful_tools:
|
|
521
|
+
parts.append(f"{successful_tools} ok")
|
|
522
|
+
if failed_tools:
|
|
523
|
+
parts.append(f"{failed_tools} failed")
|
|
524
|
+
tools_text.append(" • ".join(parts), style=Theme.WARNING if not failed_tools else Theme.ERROR)
|
|
525
|
+
|
|
526
|
+
elapsed_text = Text()
|
|
527
|
+
elapsed_text.append(f"{Icons.CLOCK} ", style=Theme.MUTED)
|
|
528
|
+
elapsed_text.append(f"{self.state.elapsed_str} elapsed", style=Theme.MUTED)
|
|
529
|
+
|
|
530
|
+
updated_text = Text()
|
|
531
|
+
updated_text.append(f"{Icons.SPARKLE} ", style=Theme.MUTED)
|
|
532
|
+
updated_text.append(f"updated {self.state.idle_str} ago", style=Theme.MUTED)
|
|
533
|
+
|
|
534
|
+
model_text = Text()
|
|
535
|
+
if self.state.model:
|
|
536
|
+
model_text.append(f"{Icons.ROBOT} ", style=Theme.PRIMARY)
|
|
537
|
+
model_text.append(self.state.model, style=Theme.MUTED)
|
|
538
|
+
else:
|
|
539
|
+
model_text.append(f"{Icons.ROBOT} default model", style=Theme.MUTED)
|
|
540
|
+
|
|
541
|
+
retry_text = Text()
|
|
542
|
+
if self.state.retries:
|
|
543
|
+
retry_text.append(f"{Icons.WARNING} ", style=Theme.WARNING)
|
|
544
|
+
retry_text.append(f"{self.state.retries} retries", style=Theme.WARNING)
|
|
545
|
+
else:
|
|
546
|
+
retry_text.append(f"{Icons.DONE} no retries", style=Theme.MUTED)
|
|
547
|
+
|
|
548
|
+
grid = Table.grid(expand=True)
|
|
549
|
+
grid.add_column(justify="left")
|
|
550
|
+
if self.density == "extended":
|
|
551
|
+
grid.add_column(justify="center")
|
|
552
|
+
grid.add_column(justify="right")
|
|
553
|
+
grid.add_row(activity, elapsed_text, updated_text)
|
|
554
|
+
grid.add_row(message_text, reasoning_text, tools_text)
|
|
555
|
+
grid.add_row(model_text, Text(), retry_text)
|
|
556
|
+
else:
|
|
557
|
+
grid.add_column(justify="right")
|
|
558
|
+
grid.add_row(activity, elapsed_text)
|
|
559
|
+
grid.add_row(message_text, tools_text)
|
|
560
|
+
|
|
561
|
+
if self.state.activity == ActivityType.ERROR:
|
|
562
|
+
border_style = Theme.ERROR
|
|
563
|
+
elif self.state.activity == ActivityType.DONE:
|
|
564
|
+
border_style = Theme.SUCCESS
|
|
565
|
+
elif self.state.activity == ActivityType.WAITING:
|
|
566
|
+
border_style = Theme.BORDER
|
|
567
|
+
else:
|
|
568
|
+
border_style = Theme.BORDER_ACTIVE
|
|
569
|
+
|
|
570
|
+
title = f"[{Theme.PRIMARY}]{Icons.ROBOT} Copex[/{Theme.PRIMARY}]"
|
|
571
|
+
if self.state.model:
|
|
572
|
+
title += f" [{Theme.MUTED}]• {self.state.model}[/{Theme.MUTED}]"
|
|
573
|
+
|
|
574
|
+
content = Group(grid, Text()) if self.density == "extended" else grid
|
|
575
|
+
|
|
576
|
+
return Panel(
|
|
577
|
+
content,
|
|
578
|
+
title=title,
|
|
579
|
+
title_align="left",
|
|
580
|
+
border_style=border_style,
|
|
581
|
+
padding=(0, 1),
|
|
582
|
+
box=ROUNDED,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
def build_live_display(self) -> Group:
|
|
586
|
+
"""Build the complete live display."""
|
|
587
|
+
self._advance_frame()
|
|
588
|
+
elements = []
|
|
589
|
+
|
|
590
|
+
# Status panel
|
|
591
|
+
elements.append(self._build_status_panel())
|
|
592
|
+
elements.append(Text()) # Spacer
|
|
593
|
+
|
|
594
|
+
# Reasoning (if any)
|
|
595
|
+
reasoning_panel = self._build_reasoning_panel()
|
|
596
|
+
if reasoning_panel:
|
|
597
|
+
elements.append(reasoning_panel)
|
|
598
|
+
elements.append(Text())
|
|
599
|
+
|
|
600
|
+
# Tool calls (if any)
|
|
601
|
+
tool_panel = self._build_tool_calls_panel()
|
|
602
|
+
if tool_panel:
|
|
603
|
+
elements.append(tool_panel)
|
|
604
|
+
elements.append(Text())
|
|
605
|
+
|
|
606
|
+
# Message (if any)
|
|
607
|
+
message_panel = self._build_message_panel()
|
|
608
|
+
if message_panel:
|
|
609
|
+
elements.append(message_panel)
|
|
610
|
+
|
|
611
|
+
return Group(*elements)
|
|
612
|
+
|
|
613
|
+
def _build_history_panel(self) -> Panel | None:
|
|
614
|
+
"""Build the conversation history panel."""
|
|
615
|
+
if not self.state.history:
|
|
616
|
+
return None
|
|
617
|
+
|
|
618
|
+
elements = []
|
|
619
|
+
for i, entry in enumerate(self.state.history):
|
|
620
|
+
if entry.role == "user":
|
|
621
|
+
# User message
|
|
622
|
+
user_text = Text()
|
|
623
|
+
user_text.append(f"❯ ", style=f"bold {Theme.SUCCESS}")
|
|
624
|
+
# Truncate long user messages
|
|
625
|
+
content = entry.content
|
|
626
|
+
if len(content) > 200:
|
|
627
|
+
content = content[:200] + "..."
|
|
628
|
+
user_text.append(content, style="bold")
|
|
629
|
+
elements.append(user_text)
|
|
630
|
+
elements.append(Text()) # Spacer
|
|
631
|
+
else:
|
|
632
|
+
# Assistant message
|
|
633
|
+
if entry.reasoning and self.density == "extended":
|
|
634
|
+
elements.append(Panel(
|
|
635
|
+
Markdown(entry.reasoning),
|
|
636
|
+
title=f"[{Theme.ACCENT}]{Icons.BRAIN} Reasoning[/{Theme.ACCENT}]",
|
|
637
|
+
title_align="left",
|
|
638
|
+
border_style=Theme.BORDER,
|
|
639
|
+
padding=(0, 1),
|
|
640
|
+
box=ROUNDED,
|
|
641
|
+
))
|
|
642
|
+
elements.append(Text())
|
|
643
|
+
|
|
644
|
+
elements.append(Panel(
|
|
645
|
+
Markdown(entry.content),
|
|
646
|
+
title=f"[{Theme.PRIMARY}]{Icons.ROBOT} Response[/{Theme.PRIMARY}]",
|
|
647
|
+
title_align="left",
|
|
648
|
+
border_style=Theme.BORDER,
|
|
649
|
+
padding=(0, 1),
|
|
650
|
+
box=ROUNDED,
|
|
651
|
+
))
|
|
652
|
+
elements.append(Text()) # Spacer between turns
|
|
653
|
+
|
|
654
|
+
if not elements:
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
return Panel(
|
|
658
|
+
Group(*elements),
|
|
659
|
+
title=f"[{Theme.MUTED}]Conversation History ({len([e for e in self.state.history if e.role == 'user'])} turns)[/{Theme.MUTED}]",
|
|
660
|
+
title_align="left",
|
|
661
|
+
border_style=Theme.BORDER,
|
|
662
|
+
padding=(0, 1),
|
|
663
|
+
box=ROUNDED,
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
def build_final_display(self) -> Group:
|
|
667
|
+
"""Build the final formatted display after streaming completes."""
|
|
668
|
+
elements = []
|
|
669
|
+
|
|
670
|
+
# Reasoning panel (collapsed/summary)
|
|
671
|
+
if self.state.reasoning and self.density == "extended":
|
|
672
|
+
elements.append(Panel(
|
|
673
|
+
Markdown(self.state.reasoning),
|
|
674
|
+
title=f"[{Theme.ACCENT}]{Icons.BRAIN} Reasoning[/{Theme.ACCENT}]",
|
|
675
|
+
title_align="left",
|
|
676
|
+
border_style=Theme.BORDER,
|
|
677
|
+
padding=(0, 1),
|
|
678
|
+
box=ROUNDED,
|
|
679
|
+
))
|
|
680
|
+
elements.append(Text())
|
|
681
|
+
|
|
682
|
+
# Main response with markdown
|
|
683
|
+
if self.state.message:
|
|
684
|
+
elements.append(Panel(
|
|
685
|
+
Markdown(self.state.message),
|
|
686
|
+
title=f"[{Theme.PRIMARY}]{Icons.ROBOT} Response[/{Theme.PRIMARY}]",
|
|
687
|
+
title_align="left",
|
|
688
|
+
border_style=Theme.BORDER_ACTIVE,
|
|
689
|
+
padding=(0, 1),
|
|
690
|
+
box=ROUNDED,
|
|
691
|
+
))
|
|
692
|
+
|
|
693
|
+
# Summary panel
|
|
694
|
+
elements.append(self._build_summary_panel())
|
|
695
|
+
|
|
696
|
+
return Group(*elements)
|
|
697
|
+
|
|
698
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
699
|
+
# Public Methods
|
|
700
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
701
|
+
|
|
702
|
+
def reset(self, model: str = "", preserve_history: bool = False) -> None:
|
|
703
|
+
"""Reset UI state for a new interaction."""
|
|
704
|
+
old_history = self.state.history if preserve_history else []
|
|
705
|
+
self.state = UIState(model=model, history=old_history)
|
|
706
|
+
self._touch()
|
|
707
|
+
|
|
708
|
+
def set_activity(self, activity: ActivityType) -> None:
|
|
709
|
+
"""Set the current activity indicator."""
|
|
710
|
+
self.state.activity = activity
|
|
711
|
+
self._touch()
|
|
712
|
+
|
|
713
|
+
def add_reasoning(self, delta: str) -> None:
|
|
714
|
+
"""Append reasoning content to the live state."""
|
|
715
|
+
self.state.reasoning += delta
|
|
716
|
+
if self.state.activity != ActivityType.REASONING:
|
|
717
|
+
self.state.activity = ActivityType.REASONING
|
|
718
|
+
self._touch()
|
|
719
|
+
|
|
720
|
+
def add_message(self, delta: str) -> None:
|
|
721
|
+
"""Append message content to the live state."""
|
|
722
|
+
self.state.message += delta
|
|
723
|
+
if self.state.activity != ActivityType.RESPONDING:
|
|
724
|
+
self.state.activity = ActivityType.RESPONDING
|
|
725
|
+
self._touch()
|
|
726
|
+
|
|
727
|
+
def add_tool_call(self, tool: ToolCallInfo) -> None:
|
|
728
|
+
"""Track a tool call in the live state."""
|
|
729
|
+
self.state.tool_calls.append(tool)
|
|
730
|
+
self.state.activity = ActivityType.TOOL_CALL
|
|
731
|
+
self._touch()
|
|
732
|
+
|
|
733
|
+
def update_tool_call(self, name: str, status: str, result: str | None = None, duration: float | None = None) -> None:
|
|
734
|
+
"""Update a tool call status and optional result details."""
|
|
735
|
+
for tool in reversed(self.state.tool_calls):
|
|
736
|
+
if tool.name == name and tool.status == "running":
|
|
737
|
+
tool.status = status
|
|
738
|
+
tool.result = result
|
|
739
|
+
tool.duration = duration
|
|
740
|
+
break
|
|
741
|
+
if self.state.activity == ActivityType.TOOL_CALL:
|
|
742
|
+
running_tools = any(tool.status == "running" for tool in self.state.tool_calls)
|
|
743
|
+
if not running_tools:
|
|
744
|
+
self.state.activity = ActivityType.THINKING
|
|
745
|
+
self._touch()
|
|
746
|
+
|
|
747
|
+
def increment_retries(self) -> None:
|
|
748
|
+
"""Increment the retry counter for the active request."""
|
|
749
|
+
self.state.retries += 1
|
|
750
|
+
self._touch()
|
|
751
|
+
|
|
752
|
+
def set_final_content(self, message: str, reasoning: str | None = None) -> None:
|
|
753
|
+
"""Set final message and reasoning content, marking completion."""
|
|
754
|
+
if message:
|
|
755
|
+
self.state.message = message
|
|
756
|
+
if reasoning:
|
|
757
|
+
self.state.reasoning = reasoning
|
|
758
|
+
self.state.activity = ActivityType.DONE
|
|
759
|
+
self._touch()
|
|
760
|
+
|
|
761
|
+
def add_user_message(self, content: str) -> None:
|
|
762
|
+
"""Add a user message to the conversation history."""
|
|
763
|
+
self.state.history.append(HistoryEntry(role="user", content=content))
|
|
764
|
+
self._touch()
|
|
765
|
+
|
|
766
|
+
def finalize_assistant_response(self) -> None:
|
|
767
|
+
"""Finalize the assistant response and store it in history."""
|
|
768
|
+
if self.state.message:
|
|
769
|
+
self.state.history.append(HistoryEntry(
|
|
770
|
+
role="assistant",
|
|
771
|
+
content=self.state.message,
|
|
772
|
+
reasoning=self.state.reasoning if self.state.reasoning else None,
|
|
773
|
+
tool_calls=list(self.state.tool_calls),
|
|
774
|
+
))
|
|
775
|
+
self._touch()
|
|
776
|
+
|
|
777
|
+
def consume_dirty(self) -> bool:
|
|
778
|
+
"""Return whether a redraw is needed and clear the dirty flag."""
|
|
779
|
+
if self._dirty:
|
|
780
|
+
self._dirty = False
|
|
781
|
+
return True
|
|
782
|
+
return False
|
|
783
|
+
|
|
784
|
+
def _touch(self) -> None:
|
|
785
|
+
"""Update last activity timestamp."""
|
|
786
|
+
self.state.last_update = time.time()
|
|
787
|
+
self._dirty = True
|
|
788
|
+
|
|
789
|
+
def _build_summary_panel(self) -> Panel:
|
|
790
|
+
"""Build a summary panel for completed output."""
|
|
791
|
+
summary = Table.grid(expand=True)
|
|
792
|
+
summary.add_column(justify="left")
|
|
793
|
+
summary.add_column(justify="right")
|
|
794
|
+
|
|
795
|
+
elapsed_text = Text()
|
|
796
|
+
elapsed_text.append(f"{Icons.CLOCK} ", style=Theme.MUTED)
|
|
797
|
+
elapsed_text.append(f"{self.state.elapsed_str} elapsed", style=Theme.MUTED)
|
|
798
|
+
|
|
799
|
+
retry_text = Text()
|
|
800
|
+
if self.state.retries:
|
|
801
|
+
retry_text.append(f"{Icons.WARNING} ", style=Theme.WARNING)
|
|
802
|
+
retry_text.append(f"{self.state.retries} retries", style=Theme.WARNING)
|
|
803
|
+
else:
|
|
804
|
+
retry_text.append(f"{Icons.DONE} no retries", style=Theme.MUTED)
|
|
805
|
+
|
|
806
|
+
summary.add_row(elapsed_text, retry_text)
|
|
807
|
+
|
|
808
|
+
if self.state.tool_calls:
|
|
809
|
+
successful = sum(1 for t in self.state.tool_calls if t.status == "success")
|
|
810
|
+
failed = sum(1 for t in self.state.tool_calls if t.status == "error")
|
|
811
|
+
tool_left = Text()
|
|
812
|
+
tool_left.append(f"{Icons.TOOL} ", style=Theme.WARNING)
|
|
813
|
+
tool_left.append(f"{len(self.state.tool_calls)} tool calls", style=Theme.WARNING)
|
|
814
|
+
|
|
815
|
+
tool_right = Text()
|
|
816
|
+
if successful:
|
|
817
|
+
tool_right.append(f"{Icons.DONE} {successful} ok", style=Theme.SUCCESS)
|
|
818
|
+
if failed:
|
|
819
|
+
if tool_right:
|
|
820
|
+
tool_right.append(" • ", style=Theme.MUTED)
|
|
821
|
+
tool_right.append(f"{Icons.ERROR} {failed} failed", style=Theme.ERROR)
|
|
822
|
+
summary.add_row(tool_left, tool_right)
|
|
823
|
+
|
|
824
|
+
return Panel(
|
|
825
|
+
summary,
|
|
826
|
+
title=f"[{Theme.SUCCESS}]{Icons.DONE} Summary[/{Theme.SUCCESS}]",
|
|
827
|
+
title_align="left",
|
|
828
|
+
border_style=Theme.BORDER_ACTIVE,
|
|
829
|
+
padding=(0, 1),
|
|
830
|
+
box=ROUNDED,
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
def _build_progress_bar(self, width: int = 28) -> Text:
|
|
834
|
+
"""Build a smooth animated progress bar."""
|
|
835
|
+
if self.density == "compact":
|
|
836
|
+
width = min(20, width)
|
|
837
|
+
if width < 10:
|
|
838
|
+
width = 10
|
|
839
|
+
pos = (self._spinner_idx // 2) % width
|
|
840
|
+
trail = 1
|
|
841
|
+
bar = ["░"] * width
|
|
842
|
+
for offset in range(trail):
|
|
843
|
+
idx = (pos - offset) % width
|
|
844
|
+
bar[idx] = "█"
|
|
845
|
+
|
|
846
|
+
if self.state.activity == ActivityType.ERROR:
|
|
847
|
+
color = Theme.ERROR
|
|
848
|
+
elif self.state.activity == ActivityType.DONE:
|
|
849
|
+
color = Theme.SUCCESS
|
|
850
|
+
elif self.state.activity == ActivityType.TOOL_CALL:
|
|
851
|
+
color = Theme.WARNING
|
|
852
|
+
elif self.state.activity == ActivityType.REASONING:
|
|
853
|
+
color = Theme.ACCENT
|
|
854
|
+
elif self.state.activity == ActivityType.RESPONDING:
|
|
855
|
+
color = Theme.SUCCESS
|
|
856
|
+
else:
|
|
857
|
+
color = Theme.PRIMARY
|
|
858
|
+
|
|
859
|
+
bar_text = Text()
|
|
860
|
+
bar_text.append("Progress ", style=Theme.MUTED)
|
|
861
|
+
bar_text.append("[" + "".join(bar) + "]", style=color)
|
|
862
|
+
return bar_text
|
|
863
|
+
|
|
864
|
+
def set_theme(self, theme: str) -> None:
|
|
865
|
+
"""Apply a theme preset."""
|
|
866
|
+
apply_theme(theme)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def apply_theme(theme: str) -> None:
|
|
870
|
+
"""Apply a theme preset globally."""
|
|
871
|
+
palette = THEME_PRESETS.get(theme, THEME_PRESETS["default"])
|
|
872
|
+
for key, value in palette.items():
|
|
873
|
+
setattr(Theme, key, value)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
877
|
+
# Utility Functions
|
|
878
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
879
|
+
|
|
880
|
+
def print_welcome(
|
|
881
|
+
console: Console,
|
|
882
|
+
model: str,
|
|
883
|
+
reasoning: str,
|
|
884
|
+
theme: str | None = None,
|
|
885
|
+
density: str | None = None,
|
|
886
|
+
) -> None:
|
|
887
|
+
"""Print the welcome banner."""
|
|
888
|
+
console.print()
|
|
889
|
+
console.print(Panel(
|
|
890
|
+
Text.from_markup(
|
|
891
|
+
f"[{Theme.HEADER}]{Icons.ROBOT} Copex[/{Theme.HEADER}] "
|
|
892
|
+
f"[{Theme.MUTED}]- Copilot Extended[/{Theme.MUTED}]\n\n"
|
|
893
|
+
f"[{Theme.MUTED}]Model:[/{Theme.MUTED}] [{Theme.PRIMARY}]{model}[/{Theme.PRIMARY}]\n"
|
|
894
|
+
f"[{Theme.MUTED}]Reasoning:[/{Theme.MUTED}] [{Theme.PRIMARY}]{reasoning}[/{Theme.PRIMARY}]\n\n"
|
|
895
|
+
f"[{Theme.MUTED}]Type [bold]exit[/bold] to quit, [bold]new[/bold] for fresh session[/{Theme.MUTED}]\n"
|
|
896
|
+
f"[{Theme.MUTED}]Press [bold]Shift+Enter[/bold] for newline[/{Theme.MUTED}]"
|
|
897
|
+
),
|
|
898
|
+
border_style=Theme.BORDER_ACTIVE,
|
|
899
|
+
box=ROUNDED,
|
|
900
|
+
padding=(0, 2),
|
|
901
|
+
))
|
|
902
|
+
console.print()
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def print_user_prompt(console: Console, prompt: str) -> None:
|
|
906
|
+
"""Print the user's prompt."""
|
|
907
|
+
console.print()
|
|
908
|
+
console.print(Text("❯ ", style=f"bold {Theme.SUCCESS}"), end="")
|
|
909
|
+
|
|
910
|
+
# Truncate long prompts for display
|
|
911
|
+
if len(prompt) > 200:
|
|
912
|
+
display_prompt = prompt[:200] + "..."
|
|
913
|
+
else:
|
|
914
|
+
display_prompt = prompt
|
|
915
|
+
console.print(Text(display_prompt, style="bold"))
|
|
916
|
+
console.print()
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def print_error(console: Console, error: str) -> None:
|
|
920
|
+
"""Print an error message."""
|
|
921
|
+
console.print(Panel(
|
|
922
|
+
Text(f"{Icons.ERROR} {error}", style=Theme.ERROR),
|
|
923
|
+
border_style=Theme.ERROR,
|
|
924
|
+
title="Error",
|
|
925
|
+
title_align="left",
|
|
926
|
+
))
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def print_retry(console: Console, attempt: int, max_attempts: int, error: str) -> None:
|
|
930
|
+
"""Print a retry notification."""
|
|
931
|
+
console.print(Text(
|
|
932
|
+
f" {Icons.WARNING} Retry {attempt}/{max_attempts}: {error[:50]}...",
|
|
933
|
+
style=Theme.WARNING,
|
|
934
|
+
))
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def print_tool_call(console: Console, name: str, args: dict[str, Any] | None = None) -> None:
|
|
938
|
+
"""Print a tool call notification."""
|
|
939
|
+
tool = ToolCallInfo(name=name, arguments=args or {})
|
|
940
|
+
|
|
941
|
+
text = Text()
|
|
942
|
+
text.append(f" {tool.icon} ", style=Theme.WARNING)
|
|
943
|
+
text.append(name, style=f"bold {Theme.WARNING}")
|
|
944
|
+
|
|
945
|
+
if args:
|
|
946
|
+
preview = ""
|
|
947
|
+
if "path" in args:
|
|
948
|
+
preview = f" path={args['path']}"
|
|
949
|
+
elif "command" in args:
|
|
950
|
+
cmd = str(args['command'])[:40]
|
|
951
|
+
preview = f" cmd={cmd}..."
|
|
952
|
+
elif "pattern" in args:
|
|
953
|
+
preview = f" pattern={args['pattern']}"
|
|
954
|
+
if preview:
|
|
955
|
+
text.append(preview, style=Theme.MUTED)
|
|
956
|
+
|
|
957
|
+
console.print(text)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def print_tool_result(console: Console, name: str, success: bool, duration: float | None = None) -> None:
|
|
961
|
+
"""Print a tool result notification."""
|
|
962
|
+
icon = Icons.DONE if success else Icons.ERROR
|
|
963
|
+
style = Theme.SUCCESS if success else Theme.ERROR
|
|
964
|
+
|
|
965
|
+
text = Text()
|
|
966
|
+
text.append(f" {icon} ", style=style)
|
|
967
|
+
text.append(name, style=f"bold {style}")
|
|
968
|
+
if duration:
|
|
969
|
+
text.append(f" ({duration:.1f}s)", style=Theme.MUTED)
|
|
970
|
+
|
|
971
|
+
console.print(text)
|