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