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/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)