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/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]")