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/cli.py
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main interactive terminal CLI for thwip.
|
|
3
|
+
|
|
4
|
+
Universal coding agent multiplexer:
|
|
5
|
+
- Dynamic UI adapting to agent capabilities
|
|
6
|
+
- Seamless mid-conversation agent switching with context preservation
|
|
7
|
+
- Auto-detection of all installed tools & subscriptions
|
|
8
|
+
- Quota / rate limit exhaustion failover
|
|
9
|
+
- Rich live markdown and tool execution
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from prompt_toolkit import PromptSession
|
|
22
|
+
from prompt_toolkit.formatted_text import HTML
|
|
23
|
+
from prompt_toolkit.history import FileHistory
|
|
24
|
+
from rich.live import Live
|
|
25
|
+
from rich.panel import Panel
|
|
26
|
+
from rich.table import Table
|
|
27
|
+
from rich.text import Text
|
|
28
|
+
from rich import box
|
|
29
|
+
|
|
30
|
+
from thwip.agents import ALL_AGENT_CLASSES, AgentRegistry
|
|
31
|
+
from thwip.agents.base import (
|
|
32
|
+
AgentDone,
|
|
33
|
+
AgentEvent,
|
|
34
|
+
BaseAgent,
|
|
35
|
+
Capability,
|
|
36
|
+
LimitHit,
|
|
37
|
+
LimitStatus,
|
|
38
|
+
TextDelta,
|
|
39
|
+
ThinkingDelta,
|
|
40
|
+
TokenUsage,
|
|
41
|
+
ToolResult,
|
|
42
|
+
ToolUseStart,
|
|
43
|
+
)
|
|
44
|
+
from thwip.config import ThwipConfig, get_config_dir
|
|
45
|
+
from thwip.detector import SystemDetector
|
|
46
|
+
from thwip.limits import UsageTracker
|
|
47
|
+
from thwip.session import Session
|
|
48
|
+
from thwip.shortcuts import ThwipCompleter, create_keybindings
|
|
49
|
+
from thwip.theme import (
|
|
50
|
+
console,
|
|
51
|
+
get_brand,
|
|
52
|
+
print_error,
|
|
53
|
+
print_info,
|
|
54
|
+
print_success,
|
|
55
|
+
print_warning,
|
|
56
|
+
render_agent_badge,
|
|
57
|
+
render_agents_table,
|
|
58
|
+
render_capability_disclaimer,
|
|
59
|
+
render_dynamic_status_bar,
|
|
60
|
+
render_limit_warning,
|
|
61
|
+
render_markdown_response,
|
|
62
|
+
render_startup_banner,
|
|
63
|
+
render_user_prompt,
|
|
64
|
+
)
|
|
65
|
+
from thwip.tools import ToolManager
|
|
66
|
+
from thwip.utils import estimate_cost, format_cost, format_tokens
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ThwipCLI:
|
|
70
|
+
"""The interactive terminal CLI REPL."""
|
|
71
|
+
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self.config = ThwipConfig.load()
|
|
74
|
+
self.registry = AgentRegistry(self.config)
|
|
75
|
+
self.detector = SystemDetector()
|
|
76
|
+
self.usage_tracker = UsageTracker()
|
|
77
|
+
self.tool_manager = ToolManager(self.config.project)
|
|
78
|
+
self.session = Session(
|
|
79
|
+
project_path=self.config.project,
|
|
80
|
+
current_agent=self.config.default_agent,
|
|
81
|
+
current_model=self.config.default_model,
|
|
82
|
+
)
|
|
83
|
+
self.current_agent = self._resolve_initial_agent()
|
|
84
|
+
|
|
85
|
+
def _resolve_initial_agent(self) -> BaseAgent:
|
|
86
|
+
agent = self.registry.get_agent(self.session.current_agent)
|
|
87
|
+
if agent and agent.is_configured():
|
|
88
|
+
return agent
|
|
89
|
+
|
|
90
|
+
# Find first ready agent
|
|
91
|
+
ready = self.registry.get_ready_agents()
|
|
92
|
+
if ready:
|
|
93
|
+
self.session.current_agent = ready[0].name
|
|
94
|
+
self.session.current_model = ready[0].get_default_model()
|
|
95
|
+
return ready[0]
|
|
96
|
+
|
|
97
|
+
# Fallback to default instantiated agent even if missing key
|
|
98
|
+
default = self.registry.get_agent("claude") or self.registry.list_agents()[0]
|
|
99
|
+
return default
|
|
100
|
+
|
|
101
|
+
def run(self) -> None:
|
|
102
|
+
"""Run the async CLI event loop."""
|
|
103
|
+
asyncio.run(self.run_async())
|
|
104
|
+
|
|
105
|
+
async def run_async(self) -> None:
|
|
106
|
+
"""Main async REPL loop."""
|
|
107
|
+
# 1. Detect agents on system
|
|
108
|
+
detected = self.detector.scan_all()
|
|
109
|
+
ready_count = len(self.registry.get_ready_agents())
|
|
110
|
+
|
|
111
|
+
# 2. Display startup banner
|
|
112
|
+
console.clear()
|
|
113
|
+
console.print(
|
|
114
|
+
render_startup_banner(
|
|
115
|
+
agent_name=self.current_agent.display_name,
|
|
116
|
+
company=self.current_agent.company,
|
|
117
|
+
model=self.session.current_model,
|
|
118
|
+
project_path=os.path.abspath(self.session.project_path),
|
|
119
|
+
session_name=self.session.name,
|
|
120
|
+
agents_detected=len(detected),
|
|
121
|
+
agents_ready=ready_count,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
console.print(
|
|
126
|
+
" [dim]Type [bold white]/help[/bold white] for commands, [bold white]/switch[/bold white] to change agent, or just start chatting.[/dim]\n"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Setup prompt session
|
|
130
|
+
history_file = str(get_config_dir() / "history.txt")
|
|
131
|
+
prompt_session: PromptSession = PromptSession(
|
|
132
|
+
history=FileHistory(history_file),
|
|
133
|
+
completer=ThwipCompleter([a.name for a in self.registry.list_agents()]),
|
|
134
|
+
key_bindings=create_keybindings(),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
while True:
|
|
138
|
+
try:
|
|
139
|
+
# Dynamic prompt showing active agent brand
|
|
140
|
+
brand = get_brand(self.current_agent.company)
|
|
141
|
+
caps = [c.value for c in self.current_agent.capabilities]
|
|
142
|
+
status_bar = render_dynamic_status_bar(
|
|
143
|
+
agent_name=self.current_agent.name,
|
|
144
|
+
company=self.current_agent.company,
|
|
145
|
+
model=self.session.current_model,
|
|
146
|
+
capabilities=caps,
|
|
147
|
+
tokens_used=self.session.get_total_tokens(),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
console.print(status_bar)
|
|
151
|
+
user_input = await prompt_session.prompt_async(
|
|
152
|
+
HTML(f"<b><style fg='{brand.primary}'>You ▶ </style></b>")
|
|
153
|
+
)
|
|
154
|
+
user_input = user_input.strip()
|
|
155
|
+
|
|
156
|
+
if not user_input:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
# Handle slash commands
|
|
160
|
+
if user_input.startswith("/"):
|
|
161
|
+
handled = await self.handle_command(user_input)
|
|
162
|
+
if handled == "QUIT":
|
|
163
|
+
break
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
# Process chat message with agent
|
|
167
|
+
await self.process_user_message(user_input)
|
|
168
|
+
|
|
169
|
+
except (KeyboardInterrupt, EOFError):
|
|
170
|
+
console.print("\n[dim]Exiting thwip. Goodbye![/dim]")
|
|
171
|
+
break
|
|
172
|
+
except Exception as e:
|
|
173
|
+
print_error(f"Unexpected error: {e}")
|
|
174
|
+
|
|
175
|
+
async def handle_command(self, cmd_line: str) -> str | None:
|
|
176
|
+
"""Handle slash commands."""
|
|
177
|
+
parts = cmd_line.split(" ", 2)
|
|
178
|
+
cmd = parts[0].lower()
|
|
179
|
+
arg1 = parts[1] if len(parts) > 1 else ""
|
|
180
|
+
arg2 = parts[2] if len(parts) > 2 else ""
|
|
181
|
+
|
|
182
|
+
if cmd in ("/quit", "/exit", "/q"):
|
|
183
|
+
return "QUIT"
|
|
184
|
+
|
|
185
|
+
elif cmd in ("/help", "/h"):
|
|
186
|
+
self.show_help()
|
|
187
|
+
|
|
188
|
+
elif cmd in ("/switch", "/s"):
|
|
189
|
+
await self.cmd_switch(arg1, arg2)
|
|
190
|
+
|
|
191
|
+
elif cmd in ("/agents", "/list"):
|
|
192
|
+
self.cmd_show_agents()
|
|
193
|
+
|
|
194
|
+
elif cmd in ("/models", "/m"):
|
|
195
|
+
self.cmd_show_models(arg1)
|
|
196
|
+
|
|
197
|
+
elif cmd == "/status":
|
|
198
|
+
self.cmd_show_status()
|
|
199
|
+
|
|
200
|
+
elif cmd == "/limits":
|
|
201
|
+
self.cmd_show_limits()
|
|
202
|
+
|
|
203
|
+
elif cmd == "/detect":
|
|
204
|
+
self.cmd_detect()
|
|
205
|
+
|
|
206
|
+
elif cmd == "/history":
|
|
207
|
+
self.cmd_show_history()
|
|
208
|
+
|
|
209
|
+
elif cmd == "/cost":
|
|
210
|
+
self.cmd_show_cost()
|
|
211
|
+
|
|
212
|
+
elif cmd == "/project":
|
|
213
|
+
self.cmd_project(arg1)
|
|
214
|
+
|
|
215
|
+
elif cmd == "/session":
|
|
216
|
+
sub = arg1.lower()
|
|
217
|
+
if sub == "save":
|
|
218
|
+
path = self.session.save(arg2 or None)
|
|
219
|
+
print_success(f"Session saved to {path.name}")
|
|
220
|
+
elif sub == "load":
|
|
221
|
+
loaded = Session.load(arg2)
|
|
222
|
+
if loaded:
|
|
223
|
+
self.session = loaded
|
|
224
|
+
agent = self.registry.get_agent(loaded.current_agent)
|
|
225
|
+
if agent:
|
|
226
|
+
self.current_agent = agent
|
|
227
|
+
print_success(f"Loaded session '{loaded.name}' with {len(loaded.messages)} messages.")
|
|
228
|
+
else:
|
|
229
|
+
print_error(f"Session '{arg2}' not found.")
|
|
230
|
+
elif sub == "list":
|
|
231
|
+
self.cmd_list_sessions()
|
|
232
|
+
elif sub == "clear":
|
|
233
|
+
self.session.messages.clear()
|
|
234
|
+
print_info("Conversation history cleared.")
|
|
235
|
+
else:
|
|
236
|
+
print_info("Usage: /session [save|load|list|clear] [name]")
|
|
237
|
+
|
|
238
|
+
else:
|
|
239
|
+
print_warning(f"Unknown command '{cmd}'. Type /help for available commands.")
|
|
240
|
+
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
def show_help(self) -> None:
|
|
244
|
+
"""Show help information."""
|
|
245
|
+
table = Table(title="thwip Commands & Shortcuts", box=box.ROUNDED)
|
|
246
|
+
table.add_column("Command / Key", style="bold cyan")
|
|
247
|
+
table.add_column("Description", style="white")
|
|
248
|
+
|
|
249
|
+
commands = [
|
|
250
|
+
("/switch [agent] [model]", "Switch active agent/model mid-conversation without losing context"),
|
|
251
|
+
("/agents", "Show all detected coding agents, company status & capabilities"),
|
|
252
|
+
("/models [agent]", "List available models for current or target agent"),
|
|
253
|
+
("/status", "Display current session, project, and token stats"),
|
|
254
|
+
("/limits", "View token usage, quota, and spend metrics"),
|
|
255
|
+
("/detect", "Re-scan system for newly installed coding agents"),
|
|
256
|
+
("/session save [name]", "Save current chat session"),
|
|
257
|
+
("/session load <name>", "Load a previously saved session"),
|
|
258
|
+
("/session list", "List all saved sessions"),
|
|
259
|
+
("/session clear", "Clear current conversation memory"),
|
|
260
|
+
("/history", "View conversation history with model attribution badges"),
|
|
261
|
+
("/cost", "Show estimated session and cumulative cost"),
|
|
262
|
+
("/project [path]", "View or change project working directory"),
|
|
263
|
+
("Ctrl + S", "Quick switch prompt"),
|
|
264
|
+
("Ctrl + T", "Status view"),
|
|
265
|
+
("Ctrl + H", "Show conversation history"),
|
|
266
|
+
("/quit", "Exit thwip"),
|
|
267
|
+
]
|
|
268
|
+
for c, d in commands:
|
|
269
|
+
table.add_row(c, d)
|
|
270
|
+
console.print(table)
|
|
271
|
+
|
|
272
|
+
async def cmd_switch(self, agent_name: str, model_id: str = "") -> None:
|
|
273
|
+
"""Switch to a different agent and/or model."""
|
|
274
|
+
if not agent_name:
|
|
275
|
+
# Interactive selection
|
|
276
|
+
agents = self.registry.list_agents()
|
|
277
|
+
console.print("\n[bold white]Select an agent to switch to:[/bold white]")
|
|
278
|
+
for i, a in enumerate(agents, 1):
|
|
279
|
+
brand = get_brand(a.company)
|
|
280
|
+
status_str, _ = a.get_status_display()
|
|
281
|
+
caps_str = ", ".join(c.value for c in a.capabilities)
|
|
282
|
+
console.print(
|
|
283
|
+
f" [bold white]{i}.[/bold white] [bold]{a.display_name}[/bold] "
|
|
284
|
+
f"({a.company}) - [{status_str}] - [dim]{caps_str}[/dim]"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
choice = input(f"\nEnter choice [1-{len(agents)}]: ").strip()
|
|
288
|
+
if choice.isdigit() and 1 <= int(choice) <= len(agents):
|
|
289
|
+
agent_name = agents[int(choice) - 1].name
|
|
290
|
+
else:
|
|
291
|
+
print_warning("Switch cancelled.")
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
new_agent = self.registry.get_agent(agent_name)
|
|
295
|
+
if not new_agent:
|
|
296
|
+
print_error(f"Unknown agent '{agent_name}'.")
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
old_agent = self.current_agent
|
|
300
|
+
old_caps = set(old_agent.capabilities)
|
|
301
|
+
|
|
302
|
+
self.current_agent = new_agent
|
|
303
|
+
chosen_model = model_id or new_agent.get_default_model()
|
|
304
|
+
self.session.switch_agent(new_agent.name, chosen_model)
|
|
305
|
+
|
|
306
|
+
# Capability comparison & disclaimer
|
|
307
|
+
missing = new_agent.get_missing_capabilities(old_caps)
|
|
308
|
+
console.print(
|
|
309
|
+
render_capability_disclaimer(
|
|
310
|
+
agent_name=new_agent.display_name,
|
|
311
|
+
company=new_agent.company,
|
|
312
|
+
supported=[c.display_name for c in new_agent.capabilities],
|
|
313
|
+
unsupported=missing,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
print_success(f"Now chatting with {new_agent.display_name} ({chosen_model}). Context preserved!")
|
|
317
|
+
|
|
318
|
+
def cmd_show_agents(self) -> None:
|
|
319
|
+
"""Show table of all detected agents."""
|
|
320
|
+
rows = [a.to_table_row() for a in self.registry.list_agents()]
|
|
321
|
+
console.print(render_agents_table(rows))
|
|
322
|
+
|
|
323
|
+
def cmd_show_models(self, agent_name: str = "") -> None:
|
|
324
|
+
"""List models for current or target agent."""
|
|
325
|
+
target = self.registry.get_agent(agent_name) if agent_name else self.current_agent
|
|
326
|
+
if not target:
|
|
327
|
+
print_error(f"Agent '{agent_name}' not found.")
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
table = Table(title=f"Available Models for {target.display_name}", box=box.ROUNDED)
|
|
331
|
+
table.add_column("Model ID", style="bold white")
|
|
332
|
+
table.add_column("Name", style="cyan")
|
|
333
|
+
table.add_column("Context", style="white")
|
|
334
|
+
table.add_column("Tools", style="green")
|
|
335
|
+
table.add_column("Thinking", style="magenta")
|
|
336
|
+
table.add_column("Price (In/Out per 1M)", style="dim")
|
|
337
|
+
|
|
338
|
+
for m in target.available_models:
|
|
339
|
+
ctx = f"{m.context_window:,}" if m.context_window else "-"
|
|
340
|
+
price = f"${m.pricing_input} / ${m.pricing_output}" if m.pricing_input else "Free / Local"
|
|
341
|
+
table.add_row(
|
|
342
|
+
m.id + (" (default)" if m.is_default else ""),
|
|
343
|
+
m.name,
|
|
344
|
+
ctx,
|
|
345
|
+
"yes" if m.supports_tools else "-",
|
|
346
|
+
"yes" if m.supports_thinking else "-",
|
|
347
|
+
price,
|
|
348
|
+
)
|
|
349
|
+
console.print(table)
|
|
350
|
+
|
|
351
|
+
def cmd_show_status(self) -> None:
|
|
352
|
+
"""Display status."""
|
|
353
|
+
brand = get_brand(self.current_agent.company)
|
|
354
|
+
content = Text()
|
|
355
|
+
content.append(f"Agent: {self.current_agent.display_name} ({self.current_agent.company})\n", style=brand.label_style)
|
|
356
|
+
content.append(f"Model: {self.session.current_model}\n", style="bold white")
|
|
357
|
+
content.append(f"Project: {os.path.abspath(self.session.project_path)}\n", style="white")
|
|
358
|
+
content.append(f"Session: {self.session.name} ({len(self.session.messages)} messages)\n", style="white")
|
|
359
|
+
content.append(f"Tokens: {self.session.get_total_tokens():,} used\n", style="dim")
|
|
360
|
+
content.append(f"Config Key: {self.config.key_sources.get(self.current_agent.name, 'None')}\n", style="dim")
|
|
361
|
+
|
|
362
|
+
console.print(Panel(content, title="Current Status", box=box.ROUNDED))
|
|
363
|
+
|
|
364
|
+
def cmd_show_limits(self) -> None:
|
|
365
|
+
"""Display usage metrics."""
|
|
366
|
+
summary = self.usage_tracker.get_summary()
|
|
367
|
+
table = Table(title="Usage & Limit Metrics", box=box.ROUNDED)
|
|
368
|
+
table.add_column("Agent", style="bold white")
|
|
369
|
+
table.add_column("Requests", style="cyan")
|
|
370
|
+
table.add_column("Tokens (In / Out)", style="white")
|
|
371
|
+
table.add_column("Est. Cost", style="green")
|
|
372
|
+
table.add_column("Last Error / Limit", style="yellow")
|
|
373
|
+
|
|
374
|
+
for agent_name, stats in summary.get("by_agent", {}).items():
|
|
375
|
+
tok_str = f"{stats['input_tokens']:,} / {stats['output_tokens']:,}"
|
|
376
|
+
table.add_row(
|
|
377
|
+
agent_name,
|
|
378
|
+
str(stats["request_count"]),
|
|
379
|
+
tok_str,
|
|
380
|
+
f"${stats['estimated_cost']:.4f}",
|
|
381
|
+
stats.get("last_error") or "None",
|
|
382
|
+
)
|
|
383
|
+
console.print(table)
|
|
384
|
+
|
|
385
|
+
def cmd_detect(self) -> None:
|
|
386
|
+
"""Re-scan system tools."""
|
|
387
|
+
with console.status("[bold cyan]Scanning system for AI coding agents...[/bold cyan]"):
|
|
388
|
+
detected = self.detector.scan_all()
|
|
389
|
+
table = Table(title="Discovered Local Coding Agents and Extensions", box=box.ROUNDED)
|
|
390
|
+
table.add_column("Name", style="bold white")
|
|
391
|
+
table.add_column("Company", style="dim")
|
|
392
|
+
table.add_column("Category", style="cyan")
|
|
393
|
+
table.add_column("Status", style="green")
|
|
394
|
+
table.add_column("Source", style="dim")
|
|
395
|
+
|
|
396
|
+
for t in detected:
|
|
397
|
+
status_style = "bold green" if t.is_configured else "bold yellow"
|
|
398
|
+
table.add_row(
|
|
399
|
+
t.name,
|
|
400
|
+
t.company,
|
|
401
|
+
t.category,
|
|
402
|
+
f"[{status_style}]{t.subscription_status}[/{status_style}]",
|
|
403
|
+
t.config_source or t.install_path,
|
|
404
|
+
)
|
|
405
|
+
console.print(table)
|
|
406
|
+
|
|
407
|
+
def cmd_show_history(self) -> None:
|
|
408
|
+
"""Show conversation history with agent badges."""
|
|
409
|
+
if not self.session.messages:
|
|
410
|
+
print_info("No messages in current session.")
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
for m in self.session.messages:
|
|
414
|
+
if m.role == "user":
|
|
415
|
+
console.print(f"\n[bold cyan]You ▶ [/bold cyan]{m.content}")
|
|
416
|
+
elif m.role == "assistant":
|
|
417
|
+
badge = render_agent_badge(m.agent_name, m.model, m.company)
|
|
418
|
+
console.print(f"\n", badge)
|
|
419
|
+
console.print(render_markdown_response(m.content))
|
|
420
|
+
|
|
421
|
+
def cmd_show_cost(self) -> None:
|
|
422
|
+
summary = self.usage_tracker.get_summary()
|
|
423
|
+
console.print(f" [bold]Total Cumulative Spend:[/bold] [green]${summary['total_cost']:.4f}[/green]")
|
|
424
|
+
console.print(f" [bold]Total Requests:[/bold] {summary['total_requests']}")
|
|
425
|
+
console.print(f" [bold]Total Tokens Processed:[/bold] {summary['total_tokens']:,}")
|
|
426
|
+
|
|
427
|
+
def cmd_project(self, new_path: str = "") -> None:
|
|
428
|
+
if new_path:
|
|
429
|
+
p = Path(new_path).expanduser().resolve()
|
|
430
|
+
if p.is_dir():
|
|
431
|
+
self.session.project_path = str(p)
|
|
432
|
+
self.tool_manager = ToolManager(str(p))
|
|
433
|
+
print_success(f"Project path changed to {p}")
|
|
434
|
+
else:
|
|
435
|
+
print_error(f"Directory '{new_path}' does not exist.")
|
|
436
|
+
else:
|
|
437
|
+
console.print(f" Current project path: [bold white]{os.path.abspath(self.session.project_path)}[/bold white]")
|
|
438
|
+
|
|
439
|
+
def cmd_list_sessions(self) -> None:
|
|
440
|
+
sessions = Session.list_saved_sessions()
|
|
441
|
+
if not sessions:
|
|
442
|
+
print_info("No saved sessions found.")
|
|
443
|
+
return
|
|
444
|
+
table = Table(title="Saved Sessions", box=box.ROUNDED)
|
|
445
|
+
table.add_column("Name", style="bold white")
|
|
446
|
+
table.add_column("Agent", style="cyan")
|
|
447
|
+
table.add_column("Model", style="white")
|
|
448
|
+
table.add_column("Messages", style="green")
|
|
449
|
+
table.add_column("Updated", style="dim")
|
|
450
|
+
for s in sessions:
|
|
451
|
+
table.add_row(s["name"], s["agent"], s["model"], str(s["messages_count"]), s["updated_at"])
|
|
452
|
+
console.print(table)
|
|
453
|
+
|
|
454
|
+
async def process_user_message(self, text: str) -> None:
|
|
455
|
+
"""Send message to active agent, handle streaming, tool calls, and limits."""
|
|
456
|
+
self.session.add_user_message(text)
|
|
457
|
+
|
|
458
|
+
# Prepare messages in portable format
|
|
459
|
+
portable_msgs = self.session.to_portable_messages()
|
|
460
|
+
|
|
461
|
+
brand = get_brand(self.current_agent.company)
|
|
462
|
+
badge = render_agent_badge(
|
|
463
|
+
self.current_agent.name,
|
|
464
|
+
self.session.current_model,
|
|
465
|
+
self.current_agent.company,
|
|
466
|
+
)
|
|
467
|
+
console.print(f"\n", badge)
|
|
468
|
+
|
|
469
|
+
# Get tools if agent supports them
|
|
470
|
+
tools = None
|
|
471
|
+
if self.current_agent.has_capability(Capability.FILE_EDIT):
|
|
472
|
+
if self.current_agent.name == "claude":
|
|
473
|
+
tools = self.tool_manager.get_anthropic_tools()
|
|
474
|
+
else:
|
|
475
|
+
tools = self.tool_manager.get_openai_tools()
|
|
476
|
+
|
|
477
|
+
collected_text = ""
|
|
478
|
+
collected_thinking = ""
|
|
479
|
+
total_tokens = 0
|
|
480
|
+
limit_hit = False
|
|
481
|
+
|
|
482
|
+
with Live(console=console, refresh_per_second=12) as live:
|
|
483
|
+
try:
|
|
484
|
+
stream = self.current_agent.chat(
|
|
485
|
+
messages=portable_msgs,
|
|
486
|
+
model=self.session.current_model,
|
|
487
|
+
system_prompt=self.session.system_prompt,
|
|
488
|
+
tools=tools,
|
|
489
|
+
stream=self.config.stream,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
async for event in stream:
|
|
493
|
+
if isinstance(event, TextDelta):
|
|
494
|
+
collected_text += event.content
|
|
495
|
+
live.update(render_markdown_response(collected_text))
|
|
496
|
+
|
|
497
|
+
elif isinstance(event, ThinkingDelta):
|
|
498
|
+
collected_thinking += event.content
|
|
499
|
+
# Render thinking panel
|
|
500
|
+
think_panel = Panel(
|
|
501
|
+
Text(collected_thinking, style="dim italic"),
|
|
502
|
+
title="Reasoning / Thinking",
|
|
503
|
+
border_style="dim magenta",
|
|
504
|
+
box=box.MINIMAL,
|
|
505
|
+
)
|
|
506
|
+
live.update(think_panel)
|
|
507
|
+
|
|
508
|
+
elif isinstance(event, ToolUseStart):
|
|
509
|
+
live.stop()
|
|
510
|
+
console.print(
|
|
511
|
+
f"\n [bold yellow]Action: {event.tool_name}[/bold yellow] [dim]{event.args}[/dim]"
|
|
512
|
+
)
|
|
513
|
+
# Execute tool
|
|
514
|
+
tool_output = self.tool_manager.execute_tool(event.tool_name, event.args)
|
|
515
|
+
console.print(f" [dim green]Result:[/dim green] [dim]{tool_output[:200]}[/dim]")
|
|
516
|
+
live.start()
|
|
517
|
+
|
|
518
|
+
elif isinstance(event, AgentDone):
|
|
519
|
+
total_tokens = event.usage.input_tokens + event.usage.output_tokens
|
|
520
|
+
self.usage_tracker.record_usage(
|
|
521
|
+
agent_name=self.current_agent.name,
|
|
522
|
+
model=self.session.current_model,
|
|
523
|
+
input_tokens=event.usage.input_tokens,
|
|
524
|
+
output_tokens=event.usage.output_tokens,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
elif isinstance(event, LimitHit):
|
|
528
|
+
limit_hit = True
|
|
529
|
+
live.stop()
|
|
530
|
+
self.usage_tracker.record_limit_hit(self.current_agent.name, event.message)
|
|
531
|
+
await self.handle_limit_failover(event)
|
|
532
|
+
break
|
|
533
|
+
|
|
534
|
+
except Exception as e:
|
|
535
|
+
live.stop()
|
|
536
|
+
print_error(f"Agent error: {e}")
|
|
537
|
+
|
|
538
|
+
if collected_text and not limit_hit:
|
|
539
|
+
self.session.add_assistant_message(
|
|
540
|
+
content=collected_text,
|
|
541
|
+
agent_name=self.current_agent.name,
|
|
542
|
+
model=self.session.current_model,
|
|
543
|
+
company=self.current_agent.company,
|
|
544
|
+
tokens=total_tokens,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
async def handle_limit_failover(self, event: LimitHit) -> None:
|
|
548
|
+
"""Handle rate limit or quota exhaustion with auto-suggested failover."""
|
|
549
|
+
# Find ready alternatives
|
|
550
|
+
ready = self.registry.get_ready_agents()
|
|
551
|
+
alternatives = []
|
|
552
|
+
for a in ready:
|
|
553
|
+
if a.name != self.current_agent.name:
|
|
554
|
+
alternatives.append({
|
|
555
|
+
"agent": a.display_name,
|
|
556
|
+
"company": a.company,
|
|
557
|
+
"model": a.get_default_model(),
|
|
558
|
+
"capabilities": [c.value for c in a.capabilities],
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
console.print(
|
|
562
|
+
render_limit_warning(
|
|
563
|
+
agent_name=self.current_agent.display_name,
|
|
564
|
+
company=self.current_agent.company,
|
|
565
|
+
error_type=event.error_type.value,
|
|
566
|
+
alternatives=alternatives,
|
|
567
|
+
)
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
if not alternatives:
|
|
571
|
+
print_warning("No other configured agents found. Please add an API key or start Ollama.")
|
|
572
|
+
return
|
|
573
|
+
|
|
574
|
+
choice = input("\nSwitch to alternative agent now? [1 to switch, Enter to cancel]: ").strip()
|
|
575
|
+
if choice == "1" or choice.lower() == "y":
|
|
576
|
+
target_alt = alternatives[0]
|
|
577
|
+
for a in ready:
|
|
578
|
+
if a.display_name == target_alt["agent"]:
|
|
579
|
+
await self.cmd_switch(a.name, target_alt["model"])
|
|
580
|
+
console.print("[bold green]Retrying your last message with new agent...[/bold green]")
|
|
581
|
+
last_msg = self.session.messages[-1].content
|
|
582
|
+
await self.process_user_message(last_msg)
|
|
583
|
+
break
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def main() -> None:
|
|
587
|
+
"""Entry point for the thwip CLI command."""
|
|
588
|
+
cli = ThwipCLI()
|
|
589
|
+
cli.run()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
if __name__ == "__main__":
|
|
593
|
+
main()
|