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.
- {copex-0.7.1 → copex-0.8.0}/PKG-INFO +1 -1
- {copex-0.7.1 → copex-0.8.0}/pyproject.toml +1 -1
- {copex-0.7.1 → copex-0.8.0}/src/copex/__init__.py +2 -1
- {copex-0.7.1 → copex-0.8.0}/src/copex/cli.py +109 -25
- {copex-0.7.1 → copex-0.8.0}/src/copex/config.py +6 -1
- {copex-0.7.1 → copex-0.8.0}/src/copex/models.py +10 -0
- {copex-0.7.1 → copex-0.8.0}/src/copex/ui.py +145 -26
- {copex-0.7.1 → copex-0.8.0}/.gitignore +0 -0
- {copex-0.7.1 → copex-0.8.0}/LICENSE +0 -0
- {copex-0.7.1 → copex-0.8.0}/README.md +0 -0
- {copex-0.7.1 → copex-0.8.0}/src/copex/checkpoint.py +0 -0
- {copex-0.7.1 → copex-0.8.0}/src/copex/client.py +0 -0
- {copex-0.7.1 → copex-0.8.0}/src/copex/mcp.py +0 -0
- {copex-0.7.1 → copex-0.8.0}/src/copex/metrics.py +0 -0
- {copex-0.7.1 → copex-0.8.0}/src/copex/persistence.py +0 -0
- {copex-0.7.1 → copex-0.8.0}/src/copex/ralph.py +0 -0
- {copex-0.7.1 → copex-0.8.0}/src/copex/tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: copex
|
|
3
|
-
Version: 0.
|
|
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
|
+
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=
|
|
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
|
-
] =
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
618
|
-
if selected
|
|
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
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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) %
|
|
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
|
-
|
|
429
|
-
if
|
|
430
|
-
|
|
431
|
-
|
|
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}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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,
|
|
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.
|
|
787
|
-
elapsed_text.append(f"{self.state.elapsed_str}
|
|
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
|
-
|
|
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}
|
|
935
|
+
title=f"[{Theme.SUCCESS}]{Icons.DONE} Complete[/{Theme.SUCCESS}]",
|
|
817
936
|
title_align="left",
|
|
818
|
-
border_style=Theme.
|
|
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]
|
|
886
|
-
f"[{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
|