copex 0.7.1__tar.gz → 0.8.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: copex
3
- Version: 0.7.1
3
+ Version: 0.8.0
4
4
  Summary: Copilot Extended - Resilient wrapper for GitHub Copilot SDK with auto-retry, Ralph Wiggum loops, and more
5
5
  Project-URL: Homepage, https://github.com/Arthur742Ramos/copex
6
6
  Project-URL: Repository, https://github.com/Arthur742Ramos/copex
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "copex"
7
- version = "0.7.1"
7
+ version = "0.8.0"
8
8
  description = "Copilot Extended - Resilient wrapper for GitHub Copilot SDK with auto-retry, Ralph Wiggum loops, and more"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -10,7 +10,7 @@ from copex.mcp import MCPClient, MCPManager, MCPServerConfig, MCPTool, load_mcp_
10
10
 
11
11
  # Metrics
12
12
  from copex.metrics import MetricsCollector, RequestMetrics, SessionMetrics, get_collector
13
- from copex.models import Model, ReasoningEffort
13
+ from copex.models import Model, ReasoningEffort, supports_reasoning
14
14
 
15
15
  # Persistence
16
16
  from copex.persistence import Message, PersistentSession, SessionData, SessionStore
@@ -27,6 +27,7 @@ __all__ = [
27
27
  "CopexConfig",
28
28
  "Model",
29
29
  "ReasoningEffort",
30
+ "supports_reasoning",
30
31
  "find_copilot_cli",
31
32
  # Ralph
32
33
  "RalphWiggum",
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import sys
7
+ import time
7
8
  from pathlib import Path
8
9
  from typing import Annotated, Optional
9
10
 
@@ -19,7 +20,7 @@ from rich.panel import Panel
19
20
 
20
21
  from copex.client import Copex, StreamChunk
21
22
  from copex.config import CopexConfig, load_last_model, save_last_model
22
- from copex.models import Model, ReasoningEffort
23
+ from copex.models import Model, ReasoningEffort, supports_reasoning
23
24
 
24
25
  # Effective default: last used model or claude-opus-4.5
25
26
  _DEFAULT_MODEL = load_last_model() or Model.CLAUDE_OPUS_4_5
@@ -99,11 +100,11 @@ class SlashCompleter(Completer):
99
100
  yield Completion(cmd, start_position=-len(text))
100
101
 
101
102
 
102
- def _build_prompt_session() -> PromptSession:
103
+ def _build_prompt_session(model: str = "") -> PromptSession:
103
104
  history_path = Path.home() / ".copex" / "history"
104
105
  history_path.parent.mkdir(parents=True, exist_ok=True)
105
106
  bindings = KeyBindings()
106
- commands = ["/model", "/reasoning", "/models", "/new", "/status", "/tools", "/help"]
107
+ commands = ["/model", "/reasoning", "/thinking", "/models", "/new", "/status", "/tools", "/copy", "/export", "/bell", "/help"]
107
108
  completer = SlashCompleter(commands)
108
109
 
109
110
  @bindings.add("enter")
@@ -118,14 +119,18 @@ def _build_prompt_session() -> PromptSession:
118
119
  def _(event) -> None:
119
120
  event.app.current_buffer.insert_text("\n")
120
121
 
122
+ # Model indicator in prompt
123
+ prompt_msg = f"[{model}] > " if model else "copilot> "
124
+
121
125
  return PromptSession(
122
- message="copilot> ",
126
+ message=prompt_msg,
123
127
  history=FileHistory(str(history_path)),
124
128
  key_bindings=bindings,
125
129
  completer=completer,
126
130
  complete_while_typing=True,
127
131
  multiline=True,
128
132
  prompt_continuation=lambda width, line_number, is_soft_wrap: "... ",
133
+ enable_history_search=True, # Ctrl+R for history search
129
134
  )
130
135
 
131
136
 
@@ -265,8 +270,8 @@ def chat(
265
270
  bool, typer.Option("--no-stream", help="Disable streaming output")
266
271
  ] = False,
267
272
  show_reasoning: Annotated[
268
- bool, typer.Option("--show-reasoning/--no-reasoning", help="Show model reasoning")
269
- ] = True,
273
+ bool, typer.Option("--show-reasoning/--no-reasoning", help="Show model reasoning (GPT models only)")
274
+ ] = False,
270
275
  config_file: Annotated[
271
276
  Optional[Path], typer.Option("--config", "-c", help="Config file path")
272
277
  ] = None,
@@ -325,18 +330,20 @@ async def _run_chat(
325
330
  ) -> None:
326
331
  """Run the chat command."""
327
332
  client = Copex(config)
333
+ # Only show reasoning for GPT models
334
+ effective_show_reasoning = show_reasoning and supports_reasoning(config.model)
328
335
 
329
336
  try:
330
337
  await client.start()
331
338
 
332
339
  if config.streaming and not raw:
333
- await _stream_response(client, prompt, show_reasoning)
340
+ await _stream_response(client, prompt, effective_show_reasoning)
334
341
  else:
335
342
  response = await client.send(prompt)
336
343
  if raw:
337
344
  print(response.content)
338
345
  else:
339
- if show_reasoning and response.reasoning:
346
+ if effective_show_reasoning and response.reasoning:
340
347
  console.print(Panel(
341
348
  Markdown(response.reasoning),
342
349
  title="[dim]Reasoning[/dim]",
@@ -517,6 +524,9 @@ def interactive(
517
524
  reasoning: Annotated[
518
525
  str, typer.Option("--reasoning", "-r", help="Reasoning effort level")
519
526
  ] = ReasoningEffort.XHIGH.value,
527
+ show_reasoning: Annotated[
528
+ bool, typer.Option("--show-reasoning/--no-reasoning", help="Show model reasoning (GPT models only)")
529
+ ] = False,
520
530
  ui_theme: Annotated[
521
531
  Optional[str], typer.Option("--ui-theme", help="UI theme (default, midnight, mono, sunset)")
522
532
  ] = None,
@@ -542,14 +552,14 @@ def interactive(
542
552
  theme=config.ui_theme,
543
553
  density=config.ui_density,
544
554
  )
545
- asyncio.run(_interactive_loop(config))
555
+ asyncio.run(_interactive_loop(config, show_reasoning))
546
556
 
547
557
 
548
- async def _interactive_loop(config: CopexConfig) -> None:
558
+ async def _interactive_loop(config: CopexConfig, show_reasoning: bool = False) -> None:
549
559
  """Run interactive chat loop."""
550
560
  client = Copex(config)
551
561
  await client.start()
552
- session = _build_prompt_session()
562
+ session = _build_prompt_session(config.model.value)
553
563
  show_all_tools = False
554
564
 
555
565
  # Create persistent UI for conversation history
@@ -560,21 +570,38 @@ async def _interactive_loop(config: CopexConfig) -> None:
560
570
  show_all_tools=show_all_tools,
561
571
  )
562
572
 
573
+ def rebuild_session() -> None:
574
+ """Rebuild prompt session with updated model."""
575
+ nonlocal session
576
+ session = _build_prompt_session(client.config.model.value)
577
+
563
578
  def show_help() -> None:
564
579
  console.print(f"\n[{Theme.MUTED}]Commands:[/{Theme.MUTED}]")
565
580
  console.print(f" [{Theme.PRIMARY}]/model <name>[/{Theme.PRIMARY}] - Change model (e.g., /model gpt-5.1-codex)")
566
- console.print(f" [{Theme.PRIMARY}]/reasoning <level>[/{Theme.PRIMARY}] - Change reasoning (low, medium, high, xhigh)")
581
+ if supports_reasoning(client.config.model):
582
+ console.print(f" [{Theme.PRIMARY}]/reasoning <level>[/{Theme.PRIMARY}] - Change reasoning (low, medium, high, xhigh)")
583
+ console.print(f" [{Theme.PRIMARY}]/thinking[/{Theme.PRIMARY}] - Toggle reasoning display")
567
584
  console.print(f" [{Theme.PRIMARY}]/models[/{Theme.PRIMARY}] - List available models")
568
- console.print(f" [{Theme.PRIMARY}]/new[/{Theme.PRIMARY}] - Start new session")
585
+ console.print(f" [{Theme.PRIMARY}]/new[/{Theme.PRIMARY}] - Start new session (or Ctrl+N)")
569
586
  console.print(f" [{Theme.PRIMARY}]/status[/{Theme.PRIMARY}] - Show current settings")
570
587
  console.print(f" [{Theme.PRIMARY}]/tools[/{Theme.PRIMARY}] - Toggle full tool call list")
588
+ console.print(f" [{Theme.PRIMARY}]/copy[/{Theme.PRIMARY}] - Copy last response to clipboard")
589
+ console.print(f" [{Theme.PRIMARY}]/export[/{Theme.PRIMARY}] - Export conversation as markdown")
590
+ console.print(f" [{Theme.PRIMARY}]/bell[/{Theme.PRIMARY}] - Toggle sound on completion")
571
591
  console.print(f" [{Theme.PRIMARY}]/help[/{Theme.PRIMARY}] - Show this help")
572
- console.print(f" [{Theme.PRIMARY}]exit[/{Theme.PRIMARY}] - Exit\n")
592
+ console.print(f" [{Theme.PRIMARY}]exit[/{Theme.PRIMARY}] - Exit")
593
+ console.print(f"\n[{Theme.MUTED}]Shortcuts: Ctrl+R (history search), Shift+Enter (newline)[/{Theme.MUTED}]\n")
573
594
 
574
595
  def show_status() -> None:
575
596
  console.print(f"\n[{Theme.MUTED}]Current settings:[/{Theme.MUTED}]")
576
597
  console.print(f" Model: [{Theme.PRIMARY}]{client.config.model.value}[/{Theme.PRIMARY}]")
577
- console.print(f" Reasoning: [{Theme.PRIMARY}]{client.config.reasoning_effort.value}[/{Theme.PRIMARY}]\n")
598
+ if supports_reasoning(client.config.model):
599
+ console.print(f" Reasoning: [{Theme.PRIMARY}]{client.config.reasoning_effort.value}[/{Theme.PRIMARY}]")
600
+ console.print(f" Thinking: [{Theme.PRIMARY}]{'on' if show_reasoning else 'off'}[/{Theme.PRIMARY}]")
601
+ console.print(f" Bell: [{Theme.PRIMARY}]{'on' if ui._bell_on_complete else 'off'}[/{Theme.PRIMARY}]")
602
+ if ui.state.total_tokens > 0:
603
+ console.print(f" Tokens: [{Theme.PRIMARY}]{ui.state.total_tokens:,}[/{Theme.PRIMARY}] (~${ui.state.estimated_cost:.4f})")
604
+ console.print()
578
605
 
579
606
  try:
580
607
  while True:
@@ -614,14 +641,15 @@ async def _interactive_loop(config: CopexConfig) -> None:
614
641
  if selected and selected != client.config.model:
615
642
  client.config.model = selected
616
643
  save_last_model(selected) # Persist for next run
617
- # Prompt for reasoning effort if GPT model
618
- if selected.value.startswith("gpt-"):
644
+ # Prompt for reasoning effort only for GPT models
645
+ if supports_reasoning(selected):
619
646
  new_reasoning = await _reasoning_picker(client.config.reasoning_effort)
620
647
  if new_reasoning:
621
648
  client.config.reasoning_effort = new_reasoning
622
649
  client.new_session()
623
650
  # Clear UI history for new session
624
651
  ui.state.history = []
652
+ rebuild_session() # Update prompt with new model
625
653
  console.print(f"\n[{Theme.SUCCESS}]{Icons.DONE} Switched to {selected.value} (new session started)[/{Theme.SUCCESS}]\n")
626
654
  continue
627
655
 
@@ -645,6 +673,7 @@ async def _interactive_loop(config: CopexConfig) -> None:
645
673
  client.new_session() # Need new session for model change
646
674
  # Clear UI history for new session
647
675
  ui.state.history = []
676
+ rebuild_session() # Update prompt with new model
648
677
  console.print(f"\n[{Theme.SUCCESS}]{Icons.DONE} Switched to {new_model.value} (new session started)[/{Theme.SUCCESS}]\n")
649
678
  except ValueError:
650
679
  console.print(f"[{Theme.ERROR}]Unknown model: {model_name}[/{Theme.ERROR}]")
@@ -652,6 +681,9 @@ async def _interactive_loop(config: CopexConfig) -> None:
652
681
  continue
653
682
 
654
683
  if command.startswith("/reasoning ") or command.startswith("reasoning "):
684
+ if not supports_reasoning(client.config.model):
685
+ console.print(f"[{Theme.MUTED}]Reasoning effort is only supported for GPT models[/{Theme.MUTED}]")
686
+ continue
655
687
  parts = prompt.split(maxsplit=1)
656
688
  if len(parts) < 2:
657
689
  console.print(f"[{Theme.ERROR}]Usage: /reasoning <level>[/{Theme.ERROR}]")
@@ -669,9 +701,56 @@ async def _interactive_loop(config: CopexConfig) -> None:
669
701
  console.print(f"[{Theme.ERROR}]Invalid reasoning level. Valid: {valid}[/{Theme.ERROR}]")
670
702
  continue
671
703
 
704
+ if command in {"thinking", "/thinking"}:
705
+ if not supports_reasoning(client.config.model):
706
+ console.print(f"[{Theme.MUTED}]Thinking display is only available for GPT models[/{Theme.MUTED}]")
707
+ continue
708
+ show_reasoning = not show_reasoning
709
+ mode = "on" if show_reasoning else "off"
710
+ console.print(f"\n[{Theme.SUCCESS}]{Icons.DONE} Thinking display {mode}[/{Theme.SUCCESS}]\n")
711
+ continue
712
+
713
+ if command in {"copy", "/copy"}:
714
+ last_response = ui.get_last_response()
715
+ if last_response:
716
+ try:
717
+ import subprocess
718
+ # Try pbcopy (macOS), xclip (Linux), or clip (Windows)
719
+ if sys.platform == "darwin":
720
+ subprocess.run(["pbcopy"], input=last_response.encode(), check=True)
721
+ elif sys.platform == "win32":
722
+ subprocess.run(["clip"], input=last_response.encode(), check=True)
723
+ else:
724
+ subprocess.run(["xclip", "-selection", "clipboard"], input=last_response.encode(), check=True)
725
+ console.print(f"\n[{Theme.SUCCESS}]{Icons.DONE} Copied to clipboard ({len(last_response)} chars)[/{Theme.SUCCESS}]\n")
726
+ except Exception as e:
727
+ console.print(f"[{Theme.ERROR}]Failed to copy: {e}[/{Theme.ERROR}]")
728
+ else:
729
+ console.print(f"[{Theme.MUTED}]No response to copy[/{Theme.MUTED}]")
730
+ continue
731
+
732
+ if command in {"export", "/export"}:
733
+ markdown = ui.export_conversation()
734
+ if markdown and len(ui.state.history) > 0:
735
+ export_path = Path.home() / ".copex" / "exports"
736
+ export_path.mkdir(parents=True, exist_ok=True)
737
+ filename = f"conversation_{time.strftime('%Y%m%d_%H%M%S')}.md"
738
+ filepath = export_path / filename
739
+ filepath.write_text(markdown)
740
+ console.print(f"\n[{Theme.SUCCESS}]{Icons.DONE} Exported to {filepath}[/{Theme.SUCCESS}]\n")
741
+ else:
742
+ console.print(f"[{Theme.MUTED}]No conversation to export[/{Theme.MUTED}]")
743
+ continue
744
+
745
+ if command in {"bell", "/bell"}:
746
+ is_on = ui.toggle_bell()
747
+ mode = "on" if is_on else "off"
748
+ console.print(f"\n[{Theme.SUCCESS}]{Icons.DONE} Bell on completion {mode}[/{Theme.SUCCESS}]\n")
749
+ continue
750
+
672
751
  try:
673
752
  print_user_prompt(console, prompt)
674
- await _stream_response_interactive(client, prompt, ui)
753
+ await _stream_response_interactive(client, prompt, ui, show_reasoning)
675
754
  except Exception as e:
676
755
  print_error(console, str(e))
677
756
 
@@ -685,6 +764,7 @@ async def _stream_response_interactive(
685
764
  client: Copex,
686
765
  prompt: str,
687
766
  ui: CopexUI,
767
+ show_reasoning: bool = False,
688
768
  ) -> None:
689
769
  """Stream response with beautiful UI in interactive mode."""
690
770
  # Add user message to history
@@ -693,6 +773,9 @@ async def _stream_response_interactive(
693
773
  # Reset for new turn but preserve history
694
774
  ui.reset(model=client.config.model.value, preserve_history=True)
695
775
  ui.set_activity(ActivityType.THINKING)
776
+
777
+ # Only show reasoning for GPT models when enabled
778
+ effective_show_reasoning = show_reasoning and supports_reasoning(client.config.model)
696
779
 
697
780
  live_display: Live | None = None
698
781
  refresh_stop = asyncio.Event()
@@ -700,14 +783,15 @@ async def _stream_response_interactive(
700
783
  def on_chunk(chunk: StreamChunk) -> None:
701
784
  if chunk.type == "message":
702
785
  if chunk.is_final:
703
- ui.set_final_content(chunk.content or ui.state.message, ui.state.reasoning)
786
+ ui.set_final_content(chunk.content or ui.state.message, ui.state.reasoning if effective_show_reasoning else None)
704
787
  else:
705
788
  ui.add_message(chunk.delta)
706
789
  elif chunk.type == "reasoning":
707
- if chunk.is_final:
708
- pass
709
- else:
710
- ui.add_reasoning(chunk.delta)
790
+ if effective_show_reasoning:
791
+ if chunk.is_final:
792
+ pass
793
+ else:
794
+ ui.add_reasoning(chunk.delta)
711
795
  elif chunk.type == "tool_call":
712
796
  tool = ToolCallInfo(
713
797
  name=chunk.tool_name or "unknown",
@@ -743,7 +827,7 @@ async def _stream_response_interactive(
743
827
  response = await client.send(prompt, on_chunk=on_chunk)
744
828
  # Prefer streamed content over response object (which may have stale fallback)
745
829
  final_message = ui.state.message if ui.state.message else response.content
746
- final_reasoning = ui.state.reasoning if ui.state.reasoning else response.reasoning
830
+ final_reasoning = (ui.state.reasoning if ui.state.reasoning else response.reasoning) if effective_show_reasoning else None
747
831
  ui.set_final_content(final_message, final_reasoning)
748
832
  ui.state.retries = response.retries
749
833
  finally:
@@ -957,7 +1041,7 @@ def status() -> None:
957
1041
  console.print("Install: [bold]https://cli.github.com/[/bold]")
958
1042
 
959
1043
 
960
- __version__ = "0.7.1"
1044
+ __version__ = "0.8.0"
961
1045
 
962
1046
 
963
1047
  if __name__ == "__main__":
@@ -269,12 +269,17 @@ class CopexConfig(BaseModel):
269
269
 
270
270
  def to_session_options(self) -> dict[str, Any]:
271
271
  """Convert to create_session options."""
272
+ from copex.models import supports_reasoning
273
+
272
274
  opts: dict[str, Any] = {
273
275
  "model": self.model.value,
274
- "model_reasoning_effort": self.reasoning_effort.value,
275
276
  "streaming": self.streaming,
276
277
  }
277
278
 
279
+ # Only include reasoning_effort for GPT models
280
+ if supports_reasoning(self.model):
281
+ opts["model_reasoning_effort"] = self.reasoning_effort.value
282
+
278
283
  # Skills
279
284
  if self.skills:
280
285
  opts["skills"] = self.skills
@@ -21,6 +21,16 @@ class Model(str, Enum):
21
21
  CLAUDE_OPUS_4_5 = "claude-opus-4.5"
22
22
  GEMINI_3_PRO = "gemini-3-pro-preview"
23
23
 
24
+ def supports_reasoning(self) -> bool:
25
+ """Check if this model supports reasoning effort."""
26
+ return self.value.startswith("gpt-")
27
+
28
+
29
+ def supports_reasoning(model: Model | str) -> bool:
30
+ """Check if a model supports reasoning effort."""
31
+ model_str = model.value if isinstance(model, Model) else model
32
+ return model_str.startswith("gpt-")
33
+
24
34
 
25
35
  class ReasoningEffort(str, Enum):
26
36
  """Reasoning effort levels for supported models."""
@@ -226,9 +226,16 @@ class UIState:
226
226
  retries: int = 0
227
227
  last_update: float = field(default_factory=time.time)
228
228
  history: list[HistoryEntry] = field(default_factory=list)
229
+ # Token tracking
230
+ input_tokens: int = 0
231
+ output_tokens: int = 0
232
+ # Completion time
233
+ completed_at: float | None = None
229
234
 
230
235
  @property
231
236
  def elapsed(self) -> float:
237
+ if self.completed_at:
238
+ return self.completed_at - self.start_time
232
239
  return time.time() - self.start_time
233
240
 
234
241
  @property
@@ -253,6 +260,16 @@ class UIState:
253
260
  seconds = idle % 60
254
261
  return f"{minutes}m {seconds:.0f}s"
255
262
 
263
+ @property
264
+ def total_tokens(self) -> int:
265
+ return self.input_tokens + self.output_tokens
266
+
267
+ @property
268
+ def estimated_cost(self) -> float:
269
+ """Rough cost estimate (varies by model)."""
270
+ # Approximate pricing: $0.01 per 1K tokens average
271
+ return self.total_tokens * 0.00001
272
+
256
273
 
257
274
  # ═══════════════════════════════════════════════════════════════════════════════
258
275
  # UI Components
@@ -261,6 +278,15 @@ class UIState:
261
278
  class CopexUI:
262
279
  """Beautiful UI for Copex CLI."""
263
280
 
281
+ # Different spinners for different activities
282
+ SPINNERS = {
283
+ "thinking": ["🤔", "💭", "🧠", "💡"],
284
+ "reasoning": ["🔍", "📊", "🔬", "📈"],
285
+ "tools": ["⚙️", "🔧", "🛠️", "⚡"],
286
+ "responding": ["✍️", "📝", "💬", "📢"],
287
+ "default": ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
288
+ }
289
+
264
290
  def __init__(
265
291
  self,
266
292
  console: Console | None = None,
@@ -274,17 +300,29 @@ class CopexUI:
274
300
  self.density = density
275
301
  self.state = UIState()
276
302
  self._live: Live | None = None
277
- self._spinners = ["", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
303
+ self._spinners = self.SPINNERS["default"]
278
304
  self._spinner_idx = 0
279
305
  self._last_frame_at = 0.0
280
306
  self._dot_frames = [".", "..", "..."]
281
307
  self.show_all_tools = show_all_tools
282
308
  self._max_live_message_chars = 2000 if density == "extended" else 900
283
309
  self._max_live_reasoning_chars = 800 if density == "extended" else 320
310
+ self._bell_on_complete = False # Can be toggled
284
311
 
285
312
  def _get_spinner(self) -> str:
286
- """Get current spinner frame."""
287
- return self._spinners[self._spinner_idx]
313
+ """Get current spinner frame based on current activity."""
314
+ activity = self.state.activity
315
+ if activity == ActivityType.THINKING:
316
+ spinners = self.SPINNERS["thinking"]
317
+ elif activity == ActivityType.REASONING:
318
+ spinners = self.SPINNERS["reasoning"]
319
+ elif activity == ActivityType.TOOL_CALL:
320
+ spinners = self.SPINNERS["tools"]
321
+ elif activity == ActivityType.RESPONDING:
322
+ spinners = self.SPINNERS["responding"]
323
+ else:
324
+ spinners = self.SPINNERS["default"]
325
+ return spinners[self._spinner_idx % len(spinners)]
288
326
 
289
327
  def _get_dots(self) -> str:
290
328
  """Get current dot animation frame."""
@@ -293,10 +331,10 @@ class CopexUI:
293
331
  def _advance_frame(self) -> None:
294
332
  """Advance animation frame."""
295
333
  now = time.time()
296
- if now - self._last_frame_at < 0.08:
334
+ if now - self._last_frame_at < 0.15: # Slower for emoji spinners
297
335
  return
298
336
  self._last_frame_at = now
299
- self._spinner_idx = (self._spinner_idx + 1) % len(self._spinners)
337
+ self._spinner_idx = (self._spinner_idx + 1) % 10 # Common denominator
300
338
 
301
339
  def _build_header(self) -> Text:
302
340
  """Build the header with model and status."""
@@ -423,12 +461,22 @@ class CopexUI:
423
461
 
424
462
  branch = tree.add(tool_text)
425
463
 
426
- # Add result preview if available
464
+ # Add result preview if available (truncated but useful)
427
465
  if tool.result and tool.status != "running":
428
- result_preview = tool.result[:100]
429
- if len(tool.result) > 100:
430
- result_preview += "..."
431
- branch.add(Text(result_preview, style=Theme.MUTED))
466
+ lines = tool.result.strip().split("\n")
467
+ max_preview_lines = 3 if self.density == "extended" else 1
468
+ preview_lines = lines[:max_preview_lines]
469
+
470
+ for line in preview_lines:
471
+ # Truncate long lines
472
+ if len(line) > 80:
473
+ line = line[:77] + "..."
474
+ if line.strip():
475
+ branch.add(Text(f" {line}", style=Theme.MUTED))
476
+
477
+ remaining = len(lines) - max_preview_lines
478
+ if remaining > 0:
479
+ branch.add(Text(f" ... +{remaining} more lines", style=Theme.MUTED))
432
480
 
433
481
  if len(self.state.tool_calls) > max_tools:
434
482
  if self.show_all_tools:
@@ -524,11 +572,18 @@ class CopexUI:
524
572
 
525
573
  elapsed_text = Text()
526
574
  elapsed_text.append(f"{Icons.CLOCK} ", style=Theme.MUTED)
527
- elapsed_text.append(f"{self.state.elapsed_str} elapsed", style=Theme.MUTED)
528
-
529
- updated_text = Text()
530
- updated_text.append(f"{Icons.SPARKLE} ", style=Theme.MUTED)
531
- updated_text.append(f"updated {self.state.idle_str} ago", style=Theme.MUTED)
575
+ elapsed_text.append(f"{self.state.elapsed_str}", style=Theme.MUTED)
576
+
577
+ # Token/cost display
578
+ tokens_text = Text()
579
+ if self.state.total_tokens > 0:
580
+ tokens_text.append("💰 ", style=Theme.SUCCESS)
581
+ tokens_text.append(f"{self.state.total_tokens:,} tokens", style=Theme.SUCCESS)
582
+ if self.state.estimated_cost > 0:
583
+ tokens_text.append(f" (~${self.state.estimated_cost:.4f})", style=Theme.MUTED)
584
+ else:
585
+ tokens_text.append("💰 ", style=Theme.MUTED)
586
+ tokens_text.append("0 tokens", style=Theme.MUTED)
532
587
 
533
588
  model_text = Text()
534
589
  if self.state.model:
@@ -549,7 +604,7 @@ class CopexUI:
549
604
  if self.density == "extended":
550
605
  grid.add_column(justify="center")
551
606
  grid.add_column(justify="right")
552
- grid.add_row(activity, elapsed_text, updated_text)
607
+ grid.add_row(activity, elapsed_text, tokens_text)
553
608
  grid.add_row(message_text, reasoning_text, tools_text)
554
609
  grid.add_row(model_text, Text(), retry_text)
555
610
  else:
@@ -747,6 +802,18 @@ class CopexUI:
747
802
  self.state.retries += 1
748
803
  self._touch()
749
804
 
805
+ def set_tokens(self, input_tokens: int = 0, output_tokens: int = 0) -> None:
806
+ """Set token counts."""
807
+ self.state.input_tokens = input_tokens
808
+ self.state.output_tokens = output_tokens
809
+ self._touch()
810
+
811
+ def add_tokens(self, input_tokens: int = 0, output_tokens: int = 0) -> None:
812
+ """Add to token counts."""
813
+ self.state.input_tokens += input_tokens
814
+ self.state.output_tokens += output_tokens
815
+ self._touch()
816
+
750
817
  def set_final_content(self, message: str, reasoning: str | None = None) -> None:
751
818
  """Set final content."""
752
819
  if message:
@@ -754,8 +821,51 @@ class CopexUI:
754
821
  if reasoning:
755
822
  self.state.reasoning = reasoning
756
823
  self.state.activity = ActivityType.DONE
824
+ self.state.completed_at = time.time()
825
+ # Ring bell if enabled
826
+ if self._bell_on_complete:
827
+ print("\a", end="", flush=True)
757
828
  self._touch()
758
829
 
830
+ def toggle_bell(self) -> bool:
831
+ """Toggle bell on completion. Returns new state."""
832
+ self._bell_on_complete = not self._bell_on_complete
833
+ return self._bell_on_complete
834
+
835
+ def get_last_response(self) -> str | None:
836
+ """Get the last assistant response for clipboard."""
837
+ return self.state.message if self.state.message else None
838
+
839
+ def export_conversation(self) -> str:
840
+ """Export conversation as markdown."""
841
+ lines = ["# Copex Conversation\n"]
842
+ lines.append(f"**Model:** {self.state.model}\n")
843
+ lines.append(f"**Date:** {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
844
+ lines.append("---\n")
845
+
846
+ for entry in self.state.history:
847
+ if entry.role == "user":
848
+ lines.append(f"## 👤 User\n\n{entry.content}\n")
849
+ else:
850
+ lines.append(f"## 🤖 Assistant\n\n{entry.content}\n")
851
+ if entry.reasoning:
852
+ lines.append(f"\n<details>\n<summary>Reasoning</summary>\n\n{entry.reasoning}\n</details>\n")
853
+ if entry.tool_calls:
854
+ lines.append("\n**Tool Calls:**\n")
855
+ for tool in entry.tool_calls:
856
+ status = "✅" if tool.status == "success" else "❌" if tool.status == "error" else "🔄"
857
+ lines.append(f"- {status} `{tool.name}`")
858
+ if tool.duration:
859
+ lines.append(f" ({tool.duration:.2f}s)")
860
+ lines.append("\n")
861
+ lines.append("\n---\n")
862
+
863
+ # Add current response if not in history
864
+ if self.state.message and (not self.state.history or self.state.history[-1].content != self.state.message):
865
+ lines.append(f"## 🤖 Assistant\n\n{self.state.message}\n")
866
+
867
+ return "".join(lines)
868
+
759
869
  def add_user_message(self, content: str) -> None:
760
870
  """Add a user message to history."""
761
871
  self.state.history.append(HistoryEntry(role="user", content=content))
@@ -783,17 +893,26 @@ class CopexUI:
783
893
  summary.add_column(justify="right")
784
894
 
785
895
  elapsed_text = Text()
786
- elapsed_text.append(f"{Icons.CLOCK} ", style=Theme.MUTED)
787
- elapsed_text.append(f"{self.state.elapsed_str} elapsed", style=Theme.MUTED)
896
+ elapsed_text.append(f"{Icons.CLOCK} ", style=Theme.SUCCESS)
897
+ elapsed_text.append(f"{self.state.elapsed_str}", style=Theme.SUCCESS)
898
+
899
+ # Token/cost summary
900
+ tokens_text = Text()
901
+ if self.state.total_tokens > 0:
902
+ tokens_text.append("💰 ", style=Theme.SUCCESS)
903
+ tokens_text.append(f"{self.state.total_tokens:,} tokens", style=Theme.SUCCESS)
904
+ if self.state.estimated_cost > 0.0001:
905
+ tokens_text.append(f" (~${self.state.estimated_cost:.4f})", style=Theme.MUTED)
906
+ else:
907
+ tokens_text.append("", style=Theme.MUTED)
908
+
909
+ summary.add_row(elapsed_text, tokens_text)
788
910
 
789
911
  retry_text = Text()
790
912
  if self.state.retries:
791
913
  retry_text.append(f"{Icons.WARNING} ", style=Theme.WARNING)
792
914
  retry_text.append(f"{self.state.retries} retries", style=Theme.WARNING)
793
- else:
794
- retry_text.append(f"{Icons.DONE} no retries", style=Theme.MUTED)
795
-
796
- summary.add_row(elapsed_text, retry_text)
915
+ summary.add_row(retry_text, Text())
797
916
 
798
917
  if self.state.tool_calls:
799
918
  successful = sum(1 for t in self.state.tool_calls if t.status == "success")
@@ -813,9 +932,9 @@ class CopexUI:
813
932
 
814
933
  return Panel(
815
934
  summary,
816
- title=f"[{Theme.SUCCESS}]{Icons.DONE} Summary[/{Theme.SUCCESS}]",
935
+ title=f"[{Theme.SUCCESS}]{Icons.DONE} Complete[/{Theme.SUCCESS}]",
817
936
  title_align="left",
818
- border_style=Theme.BORDER_ACTIVE,
937
+ border_style=Theme.SUCCESS,
819
938
  padding=(0, 1),
820
939
  box=ROUNDED,
821
940
  )
@@ -882,8 +1001,8 @@ def print_welcome(
882
1001
  f"[{Theme.MUTED}]- Copilot Extended[/{Theme.MUTED}]\n\n"
883
1002
  f"[{Theme.MUTED}]Model:[/{Theme.MUTED}] [{Theme.PRIMARY}]{model}[/{Theme.PRIMARY}]\n"
884
1003
  f"[{Theme.MUTED}]Reasoning:[/{Theme.MUTED}] [{Theme.PRIMARY}]{reasoning}[/{Theme.PRIMARY}]\n\n"
885
- f"[{Theme.MUTED}]Type [bold]exit[/bold] to quit, [bold]new[/bold] for fresh session[/{Theme.MUTED}]\n"
886
- f"[{Theme.MUTED}]Press [bold]Shift+Enter[/bold] for newline[/{Theme.MUTED}]"
1004
+ f"[{Theme.MUTED}]Type [bold]/help[/bold] for commands, [bold]exit[/bold] to quit[/{Theme.MUTED}]\n"
1005
+ f"[{Theme.MUTED}][bold]Ctrl+R[/bold] history search, [bold]Shift+Enter[/bold] newline[/{Theme.MUTED}]"
887
1006
  ),
888
1007
  border_style=Theme.BORDER_ACTIVE,
889
1008
  box=ROUNDED,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes