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,7 @@
1
+ """UI package - Presentation layer components.
2
+
3
+ This package contains all user interface components including
4
+ the TUI (Terminal User Interface) and CLI command handlers.
5
+ """
6
+
7
+ __all__ = []
@@ -0,0 +1,338 @@
1
+ """Display helper functions for the TUI."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from typing import Any
7
+
8
+ from rich import box
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ from cadecoder.core.constants import UI_STYLE
14
+ from cadecoder.core.logging import get_log_file_path
15
+
16
+ # Console instance - let Rich auto-detect terminal width
17
+ console = Console(stderr=True)
18
+
19
+
20
+ def _format_result_summary(result: str, max_len: int = 80) -> str:
21
+ """Format a tool result into a brief summary.
22
+
23
+ Intelligently summarizes JSON structures without hardcoded field names.
24
+
25
+ Args:
26
+ result: Raw result string (often JSON)
27
+ max_len: Maximum summary length
28
+
29
+ Returns:
30
+ Brief summary string
31
+ """
32
+ if not result:
33
+ return "[dim]empty[/dim]"
34
+
35
+ # Check for error patterns
36
+ result_lower = result.lower()
37
+ if "failed" in result_lower[:100] or "error" in result_lower[:100]:
38
+ # Extract HTTP status if present
39
+ for code in ["404", "401", "403", "500", "502", "503"]:
40
+ if code in result:
41
+ return f"[red]{code} error[/red]"
42
+ return "[red]failed[/red]"
43
+
44
+ # Try to parse as JSON
45
+ try:
46
+ data = json.loads(result)
47
+ return _summarize_json(data)
48
+ except (json.JSONDecodeError, TypeError):
49
+ pass
50
+
51
+ # Fallback: truncate raw string
52
+ preview = result.replace("\n", " ").strip()
53
+ if len(preview) > max_len:
54
+ return preview[: max_len - 3] + "..."
55
+ return preview if preview else "[dim]empty[/dim]"
56
+
57
+
58
+ def _summarize_json(data: Any, depth: int = 0) -> str:
59
+ """Recursively summarize a JSON structure.
60
+
61
+ Args:
62
+ data: Parsed JSON data
63
+ depth: Current recursion depth
64
+
65
+ Returns:
66
+ Brief summary string
67
+ """
68
+ if depth > 2:
69
+ return "..."
70
+
71
+ if data is None:
72
+ return "null"
73
+
74
+ if isinstance(data, bool):
75
+ return "✓" if data else "✗"
76
+
77
+ if isinstance(data, int | float):
78
+ return str(data)
79
+
80
+ if isinstance(data, str):
81
+ if len(data) > 40:
82
+ return f'"{data[:37]}..."'
83
+ return f'"{data}"' if len(data) < 20 else data[:40]
84
+
85
+ if isinstance(data, list):
86
+ length = len(data)
87
+ if length == 0:
88
+ return "[]"
89
+ # Peek at first item type
90
+ first = data[0]
91
+ if isinstance(first, dict):
92
+ return f"{length} items"
93
+ if isinstance(first, str):
94
+ return f"{length} strings"
95
+ return f"{length} items"
96
+
97
+ if isinstance(data, dict):
98
+ if not data:
99
+ return "{}"
100
+
101
+ # Count totals
102
+ total_lists = 0
103
+ total_items = 0
104
+ for v in data.values():
105
+ if isinstance(v, list):
106
+ total_lists += 1
107
+ total_items += len(v)
108
+
109
+ # If there are lists, summarize by total items
110
+ if total_lists > 0:
111
+ return f"{total_items} items" if total_items > 0 else "OK"
112
+
113
+ # Otherwise show key count
114
+ key_count = len(data)
115
+ if key_count <= 3:
116
+ return ", ".join(data.keys())
117
+ return f"{key_count} fields"
118
+
119
+ return str(type(data).__name__)
120
+
121
+
122
+ # Control signals that should be hidden from display but kept for continuation logic
123
+ CONTROL_SIGNAL_PATTERN = re.compile(
124
+ r"\[(?:TASK_COMPLETE|CONTINUE|NEED_USER_INPUT)\]", re.IGNORECASE
125
+ )
126
+
127
+
128
+ def strip_control_signals(content: str, strip_whitespace: bool = False) -> str:
129
+ """Remove control signals from content for display.
130
+
131
+ Strips [TASK_COMPLETE], [CONTINUE], [NEED_USER_INPUT] markers
132
+ that are used by the continuation strategy but shouldn't be
133
+ shown to the user.
134
+
135
+ Args:
136
+ content: Raw content that may contain control signals
137
+ strip_whitespace: If True, also strip leading/trailing whitespace.
138
+ Set to False when processing streaming chunks to
139
+ preserve word spacing.
140
+
141
+ Returns:
142
+ Content with control signals removed
143
+ """
144
+ if not content:
145
+ return content
146
+ # Remove the control signals
147
+ cleaned = CONTROL_SIGNAL_PATTERN.sub("", content)
148
+ # Clean up any resulting triple+ newlines
149
+ cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
150
+ if strip_whitespace:
151
+ cleaned = cleaned.strip()
152
+ return cleaned
153
+
154
+
155
+ def clear_screen() -> None:
156
+ """Clear the terminal screen."""
157
+ os.system("cls" if os.name == "nt" else "clear")
158
+
159
+
160
+ def display_thread_header(thread_name: str) -> None:
161
+ """Display the thread header/title."""
162
+ if UI_STYLE == "minimal":
163
+ console.print(f"[bold green]Chat Thread:[/bold green] {thread_name}")
164
+ console.print("[dim]/help for commands, Ctrl+C to exit[/dim]")
165
+ console.print()
166
+ else:
167
+ console.print(
168
+ Panel(
169
+ f"[bold green]{thread_name}[/bold green]",
170
+ title="Chat Thread",
171
+ border_style="green",
172
+ )
173
+ )
174
+
175
+
176
+ def display_git_branch_info(branch_name: str | None) -> None:
177
+ """Display the current git branch information."""
178
+ if branch_name:
179
+ console.print(f"[dim]Git branch: {branch_name}[/dim]")
180
+
181
+
182
+ def display_help() -> None:
183
+ """Display help information."""
184
+ help_text = """
185
+ [bold cyan]Available Commands:[/bold cyan]
186
+
187
+ [bold]/help[/bold] - Show this help message
188
+ [bold]/exit[/bold], [bold]/quit[/bold] - Exit the chat
189
+ [bold]/clear[/bold] - Clear the screen
190
+ [bold]/tools[/bold] - Show available tools
191
+ [bold]/logs[/bold] - Show log file location and recent logs
192
+ [bold]/context[/bold] - Show context window status and token usage
193
+ [bold]/history[/bold] - Show conversation history
194
+ [bold]/model[/bold] - Show current AI model
195
+ [bold]/thread[/bold] - Show current thread info
196
+ [bold]/pwd[/bold] - Show current directory
197
+ [bold]/cd <path>[/bold] - Change directory
198
+ [bold]/! <cmd>[/bold] - Execute shell command
199
+
200
+ [bold cyan]Context References:[/bold cyan]
201
+
202
+ Use @<path> to include file or directory content as context.
203
+ Examples:
204
+ @src/main.py - Include a specific file
205
+ @src/ - Include directory listing
206
+ @. - Include current directory listing
207
+
208
+ [bold cyan]Keyboard Shortcuts:[/bold cyan]
209
+
210
+ Ctrl+C - Cancel current operation
211
+ Ctrl+C Ctrl+C - Exit chat (press twice quickly)
212
+ Ctrl+D - Exit chat
213
+ Up/Down arrows - Navigate message history
214
+ """
215
+ console.print(Panel(help_text, title="Help", border_style="cyan"))
216
+
217
+
218
+ def display_logs(num_lines: int = 20) -> None:
219
+ """Display the log file location and recent log entries."""
220
+ log_path = get_log_file_path()
221
+
222
+ console.print(f"[bold cyan]Log file:[/bold cyan] {log_path}")
223
+ console.print()
224
+
225
+ try:
226
+ if log_path.exists():
227
+ with open(log_path, encoding="utf-8") as f:
228
+ lines = f.readlines()
229
+ recent_lines = lines[-num_lines:] if len(lines) > num_lines else lines
230
+
231
+ if recent_lines:
232
+ console.print(f"[dim]Last {len(recent_lines)} log entries:[/dim]")
233
+ console.print()
234
+ for line in recent_lines:
235
+ line = line.strip()
236
+ if not line:
237
+ continue
238
+ # Color code by log level
239
+ if "ERROR" in line:
240
+ console.print(f"[red]{line}[/red]")
241
+ elif "WARNING" in line:
242
+ console.print(f"[yellow]{line}[/yellow]")
243
+ elif "INFO" in line:
244
+ console.print(f"[green]{line}[/green]")
245
+ else:
246
+ console.print(f"[dim]{line}[/dim]")
247
+ else:
248
+ console.print("[dim]Log file is empty.[/dim]")
249
+ else:
250
+ console.print("[yellow]Log file does not exist yet.[/yellow]")
251
+ except Exception as e:
252
+ console.print(f"[red]Error reading log file: {e}[/red]")
253
+
254
+
255
+ def display_messages(messages: list[Any], limit: int = 10) -> None:
256
+ """Display recent messages."""
257
+ if not messages:
258
+ console.print("[dim]No messages in this thread.[/dim]")
259
+ return
260
+
261
+ recent = messages[-limit:]
262
+ for msg in recent:
263
+ role = getattr(msg, "role", "unknown")
264
+ content = getattr(msg, "content", "")
265
+
266
+ if role == "user":
267
+ console.print(f"[bold blue]You:[/bold blue] {content}")
268
+ elif role == "assistant":
269
+ console.print(f"[bold green]Assistant:[/bold green] {content[:500]}...")
270
+ elif role == "system":
271
+ console.print(f"[dim]System: {content[:100]}...[/dim]")
272
+
273
+
274
+ async def display_tools_async(tool_manager: Any) -> None:
275
+ """Display available tools."""
276
+ if not tool_manager:
277
+ console.print("[yellow]No tool manager available.[/yellow]")
278
+ return
279
+
280
+ try:
281
+ tools = await tool_manager.get_tools()
282
+ if not tools:
283
+ console.print("[yellow]No tools available.[/yellow]")
284
+ return
285
+
286
+ table = Table(title="Available Tools", box=box.ROUNDED)
287
+ table.add_column("Name", style="cyan")
288
+ table.add_column("Description", style="white")
289
+ table.add_column("Source", style="dim")
290
+
291
+ for tool in tools[:30]: # Limit display
292
+ func_info = tool.get("function", {})
293
+ name = func_info.get("name", "Unknown")
294
+ desc = func_info.get("description", "No description")
295
+
296
+ # Truncate description
297
+ if len(desc) > 60:
298
+ desc = desc[:57] + "..."
299
+
300
+ # Determine source
301
+ source = "Remote" if "[Arcade Cloud]" in desc else "Local"
302
+ desc = desc.replace("[Arcade Cloud] ", "")
303
+
304
+ table.add_row(name, desc, source)
305
+
306
+ console.print(table)
307
+ console.print(f"[dim]Total: {len(tools)} tools[/dim]")
308
+ except Exception as e:
309
+ console.print(f"[red]Error loading tools: {e}[/red]")
310
+
311
+
312
+ def display_tool_result(name: str, content: Any, status: str = "success") -> None:
313
+ """Display a tool execution result as a single compact line."""
314
+ content_str = str(content)
315
+ summary = _format_result_summary(content_str)
316
+
317
+ # Check if it's an error
318
+ is_error = "Failed to execute" in content_str or status != "success" or "[red]" in summary
319
+
320
+ if is_error:
321
+ icon = "[red]✗[/red]"
322
+ name_style = f"[red]{name}[/red]"
323
+ else:
324
+ icon = "[green]✓[/green]"
325
+ name_style = f"[cyan]{name}[/cyan]"
326
+
327
+ console.print(f"{icon} {name_style}: {summary}")
328
+
329
+
330
+ def display_tool_error(name: str, error: str) -> None:
331
+ """Display a tool execution error as a compact line."""
332
+ # Extract brief error message
333
+ brief_error = error
334
+ if len(brief_error) > 80:
335
+ brief_error = brief_error[:77] + "..."
336
+ brief_error = brief_error.replace("\n", " ")
337
+
338
+ console.print(f"[red]✗ {name}[/red]: [dim]{brief_error}[/dim]")
cadecoder/ui/input.py ADDED
@@ -0,0 +1,145 @@
1
+ """Input handling for the TUI."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ from prompt_toolkit import PromptSession
7
+ from prompt_toolkit.formatted_text import FormattedText
8
+ from prompt_toolkit.history import History
9
+ from prompt_toolkit.key_binding import KeyBindings
10
+ from prompt_toolkit.keys import Keys
11
+ from prompt_toolkit.patch_stdout import patch_stdout
12
+ from prompt_toolkit.styles import Style
13
+
14
+ from cadecoder.core.logging import log
15
+ from cadecoder.ui.state import TuiState
16
+
17
+ # --- Prompt History ---
18
+
19
+
20
+ class InMemoryPromptHistory(History):
21
+ """Simple in-memory history for prompt session."""
22
+
23
+ def __init__(self, thread_id: str | None = None):
24
+ """Initialize history.
25
+
26
+ Args:
27
+ thread_id: Optional thread ID (unused, kept for API compatibility)
28
+ """
29
+ super().__init__()
30
+ self._history: list[str] = []
31
+
32
+ def load_history_strings(self):
33
+ """Load history strings."""
34
+ return iter(self._history)
35
+
36
+ def store_string(self, string: str) -> None:
37
+ """Store a string in history."""
38
+ if string.strip() and string not in self._history:
39
+ self._history.append(string)
40
+
41
+
42
+ # --- Key Bindings ---
43
+
44
+
45
+ def create_key_bindings(
46
+ cancel_callback: Callable[[], None] | None = None,
47
+ exit_callback: Callable[[], None] | None = None,
48
+ ) -> KeyBindings:
49
+ """Create key bindings for the prompt."""
50
+ kb = KeyBindings()
51
+
52
+ @kb.add(Keys.ControlC)
53
+ def handle_ctrl_c(event):
54
+ """Handle Ctrl+C."""
55
+ if cancel_callback:
56
+ cancel_callback()
57
+ else:
58
+ event.app.exit(result=None)
59
+
60
+ @kb.add(Keys.ControlD)
61
+ def handle_ctrl_d(event):
62
+ """Handle Ctrl+D."""
63
+ if exit_callback:
64
+ exit_callback()
65
+ else:
66
+ event.app.exit(result=None)
67
+
68
+ return kb
69
+
70
+
71
+ # --- Bottom Toolbar ---
72
+
73
+
74
+ def create_bottom_toolbar(state: TuiState) -> Callable[[], FormattedText]:
75
+ """Create a bottom toolbar function."""
76
+
77
+ def get_toolbar() -> FormattedText:
78
+ mode = state.chat_mode.upper()
79
+ return FormattedText(
80
+ [
81
+ ("class:toolbar", f" {mode} "),
82
+ ("class:toolbar-separator", " │ "),
83
+ ("class:toolbar", "Ctrl+C: Cancel, Ctrl+D: Exit"),
84
+ ]
85
+ )
86
+
87
+ return get_toolbar
88
+
89
+
90
+ # --- Prompt Session ---
91
+
92
+
93
+ PROMPT_STYLE = Style.from_dict(
94
+ {
95
+ "prompt": "bold cyan",
96
+ "toolbar": "bg:#333333 #ffffff",
97
+ "toolbar-separator": "bg:#333333 #666666",
98
+ }
99
+ )
100
+
101
+
102
+ def create_prompt_session(
103
+ history: History | None = None,
104
+ key_bindings: KeyBindings | None = None,
105
+ bottom_toolbar: Callable[[], FormattedText] | None = None,
106
+ completer: Any = None,
107
+ ) -> PromptSession:
108
+ """Create a prompt session."""
109
+ return PromptSession(
110
+ history=history,
111
+ key_bindings=key_bindings,
112
+ bottom_toolbar=bottom_toolbar,
113
+ style=PROMPT_STYLE,
114
+ completer=completer,
115
+ complete_while_typing=False,
116
+ )
117
+
118
+
119
+ # --- Async Prompt Wrapper ---
120
+
121
+
122
+ class AsyncPromptWrapper:
123
+ """Wrapper for async prompt operations."""
124
+
125
+ def __init__(self, session: PromptSession):
126
+ self.session = session
127
+
128
+ async def prompt_async(
129
+ self,
130
+ message: str = "> ",
131
+ default: str = "",
132
+ ) -> str | None:
133
+ """Prompt for input asynchronously."""
134
+ try:
135
+ with patch_stdout():
136
+ result = await self.session.prompt_async(
137
+ message=message,
138
+ default=default,
139
+ )
140
+ return result
141
+ except (EOFError, KeyboardInterrupt):
142
+ return None
143
+ except Exception as e:
144
+ log.error(f"Prompt error: {e}")
145
+ return None