thwip-cli 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thwip/__init__.py +4 -0
- thwip/__main__.py +6 -0
- thwip/agents/__init__.py +98 -0
- thwip/agents/base.py +343 -0
- thwip/agents/claude_agent.py +346 -0
- thwip/agents/deepseek_agent.py +217 -0
- thwip/agents/google_agent.py +314 -0
- thwip/agents/groq_agent.py +181 -0
- thwip/agents/ollama_agent.py +180 -0
- thwip/agents/openai_agent.py +338 -0
- thwip/agents/openrouter_agent.py +192 -0
- thwip/cli.py +593 -0
- thwip/config.py +363 -0
- thwip/detector.py +259 -0
- thwip/limits.py +84 -0
- thwip/session.py +175 -0
- thwip/shortcuts.py +75 -0
- thwip/theme.py +380 -0
- thwip/tools/__init__.py +184 -0
- thwip/tools/code_runner.py +62 -0
- thwip/tools/file_editor.py +92 -0
- thwip/tools/git_ops.py +44 -0
- thwip/tools/terminal.py +65 -0
- thwip/utils.py +110 -0
- thwip_cli-1.0.0.dist-info/METADATA +157 -0
- thwip_cli-1.0.0.dist-info/RECORD +28 -0
- thwip_cli-1.0.0.dist-info/WHEEL +4 -0
- thwip_cli-1.0.0.dist-info/entry_points.txt +2 -0
thwip/session.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session & conversation state manager for thwip.
|
|
3
|
+
|
|
4
|
+
Enables seamless context portability across agent switches without losing history.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from thwip.config import get_sessions_dir
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Message:
|
|
22
|
+
"""A single message in the conversation history."""
|
|
23
|
+
role: str # "user" | "assistant" | "system" | "tool"
|
|
24
|
+
content: str
|
|
25
|
+
timestamp: float = field(default_factory=time.time)
|
|
26
|
+
agent_name: str = "" # Which agent created this (e.g. "claude")
|
|
27
|
+
model: str = "" # Which model (e.g. "claude-sonnet-4")
|
|
28
|
+
company: str = "" # e.g. "Anthropic"
|
|
29
|
+
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
30
|
+
tokens: int = 0
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, Any]:
|
|
33
|
+
return asdict(self)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_dict(cls, data: dict[str, Any]) -> "Message":
|
|
37
|
+
return cls(**data)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Session:
|
|
42
|
+
"""A complete conversation session."""
|
|
43
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
|
|
44
|
+
name: str = "new-session"
|
|
45
|
+
project_path: str = "."
|
|
46
|
+
current_agent: str = "claude"
|
|
47
|
+
current_model: str = "claude-sonnet-4"
|
|
48
|
+
system_prompt: str = (
|
|
49
|
+
"You are an expert AI software engineer. You have tools to inspect files, "
|
|
50
|
+
"write code, run commands, and build software. Be concise, precise, and proactive."
|
|
51
|
+
)
|
|
52
|
+
created_at: float = field(default_factory=time.time)
|
|
53
|
+
updated_at: float = field(default_factory=time.time)
|
|
54
|
+
messages: list[Message] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
def add_user_message(self, content: str) -> Message:
|
|
57
|
+
msg = Message(role="user", content=content)
|
|
58
|
+
self.messages.append(msg)
|
|
59
|
+
self.updated_at = time.time()
|
|
60
|
+
return msg
|
|
61
|
+
|
|
62
|
+
def add_assistant_message(
|
|
63
|
+
self,
|
|
64
|
+
content: str,
|
|
65
|
+
agent_name: str,
|
|
66
|
+
model: str,
|
|
67
|
+
company: str = "",
|
|
68
|
+
tokens: int = 0,
|
|
69
|
+
) -> Message:
|
|
70
|
+
msg = Message(
|
|
71
|
+
role="assistant",
|
|
72
|
+
content=content,
|
|
73
|
+
agent_name=agent_name,
|
|
74
|
+
model=model,
|
|
75
|
+
company=company,
|
|
76
|
+
tokens=tokens,
|
|
77
|
+
)
|
|
78
|
+
self.messages.append(msg)
|
|
79
|
+
self.updated_at = time.time()
|
|
80
|
+
return msg
|
|
81
|
+
|
|
82
|
+
def to_portable_messages(self) -> list[dict[str, Any]]:
|
|
83
|
+
"""
|
|
84
|
+
Convert messages to clean LLM-compatible format:
|
|
85
|
+
[{"role": "user"|"assistant", "content": "..."}]
|
|
86
|
+
"""
|
|
87
|
+
portable = []
|
|
88
|
+
for m in self.messages:
|
|
89
|
+
if m.role in ("user", "assistant"):
|
|
90
|
+
portable.append({"role": m.role, "content": m.content})
|
|
91
|
+
return portable
|
|
92
|
+
|
|
93
|
+
def switch_agent(self, agent_name: str, model: str) -> None:
|
|
94
|
+
"""Switch current agent and model while preserving full history."""
|
|
95
|
+
self.current_agent = agent_name
|
|
96
|
+
self.current_model = model
|
|
97
|
+
self.updated_at = time.time()
|
|
98
|
+
|
|
99
|
+
def get_total_tokens(self) -> int:
|
|
100
|
+
return sum(m.tokens for m in self.messages)
|
|
101
|
+
|
|
102
|
+
def save(self, custom_name: str | None = None) -> Path:
|
|
103
|
+
"""Save session to ~/.thwip/sessions/<id>.json."""
|
|
104
|
+
if custom_name:
|
|
105
|
+
self.name = custom_name
|
|
106
|
+
sessions_dir = get_sessions_dir()
|
|
107
|
+
file_path = sessions_dir / f"{self.name}.json"
|
|
108
|
+
data = {
|
|
109
|
+
"id": self.id,
|
|
110
|
+
"name": self.name,
|
|
111
|
+
"project_path": self.project_path,
|
|
112
|
+
"current_agent": self.current_agent,
|
|
113
|
+
"current_model": self.current_model,
|
|
114
|
+
"system_prompt": self.system_prompt,
|
|
115
|
+
"created_at": self.created_at,
|
|
116
|
+
"updated_at": self.updated_at,
|
|
117
|
+
"messages": [m.to_dict() for m in self.messages],
|
|
118
|
+
}
|
|
119
|
+
file_path.write_text(json.dumps(data, indent=2))
|
|
120
|
+
return file_path
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def load(cls, name_or_id: str) -> "Session | None":
|
|
124
|
+
"""Load session by name or id."""
|
|
125
|
+
sessions_dir = get_sessions_dir()
|
|
126
|
+
path = sessions_dir / f"{name_or_id}.json"
|
|
127
|
+
if not path.is_file():
|
|
128
|
+
# Try searching all sessions
|
|
129
|
+
for f in sessions_dir.glob("*.json"):
|
|
130
|
+
try:
|
|
131
|
+
d = json.loads(f.read_text())
|
|
132
|
+
if d.get("id") == name_or_id or d.get("name") == name_or_id:
|
|
133
|
+
path = f
|
|
134
|
+
break
|
|
135
|
+
except Exception:
|
|
136
|
+
continue
|
|
137
|
+
if not path.is_file():
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
data = json.loads(path.read_text())
|
|
142
|
+
session = cls(
|
|
143
|
+
id=data.get("id", ""),
|
|
144
|
+
name=data.get("name", "saved-session"),
|
|
145
|
+
project_path=data.get("project_path", "."),
|
|
146
|
+
current_agent=data.get("current_agent", "claude"),
|
|
147
|
+
current_model=data.get("current_model", "claude-sonnet-4"),
|
|
148
|
+
system_prompt=data.get("system_prompt", ""),
|
|
149
|
+
created_at=data.get("created_at", time.time()),
|
|
150
|
+
updated_at=data.get("updated_at", time.time()),
|
|
151
|
+
messages=[Message.from_dict(m) for m in data.get("messages", [])],
|
|
152
|
+
)
|
|
153
|
+
return session
|
|
154
|
+
except Exception:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def list_saved_sessions(cls) -> list[dict[str, Any]]:
|
|
159
|
+
"""List all saved sessions with summary stats."""
|
|
160
|
+
sessions_dir = get_sessions_dir()
|
|
161
|
+
result = []
|
|
162
|
+
for f in sessions_dir.glob("*.json"):
|
|
163
|
+
try:
|
|
164
|
+
data = json.loads(f.read_text())
|
|
165
|
+
result.append({
|
|
166
|
+
"id": data.get("id"),
|
|
167
|
+
"name": data.get("name", f.stem),
|
|
168
|
+
"agent": data.get("current_agent"),
|
|
169
|
+
"model": data.get("current_model"),
|
|
170
|
+
"messages_count": len(data.get("messages", [])),
|
|
171
|
+
"updated_at": datetime.fromtimestamp(data.get("updated_at", 0)).strftime("%Y-%m-%d %H:%M"),
|
|
172
|
+
})
|
|
173
|
+
except Exception:
|
|
174
|
+
continue
|
|
175
|
+
return sorted(result, key=lambda x: x["updated_at"], reverse=True)
|
thwip/shortcuts.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Keyboard shortcut bindings & autocompletion for thwip terminal interface.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
8
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SLASH_COMMANDS = [
|
|
12
|
+
("/switch", "Switch agent and model mid-conversation"),
|
|
13
|
+
("/agents", "Show all detected coding agents & status"),
|
|
14
|
+
("/models", "List available models for current agent"),
|
|
15
|
+
("/status", "Display current session and agent info"),
|
|
16
|
+
("/limits", "View token usage, quota, and spend metrics"),
|
|
17
|
+
("/detect", "Re-scan system for newly installed coding agents"),
|
|
18
|
+
("/session save", "Save current chat session"),
|
|
19
|
+
("/session load", "Resume a previously saved session"),
|
|
20
|
+
("/session list", "List all saved sessions"),
|
|
21
|
+
("/session clear", "Clear current conversation context"),
|
|
22
|
+
("/history", "Show conversation with model attribution badges"),
|
|
23
|
+
("/project", "View or change project working directory"),
|
|
24
|
+
("/cost", "Show estimated cost breakdown"),
|
|
25
|
+
("/help", "Show all available commands and keyboard shortcuts"),
|
|
26
|
+
("/quit", "Exit thwip"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ThwipCompleter(Completer):
|
|
31
|
+
"""Provides smart auto-completion for slash commands and agent names."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, agent_names: list[str] | None = None) -> None:
|
|
34
|
+
self.agent_names = agent_names or ["claude", "google", "openai", "deepseek", "groq", "ollama", "openrouter"]
|
|
35
|
+
|
|
36
|
+
def get_completions(self, document, complete_event):
|
|
37
|
+
text = document.text_before_cursor
|
|
38
|
+
if text.startswith("/"):
|
|
39
|
+
# Command autocompletion
|
|
40
|
+
for cmd, desc in SLASH_COMMANDS:
|
|
41
|
+
if cmd.startswith(text):
|
|
42
|
+
yield Completion(cmd, start_position=-len(text), display_meta=desc)
|
|
43
|
+
|
|
44
|
+
# /switch <agent> autocompletion
|
|
45
|
+
if text.startswith("/switch "):
|
|
46
|
+
parts = text.split(" ")
|
|
47
|
+
prefix = parts[1] if len(parts) > 1 else ""
|
|
48
|
+
for name in self.agent_names:
|
|
49
|
+
if name.startswith(prefix):
|
|
50
|
+
yield Completion(f"/switch {name}", start_position=-len(text), display_meta="Agent")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create_keybindings(on_switch=None, on_status=None, on_history=None) -> KeyBindings:
|
|
54
|
+
"""Create keybindings for prompt_toolkit."""
|
|
55
|
+
kb = KeyBindings()
|
|
56
|
+
|
|
57
|
+
@kb.add("c-s")
|
|
58
|
+
def _switch(event):
|
|
59
|
+
"""Ctrl+S: Quick switch prompt."""
|
|
60
|
+
event.app.current_buffer.text = "/switch "
|
|
61
|
+
event.app.current_buffer.cursor_position = len(event.app.current_buffer.text)
|
|
62
|
+
|
|
63
|
+
@kb.add("c-t")
|
|
64
|
+
def _status(event):
|
|
65
|
+
"""Ctrl+T: Status view."""
|
|
66
|
+
event.app.current_buffer.text = "/status"
|
|
67
|
+
event.app.current_buffer.validate_and_handle()
|
|
68
|
+
|
|
69
|
+
@kb.add("c-h")
|
|
70
|
+
def _history(event):
|
|
71
|
+
"""Ctrl+H: Show history."""
|
|
72
|
+
event.app.current_buffer.text = "/history"
|
|
73
|
+
event.app.current_buffer.validate_and_handle()
|
|
74
|
+
|
|
75
|
+
return kb
|
thwip/theme.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Theme and terminal styling for thwip.
|
|
3
|
+
|
|
4
|
+
Each agent/company gets its own brand color, and the UI dynamically
|
|
5
|
+
adapts based on which agent is active and what capabilities it has.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from rich.theme import Theme
|
|
18
|
+
from rich.markdown import Markdown
|
|
19
|
+
from rich.syntax import Syntax
|
|
20
|
+
from rich import box
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from thwip.agents.base import BaseAgent, Capability
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Company / Provider Brand Colors
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class BrandTheme:
|
|
32
|
+
"""Visual identity for an agent's company."""
|
|
33
|
+
primary: str # Main brand color
|
|
34
|
+
secondary: str # Accent color
|
|
35
|
+
label_style: str # Rich style string for labels
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
BRAND_THEMES: dict[str, BrandTheme] = {
|
|
39
|
+
"anthropic": BrandTheme(
|
|
40
|
+
primary="#D97757",
|
|
41
|
+
secondary="#F5E6D3",
|
|
42
|
+
label_style="bold #D97757",
|
|
43
|
+
),
|
|
44
|
+
"google": BrandTheme(
|
|
45
|
+
primary="#4285F4",
|
|
46
|
+
secondary="#E8F0FE",
|
|
47
|
+
label_style="bold #4285F4",
|
|
48
|
+
),
|
|
49
|
+
"openai": BrandTheme(
|
|
50
|
+
primary="#10A37F",
|
|
51
|
+
secondary="#D1FAE5",
|
|
52
|
+
label_style="bold #10A37F",
|
|
53
|
+
),
|
|
54
|
+
"deepseek": BrandTheme(
|
|
55
|
+
primary="#4D6BFE",
|
|
56
|
+
secondary="#E0E7FF",
|
|
57
|
+
label_style="bold #4D6BFE",
|
|
58
|
+
),
|
|
59
|
+
"mistral": BrandTheme(
|
|
60
|
+
primary="#FF7000",
|
|
61
|
+
secondary="#FFF3E0",
|
|
62
|
+
label_style="bold #FF7000",
|
|
63
|
+
),
|
|
64
|
+
"groq": BrandTheme(
|
|
65
|
+
primary="#F55036",
|
|
66
|
+
secondary="#FEE2E2",
|
|
67
|
+
label_style="bold #F55036",
|
|
68
|
+
),
|
|
69
|
+
"ollama": BrandTheme(
|
|
70
|
+
primary="#E5E7EB",
|
|
71
|
+
secondary="#374151",
|
|
72
|
+
label_style="bold #E5E7EB",
|
|
73
|
+
),
|
|
74
|
+
"openrouter": BrandTheme(
|
|
75
|
+
primary="#8B5CF6",
|
|
76
|
+
secondary="#EDE9FE",
|
|
77
|
+
label_style="bold #8B5CF6",
|
|
78
|
+
),
|
|
79
|
+
"default": BrandTheme(
|
|
80
|
+
primary="#A78BFA",
|
|
81
|
+
secondary="#1E1B2E",
|
|
82
|
+
label_style="bold #A78BFA",
|
|
83
|
+
),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_brand(company: str) -> BrandTheme:
|
|
88
|
+
"""Get brand theme for a company, falling back to default."""
|
|
89
|
+
return BRAND_THEMES.get(company.lower(), BRAND_THEMES["default"])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Rich Console Setup
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
THWIP_THEME = Theme({
|
|
97
|
+
"info": "dim cyan",
|
|
98
|
+
"warning": "bold yellow",
|
|
99
|
+
"error": "bold red",
|
|
100
|
+
"success": "bold green",
|
|
101
|
+
"muted": "dim",
|
|
102
|
+
"user.label": "bold cyan",
|
|
103
|
+
"agent.label": "bold magenta",
|
|
104
|
+
"capability.yes": "bold green",
|
|
105
|
+
"capability.no": "dim red",
|
|
106
|
+
"capability.warn": "bold yellow",
|
|
107
|
+
"status.ready": "bold green",
|
|
108
|
+
"status.no_key": "bold red",
|
|
109
|
+
"status.limited": "bold yellow",
|
|
110
|
+
"status.local": "bold cyan",
|
|
111
|
+
"status.ide": "dim yellow",
|
|
112
|
+
"badge": "bold dim",
|
|
113
|
+
"prompt": "bold white",
|
|
114
|
+
"header": "bold white on #1E1B2E",
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
console = Console(theme=THWIP_THEME)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# UI Rendering Helpers
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def render_startup_banner(
|
|
125
|
+
agent_name: str,
|
|
126
|
+
company: str,
|
|
127
|
+
model: str,
|
|
128
|
+
project_path: str,
|
|
129
|
+
session_name: str,
|
|
130
|
+
agents_detected: int,
|
|
131
|
+
agents_ready: int,
|
|
132
|
+
) -> Panel:
|
|
133
|
+
"""Render the startup banner with current agent info."""
|
|
134
|
+
brand = get_brand(company)
|
|
135
|
+
|
|
136
|
+
lines = Text()
|
|
137
|
+
lines.append("thwip", style="bold white")
|
|
138
|
+
lines.append(" v1.0\n", style="dim")
|
|
139
|
+
lines.append("Universal Coding Agent Multiplexer\n\n", style="dim italic")
|
|
140
|
+
lines.append(" Agent: ", style="dim")
|
|
141
|
+
lines.append(f"{agent_name}", style=brand.label_style)
|
|
142
|
+
lines.append(f" ({company})\n", style="dim")
|
|
143
|
+
lines.append(" Model: ", style="dim")
|
|
144
|
+
lines.append(f"{model}\n", style="bold white")
|
|
145
|
+
lines.append(" Project: ", style="dim")
|
|
146
|
+
lines.append(f"{project_path}\n", style="white")
|
|
147
|
+
lines.append(" Session: ", style="dim")
|
|
148
|
+
lines.append(f"{session_name}\n", style="white")
|
|
149
|
+
lines.append(" Agents: ", style="dim")
|
|
150
|
+
lines.append(f"{agents_detected}", style="bold white")
|
|
151
|
+
lines.append(" detected, ", style="dim")
|
|
152
|
+
lines.append(f"{agents_ready}", style="success")
|
|
153
|
+
lines.append(" ready", style="dim")
|
|
154
|
+
|
|
155
|
+
return Panel(
|
|
156
|
+
lines,
|
|
157
|
+
border_style=brand.primary,
|
|
158
|
+
box=box.ROUNDED,
|
|
159
|
+
padding=(1, 2),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def render_agent_badge(agent_name: str, model: str, company: str) -> Text:
|
|
164
|
+
"""Render an inline badge for message attribution."""
|
|
165
|
+
brand = get_brand(company)
|
|
166
|
+
badge = Text()
|
|
167
|
+
badge.append("[", style="dim")
|
|
168
|
+
badge.append(f"{model}", style=brand.label_style)
|
|
169
|
+
badge.append("]", style="dim")
|
|
170
|
+
return badge
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def render_agents_table(agents: list[dict]) -> Table:
|
|
174
|
+
"""
|
|
175
|
+
Render the detected agents table.
|
|
176
|
+
|
|
177
|
+
Each agent dict should have:
|
|
178
|
+
name, company, status, status_style, models, capabilities, subscription
|
|
179
|
+
"""
|
|
180
|
+
table = Table(
|
|
181
|
+
title="Detected Agents",
|
|
182
|
+
box=box.ROUNDED,
|
|
183
|
+
border_style="dim",
|
|
184
|
+
title_style="bold white",
|
|
185
|
+
show_lines=True,
|
|
186
|
+
padding=(0, 1),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
table.add_column("Agent", style="bold white", min_width=14)
|
|
190
|
+
table.add_column("Company", style="dim", min_width=12)
|
|
191
|
+
table.add_column("Status", min_width=10)
|
|
192
|
+
table.add_column("Models", style="white", min_width=16)
|
|
193
|
+
table.add_column("Capabilities", min_width=24)
|
|
194
|
+
table.add_column("Subscription", min_width=12)
|
|
195
|
+
|
|
196
|
+
for agent in agents:
|
|
197
|
+
status_text = Text(agent.get("status", "Unknown"))
|
|
198
|
+
status_text.stylize(agent.get("status_style", "dim"))
|
|
199
|
+
|
|
200
|
+
cap_text = Text()
|
|
201
|
+
caps = agent.get("capabilities", [])
|
|
202
|
+
for i, cap in enumerate(caps):
|
|
203
|
+
if i > 0:
|
|
204
|
+
cap_text.append(", ", style="dim")
|
|
205
|
+
cap_text.append(cap, style="capability.yes")
|
|
206
|
+
|
|
207
|
+
missing = agent.get("missing_capabilities", [])
|
|
208
|
+
for i, cap in enumerate(missing):
|
|
209
|
+
if caps or i > 0:
|
|
210
|
+
cap_text.append(", ", style="dim")
|
|
211
|
+
cap_text.append(f"-{cap}-", style="capability.no")
|
|
212
|
+
|
|
213
|
+
sub = agent.get("subscription", "Unknown")
|
|
214
|
+
sub_style = "success" if sub in ("Active", "Free", "Unlimited") else "warning" if sub == "Unknown" else "error"
|
|
215
|
+
sub_text = Text(sub, style=sub_style)
|
|
216
|
+
|
|
217
|
+
table.add_row(
|
|
218
|
+
agent["name"],
|
|
219
|
+
agent.get("company", "-"),
|
|
220
|
+
status_text,
|
|
221
|
+
agent.get("models", "-"),
|
|
222
|
+
cap_text,
|
|
223
|
+
sub_text,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return table
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def render_capability_disclaimer(
|
|
230
|
+
agent_name: str,
|
|
231
|
+
company: str,
|
|
232
|
+
supported: list[str],
|
|
233
|
+
unsupported: list[str],
|
|
234
|
+
) -> Panel:
|
|
235
|
+
"""Render a capability disclaimer when switching agents."""
|
|
236
|
+
brand = get_brand(company)
|
|
237
|
+
content = Text()
|
|
238
|
+
|
|
239
|
+
content.append("Switched to ", style="dim")
|
|
240
|
+
content.append(f"{agent_name}", style=brand.label_style)
|
|
241
|
+
content.append(f" ({company})\n\n", style="dim")
|
|
242
|
+
|
|
243
|
+
if supported:
|
|
244
|
+
content.append(" Supported: ", style="success")
|
|
245
|
+
content.append(", ".join(supported), style="white")
|
|
246
|
+
content.append("\n")
|
|
247
|
+
|
|
248
|
+
if unsupported:
|
|
249
|
+
content.append(" Not available: ", style="error")
|
|
250
|
+
content.append(", ".join(unsupported), style="dim red")
|
|
251
|
+
content.append("\n\n")
|
|
252
|
+
content.append(
|
|
253
|
+
" Notice: This agent may not support editing files, running code,\n"
|
|
254
|
+
" or other actions that your previous agent could do.\n"
|
|
255
|
+
" Chat capabilities will continue.",
|
|
256
|
+
style="warning",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
border_style = "yellow" if unsupported else brand.primary
|
|
260
|
+
return Panel(
|
|
261
|
+
content,
|
|
262
|
+
title="Agent Switch",
|
|
263
|
+
title_align="left",
|
|
264
|
+
border_style=border_style,
|
|
265
|
+
box=box.ROUNDED,
|
|
266
|
+
padding=(0, 2),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def render_limit_warning(
|
|
271
|
+
agent_name: str,
|
|
272
|
+
company: str,
|
|
273
|
+
error_type: str,
|
|
274
|
+
alternatives: list[dict],
|
|
275
|
+
) -> Panel:
|
|
276
|
+
"""Render the limit exhaustion warning with alternative suggestions."""
|
|
277
|
+
brand = get_brand(company)
|
|
278
|
+
content = Text()
|
|
279
|
+
|
|
280
|
+
content.append(f"Notice: {agent_name}", style=brand.label_style)
|
|
281
|
+
if error_type == "quota_exhausted":
|
|
282
|
+
content.append(" - quota exhausted for this billing period.\n\n", style="warning")
|
|
283
|
+
elif error_type == "rate_limited":
|
|
284
|
+
content.append(" - rate limited. Retry in a moment, or switch.\n\n", style="warning")
|
|
285
|
+
else:
|
|
286
|
+
content.append(f" - {error_type}\n\n", style="warning")
|
|
287
|
+
|
|
288
|
+
if alternatives:
|
|
289
|
+
content.append("Ready alternatives:\n", style="dim")
|
|
290
|
+
for i, alt in enumerate(alternatives, 1):
|
|
291
|
+
alt_brand = get_brand(alt.get("company", ""))
|
|
292
|
+
content.append(f" {i}. ", style="bold white")
|
|
293
|
+
content.append(f"{alt['agent']}", style=alt_brand.label_style)
|
|
294
|
+
content.append(f" -> {alt['model']}", style="white")
|
|
295
|
+
caps = alt.get("capabilities", [])
|
|
296
|
+
if caps:
|
|
297
|
+
content.append(f" ({', '.join(caps)})", style="dim green")
|
|
298
|
+
content.append("\n")
|
|
299
|
+
|
|
300
|
+
return Panel(
|
|
301
|
+
content,
|
|
302
|
+
border_style="yellow",
|
|
303
|
+
box=box.ROUNDED,
|
|
304
|
+
padding=(0, 2),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def render_dynamic_status_bar(
|
|
309
|
+
agent_name: str,
|
|
310
|
+
company: str,
|
|
311
|
+
model: str,
|
|
312
|
+
capabilities: list[str],
|
|
313
|
+
tokens_used: int = 0,
|
|
314
|
+
cost: float = 0.0,
|
|
315
|
+
) -> Text:
|
|
316
|
+
"""Render the bottom status bar based on active agent capabilities."""
|
|
317
|
+
brand = get_brand(company)
|
|
318
|
+
bar = Text()
|
|
319
|
+
|
|
320
|
+
bar.append(f" {agent_name}", style=brand.label_style)
|
|
321
|
+
bar.append(" | ", style="dim")
|
|
322
|
+
bar.append(f"{model}", style="bold white")
|
|
323
|
+
bar.append(" | ", style="dim")
|
|
324
|
+
|
|
325
|
+
cap_labels = [
|
|
326
|
+
("chat", "chat"),
|
|
327
|
+
("file_edit", "edit"),
|
|
328
|
+
("code_run", "run"),
|
|
329
|
+
("terminal", "term"),
|
|
330
|
+
("git", "git"),
|
|
331
|
+
("browser", "web"),
|
|
332
|
+
]
|
|
333
|
+
for cap_key, label in cap_labels:
|
|
334
|
+
if cap_key in capabilities:
|
|
335
|
+
bar.append(f"[{label}] ", style="success")
|
|
336
|
+
else:
|
|
337
|
+
bar.append(f"[{label}] ", style="dim #444444")
|
|
338
|
+
|
|
339
|
+
if tokens_used > 0:
|
|
340
|
+
bar.append("| ", style="dim")
|
|
341
|
+
bar.append(f"{tokens_used:,} tok", style="dim cyan")
|
|
342
|
+
if cost > 0:
|
|
343
|
+
bar.append(" | ", style="dim")
|
|
344
|
+
bar.append(f"${cost:.4f}", style="dim green")
|
|
345
|
+
|
|
346
|
+
return bar
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def render_user_prompt() -> Text:
|
|
350
|
+
"""Render the user input prompt."""
|
|
351
|
+
prompt = Text()
|
|
352
|
+
prompt.append("\n You", style="bold cyan")
|
|
353
|
+
prompt.append(" > ", style="dim cyan")
|
|
354
|
+
return prompt
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def render_markdown_response(content: str) -> Markdown:
|
|
358
|
+
"""Render agent response as markdown."""
|
|
359
|
+
return Markdown(content, code_theme="monokai")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def render_code_block(code: str, language: str = "python") -> Syntax:
|
|
363
|
+
"""Render a syntax-highlighted code block."""
|
|
364
|
+
return Syntax(code, language, theme="monokai", line_numbers=True)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def print_info(message: str) -> None:
|
|
368
|
+
console.print(f" [info][info] {message}[/info]")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def print_success(message: str) -> None:
|
|
372
|
+
console.print(f" [success][ok] {message}[/success]")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def print_warning(message: str) -> None:
|
|
376
|
+
console.print(f" [warning][warn] {message}[/warning]")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def print_error(message: str) -> None:
|
|
380
|
+
console.print(f" [error][error] {message}[/error]")
|