cade-cli 0.3.3__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.
Files changed (44) hide show
  1. cade_cli-0.3.3.dist-info/METADATA +151 -0
  2. cade_cli-0.3.3.dist-info/RECORD +44 -0
  3. cade_cli-0.3.3.dist-info/WHEEL +4 -0
  4. cade_cli-0.3.3.dist-info/entry_points.txt +2 -0
  5. cadecoder/__init__.py +1 -0
  6. cadecoder/ai/__init__.py +6 -0
  7. cadecoder/ai/prompts.py +572 -0
  8. cadecoder/cli/__init__.py +0 -0
  9. cadecoder/cli/app.py +147 -0
  10. cadecoder/cli/auth.py +483 -0
  11. cadecoder/cli/commands/__init__.py +5 -0
  12. cadecoder/cli/commands/auth.py +143 -0
  13. cadecoder/cli/commands/chat.py +264 -0
  14. cadecoder/cli/commands/mcp.py +477 -0
  15. cadecoder/cli/commands/tools.py +226 -0
  16. cadecoder/core/__init__.py +12 -0
  17. cadecoder/core/config.py +380 -0
  18. cadecoder/core/constants.py +281 -0
  19. cadecoder/core/errors.py +145 -0
  20. cadecoder/core/logging.py +148 -0
  21. cadecoder/core/types.py +235 -0
  22. cadecoder/core/utils.py +279 -0
  23. cadecoder/execution/__init__.py +46 -0
  24. cadecoder/execution/context_window.py +521 -0
  25. cadecoder/execution/orchestrator.py +562 -0
  26. cadecoder/execution/parallel.py +287 -0
  27. cadecoder/providers/__init__.py +60 -0
  28. cadecoder/providers/base.py +294 -0
  29. cadecoder/providers/openai.py +251 -0
  30. cadecoder/storage/__init__.py +0 -0
  31. cadecoder/storage/threads.py +489 -0
  32. cadecoder/templates/login_failed.html +21 -0
  33. cadecoder/templates/login_success.html +21 -0
  34. cadecoder/templates/styles.css +87 -0
  35. cadecoder/tools/__init__.py +19 -0
  36. cadecoder/tools/builtin.py +644 -0
  37. cadecoder/tools/filesystem.py +315 -0
  38. cadecoder/tools/git.py +221 -0
  39. cadecoder/tools/manager.py +1635 -0
  40. cadecoder/ui/__init__.py +7 -0
  41. cadecoder/ui/display.py +338 -0
  42. cadecoder/ui/input.py +145 -0
  43. cadecoder/ui/session.py +455 -0
  44. cadecoder/ui/state.py +20 -0
@@ -0,0 +1,455 @@
1
+ """Main chat session for the TUI."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import signal
7
+ import sys
8
+ from typing import Any
9
+
10
+ from rich.live import Live
11
+ from rich.markdown import Markdown
12
+ from ulid import ulid
13
+
14
+ from cadecoder.core.constants import DEFAULT_AI_MODEL
15
+ from cadecoder.core.errors import AuthError
16
+ from cadecoder.core.logging import log
17
+ from cadecoder.core.types import ExecutionEventType
18
+ from cadecoder.execution.orchestrator import (
19
+ ExecutionContext,
20
+ create_orchestrator,
21
+ )
22
+ from cadecoder.storage.threads import (
23
+ Message,
24
+ ModelInfo,
25
+ ToolCallInfo,
26
+ get_thread_history,
27
+ )
28
+ from cadecoder.tools.git import get_current_branch_name
29
+ from cadecoder.ui.display import (
30
+ clear_screen,
31
+ console,
32
+ display_git_branch_info,
33
+ display_help,
34
+ display_logs,
35
+ display_messages,
36
+ display_thread_header,
37
+ display_tool_result,
38
+ display_tools_async,
39
+ strip_control_signals,
40
+ )
41
+
42
+ # Box-drawing characters that break Rich Markdown rendering
43
+ _BOX_CHARS = frozenset("┌┐└┘├┤┬┴┼─│═║╔╗╚╝╠╣╦╩╬")
44
+
45
+
46
+ def _has_box_chars(text: str) -> bool:
47
+ """Check if text contains box-drawing characters."""
48
+ return bool(_BOX_CHARS & set(text))
49
+
50
+
51
+ class ChatSession:
52
+ """Manages a chat session with the AI agent."""
53
+
54
+ def __init__(
55
+ self,
56
+ thread_id: str,
57
+ model: str = DEFAULT_AI_MODEL,
58
+ system_prompt: str | None = None,
59
+ ) -> None:
60
+ """Initialize chat session."""
61
+ self.thread_id = thread_id
62
+ self.model = model
63
+ self.system_prompt = system_prompt
64
+ self.history_manager = get_thread_history()
65
+ self.thread = self.history_manager.get_thread(thread_id)
66
+ self.orchestrator = create_orchestrator(default_model=model)
67
+ self.current_task: asyncio.Task | None = None
68
+ self._ctrl_c_count = 0
69
+
70
+ def get_conversation_history(self) -> list[dict[str, Any]]:
71
+ """Get conversation history in LLM format."""
72
+ messages = self.history_manager.get_messages(self.thread_id)
73
+ history: list[dict[str, Any]] = []
74
+
75
+ for msg in messages:
76
+ if msg.role == "system":
77
+ history.append({"role": "system", "content": msg.content or ""})
78
+ elif msg.role == "user":
79
+ history.append({"role": "user", "content": msg.content or ""})
80
+ elif msg.role == "assistant":
81
+ entry: dict[str, Any] = {"role": "assistant", "content": msg.content}
82
+ if msg.tool_calls:
83
+ entry["tool_calls"] = [
84
+ {
85
+ "id": tc.call_id,
86
+ "type": "function",
87
+ "function": {
88
+ "name": tc.tool_name,
89
+ "arguments": json.dumps(tc.parameters),
90
+ },
91
+ }
92
+ for tc in msg.tool_calls
93
+ ]
94
+ history.append(entry)
95
+ elif msg.role == "tool":
96
+ history.append(
97
+ {
98
+ "role": "tool",
99
+ "tool_call_id": msg.responding_tool_call_id or "",
100
+ "content": msg.content or "",
101
+ }
102
+ )
103
+
104
+ return history
105
+
106
+ def save_user_message(self, content: str) -> Message:
107
+ """Save a user message."""
108
+ message = Message(
109
+ id=str(ulid()).lower(),
110
+ thread_id=self.thread_id,
111
+ role="user",
112
+ content=content,
113
+ )
114
+ self.history_manager.add_message(message)
115
+ return message
116
+
117
+ def save_assistant_message(
118
+ self,
119
+ content: str | None,
120
+ tool_calls: list[dict[str, Any]] | None = None,
121
+ model_info: ModelInfo | None = None,
122
+ ) -> Message:
123
+ """Save an assistant message."""
124
+ tc_infos = []
125
+ if tool_calls:
126
+ for tc in tool_calls:
127
+ func = tc.get("function", {})
128
+ args_raw = func.get("arguments", "{}")
129
+ if isinstance(args_raw, str):
130
+ try:
131
+ params = json.loads(args_raw)
132
+ except json.JSONDecodeError:
133
+ params = {"raw": args_raw}
134
+ else:
135
+ params = args_raw if isinstance(args_raw, dict) else {}
136
+
137
+ tc_infos.append(
138
+ ToolCallInfo(
139
+ call_id=tc.get("id", str(ulid()).lower()),
140
+ tool_name=func.get("name", "unknown"),
141
+ parameters=params,
142
+ )
143
+ )
144
+
145
+ message = Message(
146
+ id=str(ulid()).lower(),
147
+ thread_id=self.thread_id,
148
+ role="assistant",
149
+ content=content,
150
+ tool_calls=tc_infos,
151
+ model_info=model_info,
152
+ )
153
+ self.history_manager.add_message(message)
154
+ return message
155
+
156
+ def save_tool_message(self, tool_call_id: str, content: str, tool_name: str) -> Message:
157
+ """Save a tool response message."""
158
+ message = Message(
159
+ id=str(ulid()).lower(),
160
+ thread_id=self.thread_id,
161
+ role="tool",
162
+ content=content,
163
+ responding_tool_call_id=tool_call_id,
164
+ )
165
+ self.history_manager.add_message(message)
166
+ return message
167
+
168
+ async def process_input(self, user_input: str) -> None:
169
+ """Process user input."""
170
+ if user_input.startswith("/"):
171
+ await self.handle_command(user_input)
172
+ return
173
+
174
+ history = self.get_conversation_history()
175
+ self.save_user_message(user_input)
176
+
177
+ context = ExecutionContext(task=user_input, conversation_history=history)
178
+ accumulated_content = ""
179
+ tool_calls: list[dict[str, Any]] = []
180
+ tool_results: list[tuple[str, str, str]] = []
181
+
182
+ console.print()
183
+
184
+ try:
185
+ with Live(console=console, refresh_per_second=10) as live:
186
+ async for event in self.orchestrator.stream(context):
187
+ if event.type == ExecutionEventType.CONTENT:
188
+ accumulated_content += event.content or ""
189
+ display_content = strip_control_signals(
190
+ accumulated_content, strip_whitespace=True
191
+ )
192
+ if display_content:
193
+ # Plain text for box chars (Markdown breaks them)
194
+ if _has_box_chars(display_content):
195
+ live.update(display_content)
196
+ else:
197
+ live.update(Markdown(display_content))
198
+
199
+ elif event.type == ExecutionEventType.TOOL_CALL:
200
+ tc = event.metadata.get("tool_call", {})
201
+ tool_calls.append(tc)
202
+ func = tc.get("function", {})
203
+ tool_name = func.get("name", "unknown")
204
+ console.print(f"[dim]Calling tool: {tool_name}[/dim]")
205
+
206
+ elif event.type == ExecutionEventType.TOOL_RESULT:
207
+ tool_name = event.metadata.get("tool_name", "unknown")
208
+ tool_call_id = event.metadata.get("tool_call_id", "")
209
+ result_content = event.content or ""
210
+ display_tool_result(tool_name, result_content)
211
+ tool_results.append((tool_call_id, tool_name, result_content))
212
+
213
+ except asyncio.CancelledError:
214
+ console.print("\n[yellow]Operation cancelled.[/yellow]")
215
+ return
216
+ except Exception as e:
217
+ console.print(f"\n[red]Error: {e}[/red]")
218
+ log.error(f"Error processing input: {e}", exc_info=True)
219
+ return
220
+
221
+ # Save assistant message
222
+ if accumulated_content or tool_calls:
223
+ self.save_assistant_message(accumulated_content, tool_calls)
224
+
225
+ # Save tool results
226
+ for tool_call_id, tool_name, result_content in tool_results:
227
+ self.save_tool_message(tool_call_id, result_content, tool_name)
228
+
229
+ console.print()
230
+
231
+ async def handle_command(self, cmd: str) -> None:
232
+ """Handle slash commands."""
233
+ cmd = cmd.strip()
234
+
235
+ if cmd in ("/exit", "/quit"):
236
+ raise SystemExit(0)
237
+ elif cmd == "/help":
238
+ display_help()
239
+ elif cmd == "/clear":
240
+ clear_screen()
241
+ elif cmd == "/history":
242
+ messages = self.history_manager.get_messages(self.thread_id)
243
+ display_messages(messages)
244
+ elif cmd == "/model":
245
+ console.print(f"[cyan]Current model: {self.model}[/cyan]")
246
+ elif cmd == "/thread":
247
+ if self.thread:
248
+ console.print(f"[cyan]Thread ID: {self.thread.thread_id}[/cyan]")
249
+ console.print(f"[cyan]Name: {self.thread.name or 'Unnamed'}[/cyan]")
250
+ else:
251
+ console.print(f"[cyan]Thread ID: {self.thread_id}[/cyan]")
252
+ elif cmd == "/logs":
253
+ display_logs()
254
+ elif cmd == "/tools":
255
+ await display_tools_async(self.orchestrator.tool_manager)
256
+ elif cmd == "/context":
257
+ history = self.get_conversation_history()
258
+ status = self.orchestrator.get_context_status(history)
259
+ console.print("[cyan]Context Window Status:[/cyan]")
260
+ console.print(f" Tokens: {status['token_count']:,} / {status['effective_limit']:,}")
261
+ console.print(f" Used: {status['percentage_used']}%")
262
+ console.print(f" Messages: {status['message_count']}")
263
+ console.print(f" Needs compaction: {status['needs_compaction']}")
264
+
265
+ tool_summary = self.orchestrator.get_tool_outputs_summary()
266
+ if tool_summary["total_outputs"] > 0:
267
+ console.print("\n[cyan]Tool Outputs:[/cyan]")
268
+ console.print(f" Total outputs: {tool_summary['total_outputs']}")
269
+ console.print(f" Unique tools: {tool_summary['unique_tools']}")
270
+ console.print(f" Estimated tokens: {tool_summary['estimated_tokens']:,}")
271
+ elif cmd == "/pwd":
272
+ console.print(f"[cyan]{os.getcwd()}[/cyan]")
273
+ elif cmd.startswith("/cd "):
274
+ path = cmd[4:].strip()
275
+ try:
276
+ os.chdir(path)
277
+ console.print(f"[green]Changed to: {os.getcwd()}[/green]")
278
+ except Exception as e:
279
+ console.print(f"[red]Error: {e}[/red]")
280
+ elif cmd.startswith("/! "):
281
+ import subprocess
282
+
283
+ shell_cmd = cmd[3:].strip()
284
+ try:
285
+ result = subprocess.run(shell_cmd, shell=True, capture_output=True, text=True)
286
+ if result.stdout:
287
+ console.print(result.stdout)
288
+ if result.stderr:
289
+ console.print(f"[red]{result.stderr}[/red]")
290
+ except Exception as e:
291
+ console.print(f"[red]Error: {e}[/red]")
292
+ else:
293
+ console.print(f"[yellow]Unknown command: {cmd}[/yellow]")
294
+ console.print("[dim]Type /help for available commands[/dim]")
295
+
296
+ def cancel_current_task(self) -> None:
297
+ """Cancel the current running task."""
298
+ if self.current_task and not self.current_task.done():
299
+ self.current_task.cancel()
300
+
301
+
302
+ async def run_session(session: ChatSession) -> None:
303
+ """Run the chat session loop."""
304
+ loop = asyncio.get_running_loop()
305
+ stop_requested = False
306
+
307
+ def handle_sigint() -> None:
308
+ """Handle Ctrl+C - exit immediately."""
309
+ nonlocal stop_requested
310
+ if stop_requested or session._ctrl_c_count >= 1:
311
+ console.print("\n[bold red]Exiting.[/bold red]")
312
+ os._exit(0)
313
+ session._ctrl_c_count += 1
314
+ stop_requested = True
315
+ session.cancel_current_task()
316
+ console.print("\n[yellow]Exiting... (Ctrl+C again to force)[/yellow]")
317
+
318
+ try:
319
+ loop.add_signal_handler(signal.SIGINT, handle_sigint)
320
+ except (NotImplementedError, ValueError):
321
+ pass
322
+
323
+ # Display header
324
+ branch_name, _ = get_current_branch_name()
325
+ display_git_branch_info(branch_name)
326
+
327
+ thread_name = session.thread.name if session.thread else session.thread_id[:8]
328
+ display_thread_header(thread_name)
329
+
330
+ # Display existing messages
331
+ messages = session.history_manager.get_messages(session.thread_id)
332
+ if messages:
333
+ display_messages(messages)
334
+
335
+ # Main loop
336
+ while not stop_requested:
337
+ try:
338
+ session._ctrl_c_count = 0
339
+ user_input = await loop.run_in_executor(None, lambda: input("> "))
340
+
341
+ if stop_requested:
342
+ break
343
+
344
+ if not user_input.strip():
345
+ continue
346
+
347
+ session.current_task = asyncio.create_task(session.process_input(user_input.strip()))
348
+
349
+ try:
350
+ await session.current_task
351
+ except asyncio.CancelledError:
352
+ console.print("[yellow]Cancelled.[/yellow]")
353
+ if stop_requested:
354
+ break
355
+
356
+ except EOFError:
357
+ console.print("\n[dim]Exiting.[/dim]")
358
+ break
359
+ except KeyboardInterrupt:
360
+ console.print("\n[dim]Exiting.[/dim]")
361
+ break
362
+ except SystemExit:
363
+ break
364
+
365
+ console.print("[dim]Session ended.[/dim]")
366
+
367
+
368
+ def main(
369
+ thread_id_to_run: str,
370
+ model: str = DEFAULT_AI_MODEL,
371
+ stream: bool = False,
372
+ system_prompt: str | None = None,
373
+ target_symbol: str | None = None,
374
+ ) -> None:
375
+ """Main entry point for the TUI."""
376
+ try:
377
+ session = ChatSession(thread_id=thread_id_to_run, model=model, system_prompt=system_prompt)
378
+ asyncio.run(run_session(session))
379
+ except AuthError as e:
380
+ console.print(f"[red]Authentication error: {e}[/red]")
381
+ sys.exit(1)
382
+ except Exception as e:
383
+ log.error(f"Fatal error: {e}", exc_info=True)
384
+ console.print(f"[red]Fatal error: {e}[/red]")
385
+ sys.exit(1)
386
+
387
+
388
+ async def _run_single_message(message: str, model: str = DEFAULT_AI_MODEL) -> int:
389
+ """Run a single message through the orchestrator.
390
+
391
+ Args:
392
+ message: The user message to process
393
+ model: Model to use
394
+
395
+ Returns:
396
+ Exit code (0=success, 1=error, 2=needs interactive)
397
+ """
398
+ orchestrator = create_orchestrator(default_model=model)
399
+ context = ExecutionContext(task=message, conversation_history=[])
400
+
401
+ accumulated_content = ""
402
+
403
+ try:
404
+ # Status to stderr
405
+ print("\033[2m\033[3m[Processing...]\033[0m", file=sys.stderr)
406
+
407
+ async for event in orchestrator.stream(context):
408
+ if event.type == ExecutionEventType.CONTENT:
409
+ chunk = event.content or ""
410
+ cleaned = strip_control_signals(chunk, strip_whitespace=False)
411
+ accumulated_content += cleaned
412
+
413
+ elif event.type == ExecutionEventType.TOOL_CALL:
414
+ tc = event.metadata.get("tool_call", {})
415
+ func = tc.get("function", {})
416
+ tool_name = func.get("name", "unknown")
417
+ print(f"\033[2m\033[3m[Calling: {tool_name}]\033[0m", file=sys.stderr)
418
+
419
+ elif event.type == ExecutionEventType.TOOL_RESULT:
420
+ tool_name = event.metadata.get("tool_name", "unknown")
421
+ result = event.content or ""
422
+ preview = result[:80] + "..." if len(result) > 80 else result
423
+ preview = preview.replace("\n", " ")
424
+ print(
425
+ f"\033[2m\033[3m[Result: {tool_name}] {preview}\033[0m",
426
+ file=sys.stderr,
427
+ )
428
+
429
+ # Final output to stdout
430
+ final_output = strip_control_signals(accumulated_content, strip_whitespace=True)
431
+ if final_output:
432
+ print(final_output)
433
+
434
+ return 0
435
+
436
+ except AuthError:
437
+ print("\033[2m\033[3m[Authentication required]\033[0m", file=sys.stderr)
438
+ return 2
439
+ except Exception as e:
440
+ print(f"Error: {e}", file=sys.stderr)
441
+ log.error(f"Single message error: {e}", exc_info=True)
442
+ return 1
443
+
444
+
445
+ def run_single_message_mode(message: str, model: str = DEFAULT_AI_MODEL) -> int:
446
+ """Entry point for single message mode.
447
+
448
+ Args:
449
+ message: The user message to process
450
+ model: Model to use
451
+
452
+ Returns:
453
+ Exit code (0=success, 1=error, 2=needs interactive)
454
+ """
455
+ return asyncio.run(_run_single_message(message, model))
cadecoder/ui/state.py ADDED
@@ -0,0 +1,20 @@
1
+ """State management for the TUI application."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class TuiState:
9
+ """Main TUI application state."""
10
+
11
+ chat_mode: str = "agent"
12
+ show_tools: bool = False
13
+ orchestrator: Any = None
14
+ message_history_index: int = -1
15
+ cached_user_messages: list[str] = field(default_factory=list)
16
+ expand_tool_results: bool = True
17
+
18
+
19
+ # Global application state
20
+ app_state = TuiState()