grucli 3.3.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.
- grucli/__init__.py +1 -0
- grucli/api.py +725 -0
- grucli/auth.py +115 -0
- grucli/chat_manager.py +190 -0
- grucli/commands.py +318 -0
- grucli/config.py +262 -0
- grucli/handlers.py +75 -0
- grucli/interrupt.py +179 -0
- grucli/main.py +617 -0
- grucli/permissions.py +181 -0
- grucli/stats.py +100 -0
- grucli/sysprompts/main_sysprompt.txt +65 -0
- grucli/theme.py +144 -0
- grucli/tools.py +368 -0
- grucli/ui.py +496 -0
- grucli-3.3.0.dist-info/METADATA +145 -0
- grucli-3.3.0.dist-info/RECORD +21 -0
- grucli-3.3.0.dist-info/WHEEL +5 -0
- grucli-3.3.0.dist-info/entry_points.txt +2 -0
- grucli-3.3.0.dist-info/licenses/LICENSE +21 -0
- grucli-3.3.0.dist-info/top_level.txt +1 -0
grucli/permissions.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-tool-call permission system for grucli.
|
|
3
|
+
Implements granular permissions with session-scoped "Allow always" functionality.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Set, Dict, Optional, Tuple
|
|
8
|
+
from .theme import Colors, Borders, Icons, Styles, get_terminal_width
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PermissionGroup(Enum):
|
|
12
|
+
"""Permission categories for tool operations."""
|
|
13
|
+
READ = "read" # read_file, get_current_directory_structure
|
|
14
|
+
WRITE = "write" # create_file, edit_file
|
|
15
|
+
DESTRUCTIVE = "delete" # delete_file
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Tool-to-permission-group mapping
|
|
19
|
+
TOOL_PERMISSION_MAP: Dict[str, PermissionGroup] = {
|
|
20
|
+
'read_file': PermissionGroup.READ,
|
|
21
|
+
'get_current_directory_structure': PermissionGroup.READ,
|
|
22
|
+
'create_file': PermissionGroup.WRITE,
|
|
23
|
+
'edit_file': PermissionGroup.WRITE,
|
|
24
|
+
'delete_file': PermissionGroup.DESTRUCTIVE,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Human-readable descriptions for permission groups
|
|
29
|
+
PERMISSION_DESCRIPTIONS: Dict[PermissionGroup, str] = {
|
|
30
|
+
PermissionGroup.READ: "Read files and directory structure",
|
|
31
|
+
PermissionGroup.WRITE: "Create and modify files",
|
|
32
|
+
PermissionGroup.DESTRUCTIVE: "Delete files permanently",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Colors for permission groups
|
|
37
|
+
PERMISSION_COLORS: Dict[PermissionGroup, str] = {
|
|
38
|
+
PermissionGroup.READ: Colors.READ_OP,
|
|
39
|
+
PermissionGroup.WRITE: Colors.WRITE_OP,
|
|
40
|
+
PermissionGroup.DESTRUCTIVE: Colors.DESTRUCTIVE_OP,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Icons for permission groups
|
|
45
|
+
PERMISSION_ICONS: Dict[PermissionGroup, str] = {
|
|
46
|
+
PermissionGroup.READ: Icons.READ,
|
|
47
|
+
PermissionGroup.WRITE: Icons.WRITE,
|
|
48
|
+
PermissionGroup.DESTRUCTIVE: Icons.DELETE,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PermissionStore:
|
|
53
|
+
"""
|
|
54
|
+
Session-scoped permission storage.
|
|
55
|
+
Tracks which permission groups have been granted "Allow always".
|
|
56
|
+
Resets when the session ends.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self):
|
|
60
|
+
self._allowed_always: Set[PermissionGroup] = set()
|
|
61
|
+
|
|
62
|
+
def is_allowed(self, group: PermissionGroup) -> bool:
|
|
63
|
+
"""Check if a permission group is already allowed."""
|
|
64
|
+
return group in self._allowed_always
|
|
65
|
+
|
|
66
|
+
def allow_always(self, group: PermissionGroup) -> None:
|
|
67
|
+
"""Grant persistent permission for this session."""
|
|
68
|
+
self._allowed_always.add(group)
|
|
69
|
+
# If allowing write (edits), also allow read
|
|
70
|
+
if group == PermissionGroup.WRITE:
|
|
71
|
+
self._allowed_always.add(PermissionGroup.READ)
|
|
72
|
+
|
|
73
|
+
def revoke_always(self, group: PermissionGroup) -> None:
|
|
74
|
+
"""Revoke persistent permission for this session."""
|
|
75
|
+
self._allowed_always.discard(group)
|
|
76
|
+
|
|
77
|
+
def reset(self) -> None:
|
|
78
|
+
"""Clear all permissions (called on session end)."""
|
|
79
|
+
self._allowed_always.clear()
|
|
80
|
+
|
|
81
|
+
def get_allowed_groups(self) -> Set[PermissionGroup]:
|
|
82
|
+
"""Get all currently allowed permission groups."""
|
|
83
|
+
return self._allowed_always.copy()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Global permission store instance
|
|
87
|
+
PERMISSION_STORE = PermissionStore()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_tool_permission_group(tool_name: str) -> Optional[PermissionGroup]:
|
|
91
|
+
"""Get the permission group for a tool."""
|
|
92
|
+
return TOOL_PERMISSION_MAP.get(tool_name)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def format_tool_for_permission(tool_name: str, args: dict) -> str:
|
|
96
|
+
"""Format a tool call for display in permission prompt."""
|
|
97
|
+
group = get_tool_permission_group(tool_name)
|
|
98
|
+
if not group:
|
|
99
|
+
return f"{tool_name}(...)"
|
|
100
|
+
|
|
101
|
+
color = PERMISSION_COLORS.get(group, Colors.WHITE)
|
|
102
|
+
icon = PERMISSION_ICONS.get(group, "")
|
|
103
|
+
|
|
104
|
+
# Format args nicely
|
|
105
|
+
arg_parts = []
|
|
106
|
+
for key, value in args.items():
|
|
107
|
+
if isinstance(value, str) and len(value) > 50:
|
|
108
|
+
value = value[:47] + "..."
|
|
109
|
+
arg_parts.append(f"{Colors.MUTED}{key}={Colors.RESET}{repr(value)}")
|
|
110
|
+
|
|
111
|
+
args_str = ", ".join(arg_parts) if arg_parts else ""
|
|
112
|
+
|
|
113
|
+
return f"{color}{icon} {tool_name}{Colors.RESET}({args_str})"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def prompt_permission(tool_name: str, args: dict) -> str:
|
|
117
|
+
"""
|
|
118
|
+
Display permission prompt and get user decision.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
'once' - Allow this single invocation
|
|
122
|
+
'always' - Allow all future calls in this permission group
|
|
123
|
+
'deny' - Deny this and all remaining tool calls
|
|
124
|
+
"""
|
|
125
|
+
from . import interrupt
|
|
126
|
+
|
|
127
|
+
group = get_tool_permission_group(tool_name)
|
|
128
|
+
if not group:
|
|
129
|
+
# Unknown tool - default to requiring permission
|
|
130
|
+
group = PermissionGroup.WRITE
|
|
131
|
+
|
|
132
|
+
# Check if already allowed
|
|
133
|
+
if PERMISSION_STORE.is_allowed(group):
|
|
134
|
+
return 'always'
|
|
135
|
+
|
|
136
|
+
color = PERMISSION_COLORS.get(group, Colors.WHITE)
|
|
137
|
+
|
|
138
|
+
# Print options (the tool box is printed by main.py)
|
|
139
|
+
print(f" {Colors.SUCCESS}1{Colors.RESET}) Allow once")
|
|
140
|
+
print(f" {Colors.PRIMARY}2{Colors.RESET}) Allow always {Colors.MUTED}(this session, {group.value} operations){Colors.RESET}")
|
|
141
|
+
print(f" {Colors.ERROR}3{Colors.RESET}) Deny {Colors.MUTED}(cancels all remaining operations){Colors.RESET}")
|
|
142
|
+
print()
|
|
143
|
+
|
|
144
|
+
while True:
|
|
145
|
+
try:
|
|
146
|
+
choice = interrupt.safe_input(f"{color}Choose [1/2/3]: {Colors.RESET}").strip()
|
|
147
|
+
if choice in ['1', '2', '3']:
|
|
148
|
+
print("\033[A\033[K" * 5, end="", flush=True)
|
|
149
|
+
if choice == '1':
|
|
150
|
+
return 'once'
|
|
151
|
+
elif choice == '2':
|
|
152
|
+
PERMISSION_STORE.allow_always(group)
|
|
153
|
+
print(f"{Colors.SUCCESS}{Icons.CHECK} Allowed {group.value} operations for this session{Colors.RESET}")
|
|
154
|
+
return 'always'
|
|
155
|
+
elif choice == '3':
|
|
156
|
+
print(f"{Colors.WARNING}{Icons.WARNING} Operation denied{Colors.RESET}")
|
|
157
|
+
return 'deny'
|
|
158
|
+
else:
|
|
159
|
+
print(f"{Colors.ERROR}Please enter 1, 2, or 3{Colors.RESET}")
|
|
160
|
+
pass
|
|
161
|
+
except KeyboardInterrupt:
|
|
162
|
+
print(f"\n{Colors.WARNING}{Icons.WARNING} Operation cancelled{Colors.RESET}")
|
|
163
|
+
return 'deny'
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def check_permission(tool_name: str, args: dict) -> Tuple[bool, str]:
|
|
167
|
+
"""
|
|
168
|
+
Check if a tool has permission to execute.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
(allowed: bool, decision: str)
|
|
172
|
+
- allowed: Whether the tool can execute
|
|
173
|
+
- decision: 'once', 'always', or 'deny'
|
|
174
|
+
"""
|
|
175
|
+
group = get_tool_permission_group(tool_name)
|
|
176
|
+
|
|
177
|
+
if group and PERMISSION_STORE.is_allowed(group):
|
|
178
|
+
return True, 'always'
|
|
179
|
+
|
|
180
|
+
decision = prompt_permission(tool_name, args)
|
|
181
|
+
return decision != 'deny', decision
|
grucli/stats.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session statistics tracking for grucli.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
import sys
|
|
8
|
+
from .theme import Colors, Borders, Icons
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SessionStats:
|
|
12
|
+
"""Track statistics for the current session."""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.session_id = str(uuid.uuid4())[:8] # Short ID
|
|
16
|
+
self.start_time = time.time()
|
|
17
|
+
self.tool_calls_total = 0
|
|
18
|
+
self.tool_calls_success = 0
|
|
19
|
+
self.tool_calls_failed = 0
|
|
20
|
+
self.model_usage = {} # {model_name: {'reqs': 0, 'input_tokens': 0, 'cache_reads': 0, 'output_tokens': 0}}
|
|
21
|
+
self.api_time_total = 0
|
|
22
|
+
|
|
23
|
+
def record_tool_call(self, success=True):
|
|
24
|
+
"""Record a tool call result."""
|
|
25
|
+
self.tool_calls_total += 1
|
|
26
|
+
if success:
|
|
27
|
+
self.tool_calls_success += 1
|
|
28
|
+
else:
|
|
29
|
+
self.tool_calls_failed += 1
|
|
30
|
+
|
|
31
|
+
def record_request(self, model_name, api_duration=0):
|
|
32
|
+
"""Record an API request."""
|
|
33
|
+
if model_name not in self.model_usage:
|
|
34
|
+
self.model_usage[model_name] = {'reqs': 0, 'input_tokens': 0, 'cache_reads': 0, 'output_tokens': 0}
|
|
35
|
+
self.model_usage[model_name]['reqs'] += 1
|
|
36
|
+
self.api_time_total += api_duration
|
|
37
|
+
|
|
38
|
+
def record_tokens(self, model_name, input_tokens=0, output_tokens=0, cache_reads=0):
|
|
39
|
+
"""Record token usage for a model."""
|
|
40
|
+
if model_name not in self.model_usage:
|
|
41
|
+
self.model_usage[model_name] = {'reqs': 0, 'input_tokens': 0, 'cache_reads': 0, 'output_tokens': 0}
|
|
42
|
+
self.model_usage[model_name]['input_tokens'] += input_tokens
|
|
43
|
+
self.model_usage[model_name]['output_tokens'] += output_tokens
|
|
44
|
+
self.model_usage[model_name]['cache_reads'] += cache_reads
|
|
45
|
+
|
|
46
|
+
def get_formatted_summary(self):
|
|
47
|
+
"""Generate a formatted summary of the session."""
|
|
48
|
+
end_time = time.time()
|
|
49
|
+
wall_time = end_time - self.start_time
|
|
50
|
+
|
|
51
|
+
minutes = int(wall_time // 60)
|
|
52
|
+
seconds = int(wall_time % 60)
|
|
53
|
+
wall_time_str = f"{minutes}m {seconds}s" if minutes > 0 else f"{seconds}s"
|
|
54
|
+
|
|
55
|
+
tool_success_rate = 0.0
|
|
56
|
+
if self.tool_calls_total > 0:
|
|
57
|
+
tool_success_rate = (self.tool_calls_success / self.tool_calls_total) * 100
|
|
58
|
+
|
|
59
|
+
api_time_pct = 0.0
|
|
60
|
+
if wall_time > 0:
|
|
61
|
+
api_time_pct = (self.api_time_total / wall_time) * 100
|
|
62
|
+
|
|
63
|
+
summary = []
|
|
64
|
+
|
|
65
|
+
# Header
|
|
66
|
+
summary.append("")
|
|
67
|
+
summary.append(f"{Colors.MUTED}{Borders.HORIZONTAL * 50}{Colors.RESET}")
|
|
68
|
+
summary.append(f"{Colors.PRIMARY}{Colors.BOLD}Session Summary{Colors.RESET} {Colors.MUTED}#{self.session_id}{Colors.RESET}")
|
|
69
|
+
summary.append(f"{Colors.MUTED}{Borders.HORIZONTAL * 50}{Colors.RESET}")
|
|
70
|
+
|
|
71
|
+
# Time
|
|
72
|
+
summary.append(f" {Colors.MUTED}Duration:{Colors.RESET} {Colors.WHITE}{wall_time_str}{Colors.RESET}")
|
|
73
|
+
summary.append(f" {Colors.MUTED}API Time:{Colors.RESET} {Colors.WHITE}{self.api_time_total:.1f}s{Colors.RESET} {Colors.MUTED}({api_time_pct:.0f}%){Colors.RESET}")
|
|
74
|
+
|
|
75
|
+
# Tools
|
|
76
|
+
if self.tool_calls_total > 0:
|
|
77
|
+
success_color = Colors.SUCCESS if self.tool_calls_failed == 0 else Colors.WARNING
|
|
78
|
+
summary.append(f" {Colors.MUTED}Tool Calls:{Colors.RESET} {Colors.WHITE}{self.tool_calls_total}{Colors.RESET} {Colors.MUTED}({Colors.SUCCESS}{Icons.CHECK}{self.tool_calls_success}{Colors.MUTED} {Colors.ERROR}{Icons.CROSS}{self.tool_calls_failed}{Colors.MUTED}){Colors.RESET}")
|
|
79
|
+
|
|
80
|
+
# Model usage
|
|
81
|
+
if self.model_usage:
|
|
82
|
+
summary.append("")
|
|
83
|
+
summary.append(f" {Colors.MUTED}Models Used:{Colors.RESET}")
|
|
84
|
+
for model, usage in self.model_usage.items():
|
|
85
|
+
# Truncate long model names
|
|
86
|
+
display_model = model if len(model) < 30 else model[:27] + "..."
|
|
87
|
+
summary.append(f" {Colors.SECONDARY}{display_model}{Colors.RESET} {Colors.MUTED}({usage['reqs']} requests){Colors.RESET}")
|
|
88
|
+
|
|
89
|
+
summary.append(f"{Colors.MUTED}{Borders.HORIZONTAL * 50}{Colors.RESET}")
|
|
90
|
+
summary.append("")
|
|
91
|
+
|
|
92
|
+
return "\n".join(summary)
|
|
93
|
+
|
|
94
|
+
def print_summary(self):
|
|
95
|
+
"""Print the session summary."""
|
|
96
|
+
print(self.get_formatted_summary())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Global instance
|
|
100
|
+
STATS = SessionStats()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Agentic CLI LLM
|
|
2
|
+
|
|
3
|
+
You are a precise local development assistant. You operate strictly within the current directory using relative paths.
|
|
4
|
+
|
|
5
|
+
## Environment & Paths
|
|
6
|
+
- **Relative Paths Only**: Use paths starting with `/` relative to the project root (e.g., `/src/main.py`). Never use absolute paths or `../`.
|
|
7
|
+
- **Context**: The current project structure is:
|
|
8
|
+
<auto_inject_file_tree>
|
|
9
|
+
Read files before assuming their content.
|
|
10
|
+
|
|
11
|
+
## Tools
|
|
12
|
+
Call tools using Python syntax: `tool_name(arg="value")`.
|
|
13
|
+
|
|
14
|
+
**Recommended: One Tool Call Per Response**: It is generally best to run only one tool call per response for reliability. However, if the situation clearly benefits—such as when making multiple small, non-conflicting edits—you may batch several tool calls together in a single response. Use your judgment and prioritize reliability.
|
|
15
|
+
|
|
16
|
+
- `read_file(path, start_line=None, end_line=None)`: Reads file content. Use `start_line` and `end_line` (1-indexed) for large files.
|
|
17
|
+
- `create_file(path, content)`: Creates a new file (and directories if needed).
|
|
18
|
+
- `edit_file(path, old_string, new_string)`: Replaces the **exact, unique** occurrence of `old_string`. Always provide enough context in `old_string` to ensure it only matches once.
|
|
19
|
+
- `delete_file(path)`: Deletes a file. Use with caution.
|
|
20
|
+
- `get_current_directory_structure()`: Returns the latest file tree. ONLY use if a file is not found or you don't know where it is.
|
|
21
|
+
|
|
22
|
+
## STRICT Tool Usage Protocols (CRITICAL)
|
|
23
|
+
|
|
24
|
+
### 1. NO Redundant Verification (MOST IMPORTANT)
|
|
25
|
+
- **NEVER** immediately read a file you just created or edited. Trust that the tool worked.
|
|
26
|
+
- **NEVER** read a file if the user provided the full content in the prompt.
|
|
27
|
+
- **Exception**: Only read back if you encounter a specific error message or if the user explicitly asks "Verify the file content".
|
|
28
|
+
- **ANTI-PATTERN (DO NOT DO THIS)**:
|
|
29
|
+
1. `create_file(path="script.py", ...)`
|
|
30
|
+
2. "Now I will read it to verify." -> `read_file(path="script.py")` <--- WRONG! STOP!
|
|
31
|
+
|
|
32
|
+
### 2. When NOT to use tools
|
|
33
|
+
- **Chat**: "Hello", "Thanks", "What is Python?" -> **No tools**. Text only. Respond naturally and politely to pleasantries.
|
|
34
|
+
- **Clarification**: Need more info? Ask text questions. Don't scan directory or read files just to "look busy".
|
|
35
|
+
- **Redundancy**: NEVER use a tool if the information is already present in the prompt or recent context.
|
|
36
|
+
- **Empty Directory**: If context says "Current directory empty", **do not** run `get_current_directory_structure()`.
|
|
37
|
+
- **Directory Structure**: NEVER use `get_current_directory_structure()` unless a file is not found or when the user explicitly asks for it.
|
|
38
|
+
|
|
39
|
+
### 3. When to USE tools
|
|
40
|
+
- **Mandatory Action**: If the user asks for a code change, bug fix, or new file, you **MUST** use the appropriate tool (`create_file`, `edit_file`). **Never** output code blocks as plain text if the intent is to modify the codebase.
|
|
41
|
+
- **Exploration**: ONLY if a file is not found in the provided tree, or the user explicitly asks for it -> `get_current_directory_structure()`
|
|
42
|
+
- **Modifying**: "Fix the bug in line 10" -> `read_file(...)` (to find context) -> `edit_file(...)`.
|
|
43
|
+
|
|
44
|
+
## Rules of Engagement
|
|
45
|
+
1. **Tool Invocation**: Any tool call **must be the final output** of your response.
|
|
46
|
+
2. **No Post-Tool Talk**: Do not add explanations, summaries, or "Done" messages after a tool call.
|
|
47
|
+
3. **Atomic Changes**: Prefer small, verifiable edits over large rewrites. Instead of trying to edit a full 100-line function in a single tool call, batch multiple smaller tool calls in the same response—each operating on a small part (e.g., a few lines) of the function.
|
|
48
|
+
- **Example**:
|
|
49
|
+
Instead of:
|
|
50
|
+
`edit_file(path="/foo.py", old_string="(entire 100-line function)", new_string="(entire new function)")`
|
|
51
|
+
Prefer:
|
|
52
|
+
```
|
|
53
|
+
edit_file(path="/foo.py", old_string="def foo(...):\n # lines 1-10", new_string="def foo(...):\n # updated lines 1-10")
|
|
54
|
+
edit_file(path="/foo.py", old_string=" # lines 11-20", new_string=" # updated lines 11-20")
|
|
55
|
+
```
|
|
56
|
+
This greatly reduces risk and aids verification.
|
|
57
|
+
4. **No Hallucinations**: If a file path isn't in the tree and you don't know where it is, use `get_current_directory_structure()`. Do not use it otherwise.
|
|
58
|
+
5. **Conciseness**: Minimize fluff. Focus on reasoning and action. However, do not be robotic; acknowledge greetings and thanks naturally alongside normal language.
|
|
59
|
+
6. **Tool Necessity**: Only trigger tools when a task requires action or specific information not present in the current prompt context. Avoid "informational" tool calls if you can answer from context.
|
|
60
|
+
7. **No Passive Code Blocks**: If the user's request implies a file modification, never output a markdown code block alone. You must call the relevant tool.
|
|
61
|
+
8. **Efficiency**: Use your existing knowledge and the provided file tree to answer simple questions before reaching for a tool.
|
|
62
|
+
|
|
63
|
+
## Example output
|
|
64
|
+
I'll check the config before updating it.
|
|
65
|
+
read_file(path="/config.json")
|
grucli/theme.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized theme and color constants for grucli UI.
|
|
3
|
+
All ANSI color codes and styling are defined here for consistency.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Colors:
|
|
10
|
+
"""ANSI color codes for terminal output."""
|
|
11
|
+
|
|
12
|
+
# Branding colors
|
|
13
|
+
PRIMARY = "\033[38;5;33m" # Deep sky blue (better contrast than 39)
|
|
14
|
+
SECONDARY = "\033[38;5;135m" # Medium purple (better contrast than 141)
|
|
15
|
+
ACCENT = "\033[38;5;214m" # Orange/Gold
|
|
16
|
+
|
|
17
|
+
# Semantic colors
|
|
18
|
+
SUCCESS = "\033[38;5;71m" # Medium green
|
|
19
|
+
WARNING = "\033[38;5;214m" # Orange
|
|
20
|
+
ERROR = "\033[38;5;196m" # Red
|
|
21
|
+
INFO = "\033[38;5;33m" # Blue
|
|
22
|
+
MUTED = "\033[38;5;244m" # Medium gray (readable on both)
|
|
23
|
+
WHITE = "\033[97m" # Bright white
|
|
24
|
+
THINK_COLOR = "\033[38;5;213m" # Pinkish for reasoning
|
|
25
|
+
|
|
26
|
+
# Tool category colors
|
|
27
|
+
READ_OP = "\033[38;5;45m" # Cyan for read operations
|
|
28
|
+
WRITE_OP = "\033[38;5;214m" # Orange for write operations
|
|
29
|
+
DESTRUCTIVE_OP = "\033[91m" # Red for destructive operations
|
|
30
|
+
|
|
31
|
+
# UI element colors
|
|
32
|
+
PROMPT = "\033[38;5;39;1m" # Bold blue for prompts
|
|
33
|
+
HEADER = "\033[1;4m" # Bold underline for headers
|
|
34
|
+
TITLE = "\033[38;5;39;1m" # Bold blue for titles
|
|
35
|
+
SUBTITLE = "\033[38;5;245m" # Dim gray for subtitles
|
|
36
|
+
|
|
37
|
+
# Text modifiers
|
|
38
|
+
DIM = "\033[2m"
|
|
39
|
+
BOLD = "\033[1m"
|
|
40
|
+
ITALIC = "\033[3m"
|
|
41
|
+
UNDERLINE = "\033[4m"
|
|
42
|
+
REVERSE = "\033[7m"
|
|
43
|
+
RESET = "\033[0m"
|
|
44
|
+
|
|
45
|
+
# Input states
|
|
46
|
+
INPUT_ACTIVE = "\033[38;5;39m" # Blue for active input
|
|
47
|
+
INPUT_DISABLED = "\033[38;5;240m" # Dark gray for disabled
|
|
48
|
+
INPUT_CONFIRM = "\033[38;5;220m" # Gold for confirmation
|
|
49
|
+
INPUT_ERROR = "\033[91m" # Red for error state
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Borders:
|
|
53
|
+
"""Box drawing characters for clean UI elements."""
|
|
54
|
+
|
|
55
|
+
# Rounded corners
|
|
56
|
+
TOP_LEFT = "╭"
|
|
57
|
+
TOP_RIGHT = "╮"
|
|
58
|
+
BOTTOM_LEFT = "╰"
|
|
59
|
+
BOTTOM_RIGHT = "╯"
|
|
60
|
+
|
|
61
|
+
# Lines
|
|
62
|
+
HORIZONTAL = "─"
|
|
63
|
+
VERTICAL = "│"
|
|
64
|
+
|
|
65
|
+
# T-junctions
|
|
66
|
+
T_LEFT = "├"
|
|
67
|
+
T_RIGHT = "┤"
|
|
68
|
+
T_TOP = "┬"
|
|
69
|
+
T_BOTTOM = "┴"
|
|
70
|
+
|
|
71
|
+
# Cross
|
|
72
|
+
CROSS = "┼"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Icons:
|
|
76
|
+
"""Unicode icons for visual enhancement."""
|
|
77
|
+
|
|
78
|
+
# Status icons
|
|
79
|
+
CHECK = "✓"
|
|
80
|
+
CROSS = "✗"
|
|
81
|
+
WARNING = "⚠"
|
|
82
|
+
INFO = "ℹ"
|
|
83
|
+
|
|
84
|
+
# Tool icons
|
|
85
|
+
READ = "[READ]"
|
|
86
|
+
WRITE = "[WRITE]"
|
|
87
|
+
DELETE = "[DELETE]"
|
|
88
|
+
FOLDER = "[FOLDER]"
|
|
89
|
+
|
|
90
|
+
# UI icons
|
|
91
|
+
ARROW_RIGHT = "→"
|
|
92
|
+
ARROW_DOWN = "↓"
|
|
93
|
+
BULLET = "•"
|
|
94
|
+
DIAMOND = "◆"
|
|
95
|
+
CIRCLE = "○"
|
|
96
|
+
CIRCLE_FILLED = "●"
|
|
97
|
+
|
|
98
|
+
# Spinner frames
|
|
99
|
+
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Styles:
|
|
103
|
+
"""Pre-composed style combinations."""
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def success(text: str) -> str:
|
|
107
|
+
return f"{Colors.SUCCESS}{text}{Colors.RESET}"
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def error(text: str) -> str:
|
|
111
|
+
return f"{Colors.ERROR}{text}{Colors.RESET}"
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def warning(text: str) -> str:
|
|
115
|
+
return f"{Colors.WARNING}{text}{Colors.RESET}"
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def info(text: str) -> str:
|
|
119
|
+
return f"{Colors.INFO}{text}{Colors.RESET}"
|
|
120
|
+
|
|
121
|
+
@staticmethod
|
|
122
|
+
def muted(text: str) -> str:
|
|
123
|
+
return f"{Colors.MUTED}{text}{Colors.RESET}"
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def bold(text: str) -> str:
|
|
127
|
+
return f"{Colors.BOLD}{text}{Colors.RESET}"
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def primary(text: str) -> str:
|
|
131
|
+
return f"{Colors.PRIMARY}{text}{Colors.RESET}"
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def accent(text: str) -> str:
|
|
135
|
+
return f"{Colors.ACCENT}{text}{Colors.RESET}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_terminal_width() -> int:
|
|
139
|
+
"""Get terminal width, defaulting to 80 if unavailable."""
|
|
140
|
+
import shutil
|
|
141
|
+
try:
|
|
142
|
+
return shutil.get_terminal_size().columns
|
|
143
|
+
except Exception:
|
|
144
|
+
return 80
|