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/ui.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UI components for grucli - polished terminal interface.
|
|
3
|
+
"""
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
import shutil
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
from prompt_toolkit.application import Application
|
|
10
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
11
|
+
from prompt_toolkit.keys import Keys
|
|
12
|
+
from prompt_toolkit.layout.containers import Window
|
|
13
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
14
|
+
from prompt_toolkit.layout.layout import Layout
|
|
15
|
+
from prompt_toolkit.styles import Style
|
|
16
|
+
from . import interrupt
|
|
17
|
+
from .theme import Colors, Borders, Icons, Styles, get_terminal_width
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def print_messages(messages):
|
|
21
|
+
"""Print chat history to the terminal."""
|
|
22
|
+
from . import tools # avoid circular import
|
|
23
|
+
|
|
24
|
+
for msg in messages:
|
|
25
|
+
role = msg.get('role')
|
|
26
|
+
content = msg.get('content', '')
|
|
27
|
+
reasoning = msg.get('reasoning')
|
|
28
|
+
|
|
29
|
+
if role == 'system':
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
if role == 'user':
|
|
33
|
+
# Remove the attached files part for display if it exists
|
|
34
|
+
display_content = content
|
|
35
|
+
if "--- ATTACHED FILES ---" in content:
|
|
36
|
+
display_content = content.split("--- ATTACHED FILES ---")[0].strip()
|
|
37
|
+
|
|
38
|
+
print(f"\n{Colors.PRIMARY}> {Colors.RESET}{display_content}")
|
|
39
|
+
|
|
40
|
+
elif role == 'assistant':
|
|
41
|
+
ai_prefix = f"{Colors.SECONDARY}{Icons.DIAMOND}{Colors.RESET} "
|
|
42
|
+
print(f"\n{ai_prefix}", end="")
|
|
43
|
+
|
|
44
|
+
# Show reasoning duration if available
|
|
45
|
+
duration = msg.get('thinking_duration')
|
|
46
|
+
if duration:
|
|
47
|
+
print(f"{Colors.MUTED}[Thought for {duration:.0f}s]{Colors.RESET}")
|
|
48
|
+
elif reasoning:
|
|
49
|
+
print(f"{Colors.MUTED}[Thought for history]{Colors.RESET}")
|
|
50
|
+
|
|
51
|
+
# Clean content from <think> tags if they exist (some models output them in text)
|
|
52
|
+
display_content = content
|
|
53
|
+
if "<think>" in content and "</think>" in content:
|
|
54
|
+
# Remove everything between and including <think> tags
|
|
55
|
+
display_content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL).strip()
|
|
56
|
+
|
|
57
|
+
# Find tool calls in content
|
|
58
|
+
cmds = tools.parse_commands(display_content)
|
|
59
|
+
|
|
60
|
+
if not cmds:
|
|
61
|
+
print(display_content)
|
|
62
|
+
else:
|
|
63
|
+
# Split content by tool calls to print them with their boxes
|
|
64
|
+
last_idx = 0
|
|
65
|
+
for cmd in cmds:
|
|
66
|
+
tool_str = cmd['original']
|
|
67
|
+
start_pos = display_content.find(tool_str, last_idx)
|
|
68
|
+
|
|
69
|
+
if start_pos != -1:
|
|
70
|
+
# Print text before tool
|
|
71
|
+
pre_text = display_content[last_idx:start_pos]
|
|
72
|
+
if pre_text:
|
|
73
|
+
print(pre_text)
|
|
74
|
+
|
|
75
|
+
# Print tool box
|
|
76
|
+
name = cmd['name']
|
|
77
|
+
args = cmd['args']
|
|
78
|
+
|
|
79
|
+
from .main import get_tool_category
|
|
80
|
+
category = get_tool_category(name)
|
|
81
|
+
print(format_tool_call_block(name, args, category))
|
|
82
|
+
|
|
83
|
+
last_idx = start_pos + len(tool_str)
|
|
84
|
+
|
|
85
|
+
# Print remaining text
|
|
86
|
+
remaining = display_content[last_idx:]
|
|
87
|
+
if remaining:
|
|
88
|
+
print(remaining)
|
|
89
|
+
print()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def print_ascii_art():
|
|
93
|
+
"""Print a generated ASCII art logo for GRU CLI with a seamless vertical gradient."""
|
|
94
|
+
# Font style: ANSI Shadow
|
|
95
|
+
logo = [
|
|
96
|
+
" ██████╗ ██████╗ ██╗ ██╗ ██████╗██╗ ██╗",
|
|
97
|
+
"██╔════╝ ██╔══██╗██║ ██║██╔════╝██║ ██║",
|
|
98
|
+
"██║ ███╗██████╔╝██║ ██║██║ ██║ ██║",
|
|
99
|
+
"██║ ██║██╔══██╗██║ ██║██║ ██║ ██║",
|
|
100
|
+
"╚██████╔╝██║ ██║╚██████╔╝╚██████╗███████╗██║",
|
|
101
|
+
" ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝"
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
# 6-step Gradient: Blue -> Light Blue -> Purple/Pink
|
|
105
|
+
# Maps exactly to the 6 lines of the logo
|
|
106
|
+
gradient = [
|
|
107
|
+
"\033[38;5;33m", # DodgerBlue1
|
|
108
|
+
"\033[38;5;39m", # DeepSkyBlue1
|
|
109
|
+
"\033[38;5;75m", # SteelBlue1
|
|
110
|
+
"\033[38;5;111m", # SkyBlue2
|
|
111
|
+
"\033[38;5;141m", # MediumPurple1
|
|
112
|
+
"\033[38;5;177m" # Violet
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
indent = " "
|
|
116
|
+
print()
|
|
117
|
+
for i, line in enumerate(logo):
|
|
118
|
+
# Use the corresponding color for the line, default to last color if out of bounds
|
|
119
|
+
color = gradient[i] if i < len(gradient) else gradient[-1]
|
|
120
|
+
print(f"{indent}{color}{line}{Colors.RESET}")
|
|
121
|
+
|
|
122
|
+
from . import __version__
|
|
123
|
+
print(f"{indent}{Colors.MUTED}Version {__version__}{Colors.RESET}")
|
|
124
|
+
print()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def select_option(options, title="Select an option:", is_root=False):
|
|
128
|
+
"""
|
|
129
|
+
Display a styled selection menu with keyboard navigation.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
options: List of options (strings or (style, text) tuples)
|
|
133
|
+
title: Header text for the menu
|
|
134
|
+
is_root: If True, Ctrl+C exits app; otherwise goes back
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
(selected_value, selected_index) tuple
|
|
138
|
+
"""
|
|
139
|
+
if not options:
|
|
140
|
+
raise ValueError("No options provided")
|
|
141
|
+
|
|
142
|
+
selected_index = 0
|
|
143
|
+
result = None
|
|
144
|
+
|
|
145
|
+
from prompt_toolkit.formatted_text import ANSI, to_formatted_text
|
|
146
|
+
|
|
147
|
+
def get_text():
|
|
148
|
+
text = []
|
|
149
|
+
# Title with accent color
|
|
150
|
+
text.extend(to_formatted_text(ANSI(f" {Colors.HEADER}{title}{Colors.RESET}\n\n")))
|
|
151
|
+
|
|
152
|
+
for i, option in enumerate(options):
|
|
153
|
+
if isinstance(option, tuple):
|
|
154
|
+
_, opt_text = option
|
|
155
|
+
else:
|
|
156
|
+
opt_text = option
|
|
157
|
+
|
|
158
|
+
if i == selected_index:
|
|
159
|
+
# Selected item: highlighted with arrow
|
|
160
|
+
prefix = f" {Icons.ARROW_RIGHT} "
|
|
161
|
+
# Bold white text on purple background
|
|
162
|
+
line = f"\033[38;5;255;48;5;61;1m{prefix}{opt_text}\033[0m\n"
|
|
163
|
+
text.extend(to_formatted_text(ANSI(line)))
|
|
164
|
+
else:
|
|
165
|
+
prefix = " "
|
|
166
|
+
text.extend(to_formatted_text(ANSI(f"{prefix}{opt_text}\n")))
|
|
167
|
+
|
|
168
|
+
# Footer hint
|
|
169
|
+
text.extend(to_formatted_text(ANSI(f"\n {Colors.MUTED}{Icons.BULLET} Use arrows to navigate, Enter to select{Colors.RESET}\n")))
|
|
170
|
+
|
|
171
|
+
return text
|
|
172
|
+
|
|
173
|
+
kb = KeyBindings()
|
|
174
|
+
|
|
175
|
+
@kb.add(Keys.Up)
|
|
176
|
+
@kb.add('k') # vim-style
|
|
177
|
+
def _(event):
|
|
178
|
+
nonlocal selected_index
|
|
179
|
+
selected_index = (selected_index - 1) % len(options)
|
|
180
|
+
|
|
181
|
+
@kb.add(Keys.Down)
|
|
182
|
+
@kb.add('j') # vim-style
|
|
183
|
+
def _(event):
|
|
184
|
+
nonlocal selected_index
|
|
185
|
+
selected_index = (selected_index + 1) % len(options)
|
|
186
|
+
|
|
187
|
+
@kb.add(Keys.Enter)
|
|
188
|
+
def _(event):
|
|
189
|
+
nonlocal result
|
|
190
|
+
val = options[selected_index][1] if isinstance(options[selected_index], tuple) else options[selected_index]
|
|
191
|
+
result = (val, selected_index)
|
|
192
|
+
event.app.exit()
|
|
193
|
+
|
|
194
|
+
@kb.add(Keys.ControlC)
|
|
195
|
+
@kb.add(Keys.Escape, eager=True)
|
|
196
|
+
def _(event):
|
|
197
|
+
if not is_root:
|
|
198
|
+
event.app.exit(exception=interrupt.BackSignal)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if interrupt.should_quit():
|
|
202
|
+
interrupt.clear_bottom_warning()
|
|
203
|
+
event.app.exit(exception=KeyboardInterrupt)
|
|
204
|
+
else:
|
|
205
|
+
interrupt.show_bottom_warning()
|
|
206
|
+
|
|
207
|
+
style = Style.from_dict({
|
|
208
|
+
'': '#eeeeee bg:#1c1c1c', # Default: light gray on dark gray
|
|
209
|
+
'title': '#af5fff bold', # Purple, bold
|
|
210
|
+
'selected': '#ffffff bg:#5f5faf', # White on purple
|
|
211
|
+
'option': '#eeeeee', # Near white
|
|
212
|
+
'cloud': '#0087ff', # Blue for cloud
|
|
213
|
+
'hint': '#8a8a8a', # Medium gray
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
app = Application(
|
|
217
|
+
layout=Layout(Window(content=FormattedTextControl(get_text))),
|
|
218
|
+
key_bindings=kb,
|
|
219
|
+
style=style,
|
|
220
|
+
full_screen=True,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
app.run()
|
|
224
|
+
return result
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def strip_ansi(text: str) -> str:
|
|
228
|
+
"""Remove ANSI escape codes for length calculation."""
|
|
229
|
+
return re.sub(r'\033\[[0-9;]*m', '', text)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_visible_len(text: str) -> int:
|
|
233
|
+
"""Get the visible length of a string containing ANSI escape codes."""
|
|
234
|
+
return len(strip_ansi(text))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def print_header(title: str, subtitle: str = None):
|
|
238
|
+
"""Print a styled header with optional subtitle."""
|
|
239
|
+
width = get_terminal_width()
|
|
240
|
+
|
|
241
|
+
print()
|
|
242
|
+
print(f"{Colors.PRIMARY}{Colors.BOLD}{title}{Colors.RESET}")
|
|
243
|
+
if subtitle:
|
|
244
|
+
print(f"{Colors.MUTED}{subtitle}{Colors.RESET}")
|
|
245
|
+
print(f"{Colors.MUTED}{Borders.HORIZONTAL * min(width - 4, 60)}{Colors.RESET}")
|
|
246
|
+
print()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def print_box(content: str, style: str = "info", title: str = None):
|
|
250
|
+
"""
|
|
251
|
+
Print content in a styled box.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
content: Text content (can be multiline)
|
|
255
|
+
style: "info", "success", "warning", "error"
|
|
256
|
+
title: Optional title for the box
|
|
257
|
+
"""
|
|
258
|
+
width = min(get_terminal_width() - 4, 70)
|
|
259
|
+
|
|
260
|
+
style_colors = {
|
|
261
|
+
"info": Colors.INFO,
|
|
262
|
+
"success": Colors.SUCCESS,
|
|
263
|
+
"warning": Colors.WARNING,
|
|
264
|
+
"error": Colors.ERROR,
|
|
265
|
+
}
|
|
266
|
+
color = style_colors.get(style, Colors.INFO)
|
|
267
|
+
|
|
268
|
+
lines = content.split('\n')
|
|
269
|
+
|
|
270
|
+
# Top border
|
|
271
|
+
if title:
|
|
272
|
+
title_text = f" {title} "
|
|
273
|
+
padding = width - len(title_text) - 2
|
|
274
|
+
print(f"{color}{Borders.TOP_LEFT}{Borders.HORIZONTAL}{title_text}{Borders.HORIZONTAL * padding}{Borders.TOP_RIGHT}{Colors.RESET}")
|
|
275
|
+
else:
|
|
276
|
+
print(f"{color}{Borders.TOP_LEFT}{Borders.HORIZONTAL * width}{Borders.TOP_RIGHT}{Colors.RESET}")
|
|
277
|
+
|
|
278
|
+
# Content lines
|
|
279
|
+
for line in lines:
|
|
280
|
+
visible = strip_ansi(line)
|
|
281
|
+
if len(visible) > width - 2:
|
|
282
|
+
line = line[:width - 5] + "..."
|
|
283
|
+
visible = strip_ansi(line)
|
|
284
|
+
padding = " " * (width - len(visible) - 2)
|
|
285
|
+
print(f"{color}{Borders.VERTICAL}{Colors.RESET} {line}{padding}{color}{Borders.VERTICAL}{Colors.RESET}")
|
|
286
|
+
|
|
287
|
+
# Bottom border
|
|
288
|
+
print(f"{color}{Borders.BOTTOM_LEFT}{Borders.HORIZONTAL * width}{Borders.BOTTOM_RIGHT}{Colors.RESET}")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def print_divider(char: str = None, color: str = None):
|
|
292
|
+
"""Print a horizontal divider line."""
|
|
293
|
+
width = min(get_terminal_width() - 4, 60)
|
|
294
|
+
c = char or Borders.HORIZONTAL
|
|
295
|
+
col = color or Colors.MUTED
|
|
296
|
+
print(f"{col}{c * width}{Colors.RESET}")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def print_tips():
|
|
300
|
+
"""Print the getting started tips (inspired by reference image)."""
|
|
301
|
+
print(f"{Colors.MUTED}Tips for getting started:{Colors.RESET}")
|
|
302
|
+
print(f"{Colors.MUTED}1. Ask questions, edit files, or run commands.{Colors.RESET}")
|
|
303
|
+
print(f"{Colors.MUTED}2. Be specific for the best results.{Colors.RESET}")
|
|
304
|
+
print(f"{Colors.MUTED}3. Use {Colors.SECONDARY}@file{Colors.MUTED} to reference files in your prompt.{Colors.RESET}")
|
|
305
|
+
print(f"{Colors.MUTED}4. {Colors.SECONDARY}/help{Colors.MUTED} for more information.{Colors.RESET}")
|
|
306
|
+
print()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def format_tool_call_block(name: str, args: dict, category: str = "read") -> str:
|
|
310
|
+
"""
|
|
311
|
+
Format a tool call as a styled block.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
name: Tool name
|
|
315
|
+
args: Tool arguments
|
|
316
|
+
category: "read", "write", or "destructive"
|
|
317
|
+
"""
|
|
318
|
+
width = min(get_terminal_width() - 4, 65)
|
|
319
|
+
|
|
320
|
+
colors = {
|
|
321
|
+
"read": Colors.READ_OP,
|
|
322
|
+
"write": Colors.WRITE_OP,
|
|
323
|
+
"destructive": Colors.DESTRUCTIVE_OP,
|
|
324
|
+
}
|
|
325
|
+
icons = {
|
|
326
|
+
"read": Icons.READ,
|
|
327
|
+
"write": Icons.WRITE,
|
|
328
|
+
"destructive": Icons.DELETE,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
color = colors.get(category, Colors.INFO)
|
|
332
|
+
icon = icons.get(category, Icons.INFO)
|
|
333
|
+
|
|
334
|
+
lines = []
|
|
335
|
+
|
|
336
|
+
# Header
|
|
337
|
+
header = f" {icon} {category.upper()} "
|
|
338
|
+
padding = width - len(header) - 2
|
|
339
|
+
lines.append(f"{color}{Borders.TOP_LEFT}{Borders.HORIZONTAL}{header}{Borders.HORIZONTAL * padding}{Borders.TOP_RIGHT}{Colors.RESET}")
|
|
340
|
+
|
|
341
|
+
# Tool name
|
|
342
|
+
tool_line_content = f" {Colors.BOLD}{name}{Colors.RESET}"
|
|
343
|
+
visible_tool_len = get_visible_len(tool_line_content)
|
|
344
|
+
padding_tool = " " * (width - visible_tool_len)
|
|
345
|
+
lines.append(f"{color}{Borders.VERTICAL}{Colors.RESET}{tool_line_content}{padding_tool}{color}{Borders.VERTICAL}{Colors.RESET}")
|
|
346
|
+
|
|
347
|
+
# Arguments
|
|
348
|
+
display_args = args.copy()
|
|
349
|
+
if name == 'edit_file' and not display_args.get('new_string'):
|
|
350
|
+
display_args['new_string'] = "(empty)"
|
|
351
|
+
|
|
352
|
+
for key, value in display_args.items():
|
|
353
|
+
if isinstance(value, str):
|
|
354
|
+
# Special handling for 'content' in create_file (show multiline)
|
|
355
|
+
if name == 'create_file' and key == 'content':
|
|
356
|
+
content_lines = value.split('\n')
|
|
357
|
+
display_lines = content_lines[:20] # Cap at 20 lines
|
|
358
|
+
has_more = len(content_lines) > 20
|
|
359
|
+
|
|
360
|
+
# Label row
|
|
361
|
+
arg_label = f" {Colors.MUTED}{key}:{Colors.RESET}"
|
|
362
|
+
visible_label_len = get_visible_len(arg_label)
|
|
363
|
+
padding_label = " " * (width - visible_label_len)
|
|
364
|
+
lines.append(f"{color}{Borders.VERTICAL}{Colors.RESET}{arg_label}{padding_label}{color}{Borders.VERTICAL}{Colors.RESET}")
|
|
365
|
+
|
|
366
|
+
# Content rows
|
|
367
|
+
for subline in display_lines:
|
|
368
|
+
# Truncate line width
|
|
369
|
+
if len(subline) > width - 6:
|
|
370
|
+
subline = subline[:width - 9] + "..."
|
|
371
|
+
|
|
372
|
+
row = f" {Colors.MUTED}{subline}{Colors.RESET}"
|
|
373
|
+
visible_row_len = get_visible_len(row)
|
|
374
|
+
padding_row = " " * (width - visible_row_len)
|
|
375
|
+
lines.append(f"{color}{Borders.VERTICAL}{Colors.RESET}{row}{padding_row}{color}{Borders.VERTICAL}{Colors.RESET}")
|
|
376
|
+
|
|
377
|
+
if has_more:
|
|
378
|
+
more_row = f" {Colors.MUTED}... ({len(content_lines) - 20} more lines){Colors.RESET}"
|
|
379
|
+
visible_more_len = get_visible_len(more_row)
|
|
380
|
+
padding_more = " " * (width - visible_more_len)
|
|
381
|
+
lines.append(f"{color}{Borders.VERTICAL}{Colors.RESET}{more_row}{padding_more}{color}{Borders.VERTICAL}{Colors.RESET}")
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
# Default truncation for other string args
|
|
385
|
+
display = value[:width - 15] + "..." if len(value) > width - 12 else value
|
|
386
|
+
if '\n' in display:
|
|
387
|
+
display = display.split('\n')[0] + "..."
|
|
388
|
+
else:
|
|
389
|
+
display = repr(value)
|
|
390
|
+
|
|
391
|
+
arg_line_content = f" {Colors.MUTED}{key}:{Colors.RESET} {display}"
|
|
392
|
+
visible_arg_len = get_visible_len(arg_line_content)
|
|
393
|
+
|
|
394
|
+
if visible_arg_len > width - 2:
|
|
395
|
+
arg_line_content = arg_line_content[:width - 5] + "..."
|
|
396
|
+
visible_arg_len = get_visible_len(arg_line_content)
|
|
397
|
+
|
|
398
|
+
padding_arg = " " * (width - visible_arg_len)
|
|
399
|
+
lines.append(f"{color}{Borders.VERTICAL}{Colors.RESET}{arg_line_content}{padding_arg}{color}{Borders.VERTICAL}{Colors.RESET}")
|
|
400
|
+
|
|
401
|
+
# Footer
|
|
402
|
+
lines.append(f"{color}{Borders.BOTTOM_LEFT}{Borders.HORIZONTAL * width}{Borders.BOTTOM_RIGHT}{Colors.RESET}")
|
|
403
|
+
|
|
404
|
+
return "\n".join(lines)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def format_diff(old_content: str, new_content: str, filename: str) -> str:
|
|
408
|
+
"""
|
|
409
|
+
Format file changes as a GitHub-style diff.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
old_content: Original content being replaced
|
|
413
|
+
new_content: New content
|
|
414
|
+
filename: Name of the file being edited
|
|
415
|
+
"""
|
|
416
|
+
width = min(get_terminal_width() - 4, 70)
|
|
417
|
+
|
|
418
|
+
lines = []
|
|
419
|
+
|
|
420
|
+
# Header
|
|
421
|
+
header = f" {Icons.WRITE} EDIT: {filename} "
|
|
422
|
+
padding = max(0, width - len(header) - 2)
|
|
423
|
+
lines.append(f"{Colors.WRITE_OP}{Borders.TOP_LEFT}{Borders.HORIZONTAL}{header}{Borders.HORIZONTAL * padding}{Borders.TOP_RIGHT}{Colors.RESET}")
|
|
424
|
+
|
|
425
|
+
# Old content (red, prefixed with -)
|
|
426
|
+
old_lines = old_content.split('\n')
|
|
427
|
+
for old_line in old_lines[:10]: # Limit display
|
|
428
|
+
display = old_line[:width - 6] if len(old_line) > width - 6 else old_line
|
|
429
|
+
lines.append(f"{Colors.WRITE_OP}{Borders.VERTICAL}{Colors.RESET} {Colors.ERROR}- {display}{Colors.RESET}")
|
|
430
|
+
if len(old_lines) > 10:
|
|
431
|
+
lines.append(f"{Colors.WRITE_OP}{Borders.VERTICAL}{Colors.RESET} {Colors.MUTED} ... ({len(old_lines) - 10} more lines){Colors.RESET}")
|
|
432
|
+
|
|
433
|
+
# Separator
|
|
434
|
+
lines.append(f"{Colors.WRITE_OP}{Borders.VERTICAL}{Colors.MUTED}{'─' * (width - 1)}{Colors.RESET}")
|
|
435
|
+
|
|
436
|
+
# New content (green, prefixed with +)
|
|
437
|
+
if new_content is None:
|
|
438
|
+
new_content = ""
|
|
439
|
+
|
|
440
|
+
new_lines = new_content.split('\n')
|
|
441
|
+
for new_line in new_lines[:10]:
|
|
442
|
+
display = new_line[:width - 6] if len(new_line) > width - 6 else new_line
|
|
443
|
+
lines.append(f"{Colors.WRITE_OP}{Borders.VERTICAL}{Colors.RESET} {Colors.SUCCESS}+ {display}{Colors.RESET}")
|
|
444
|
+
if len(new_lines) > 10:
|
|
445
|
+
lines.append(f"{Colors.WRITE_OP}{Borders.VERTICAL}{Colors.RESET} {Colors.MUTED} ... ({len(new_lines) - 10} more lines){Colors.RESET}")
|
|
446
|
+
|
|
447
|
+
# Footer
|
|
448
|
+
lines.append(f"{Colors.WRITE_OP}{Borders.BOTTOM_LEFT}{Borders.HORIZONTAL * width}{Borders.BOTTOM_RIGHT}{Colors.RESET}")
|
|
449
|
+
|
|
450
|
+
return "\n".join(lines)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class Spinner:
|
|
454
|
+
"""Animated spinner for LLM generation feedback."""
|
|
455
|
+
|
|
456
|
+
def __init__(self, message: str = "Generating"):
|
|
457
|
+
self.message = message
|
|
458
|
+
self.frames = Icons.SPINNER_FRAMES
|
|
459
|
+
self.frame_idx = 0
|
|
460
|
+
self._running = False
|
|
461
|
+
self._thread = None
|
|
462
|
+
|
|
463
|
+
def _animate(self):
|
|
464
|
+
import sys
|
|
465
|
+
import time
|
|
466
|
+
|
|
467
|
+
while self._running:
|
|
468
|
+
frame = self.frames[self.frame_idx % len(self.frames)]
|
|
469
|
+
sys.stdout.write(f"\r{Colors.SECONDARY}{frame}{Colors.RESET} {Colors.MUTED}{self.message}...{Colors.RESET} ")
|
|
470
|
+
sys.stdout.flush()
|
|
471
|
+
self.frame_idx += 1
|
|
472
|
+
time.sleep(0.1)
|
|
473
|
+
|
|
474
|
+
def start(self):
|
|
475
|
+
import threading
|
|
476
|
+
|
|
477
|
+
self._running = True
|
|
478
|
+
self._thread = threading.Thread(target=self._animate, daemon=True)
|
|
479
|
+
self._thread.start()
|
|
480
|
+
|
|
481
|
+
def stop(self):
|
|
482
|
+
import sys
|
|
483
|
+
|
|
484
|
+
self._running = False
|
|
485
|
+
if self._thread:
|
|
486
|
+
self._thread.join(timeout=0.2)
|
|
487
|
+
# Clear the spinner line
|
|
488
|
+
sys.stdout.write(f"\r{' ' * 40}\r")
|
|
489
|
+
sys.stdout.flush()
|
|
490
|
+
|
|
491
|
+
def __enter__(self):
|
|
492
|
+
self.start()
|
|
493
|
+
return self
|
|
494
|
+
|
|
495
|
+
def __exit__(self, *args):
|
|
496
|
+
self.stop()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: grucli
|
|
3
|
+
Version: 3.3.0
|
|
4
|
+
Summary: A command-line interface for interacting with local and remote LLMs.
|
|
5
|
+
Home-page: https://github.com/grufr/grucli
|
|
6
|
+
Author: grufr
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.6
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Dynamic: author
|
|
14
|
+
Dynamic: classifier
|
|
15
|
+
Dynamic: description
|
|
16
|
+
Dynamic: description-content-type
|
|
17
|
+
Dynamic: home-page
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
Dynamic: requires-python
|
|
20
|
+
Dynamic: summary
|
|
21
|
+
|
|
22
|
+
# gru-cli -- Version 3.3.0
|
|
23
|
+
|
|
24
|
+
`grucli` is a CLI tool for agentic LLMs that works with both local LLMs and API-based models such as Claude, Gemini, GPT-5 and others.
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
## why?
|
|
29
|
+
I hate how other CLI tools have big ass 3000 line+ system prompts and don't allow you to run local models or use custom APIs.
|
|
30
|
+
|
|
31
|
+
So I decided to make my own CLI tool for *professional vibe coders🔥🔥🔥*.
|
|
32
|
+
|
|
33
|
+
I'll try to improve on it over time, if I don't give up on this project that is.
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
* **Interactive Chat:** Have a conversation with your chosen LLM right from your terminal.
|
|
38
|
+
* **Model Selection:** Easily switch between different locally-served models.
|
|
39
|
+
* **Command System:** Use slash commands (`/help`, `/model`, `/clear`, `/manage-api-keys`) for quick actions.
|
|
40
|
+
* **Context-Aware:** Automatically injects your project's file tree into the system prompt to give the LLM better context.
|
|
41
|
+
* **Encrypted API keys** You don't need to worry about someone snooping your API keys, they're encrypted :)
|
|
42
|
+
|
|
43
|
+
## Supported APIs
|
|
44
|
+
|
|
45
|
+
GruCLI supports models from the following providers:
|
|
46
|
+
|
|
47
|
+
* **OpenAI:** All available models, including GPT-5.2
|
|
48
|
+
* **Anthropic:** All available models, including Claude Opus and Sonnet 4.5.
|
|
49
|
+
* **Gemini:** Supports BOTH Gemini API <bold>AND</bold> Google OAUTH
|
|
50
|
+
* **Ollama:** Supports both local and cloud models.
|
|
51
|
+
* **LM Studio:** Supports all local models.
|
|
52
|
+
* **Cerebras:** Supports their API available models.
|
|
53
|
+
|
|
54
|
+
* If a model doesn't show up on your selected API, you can just type it out.
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
To install `grucli`, clone this repository and use `pip` to install the package.
|
|
59
|
+
|
|
60
|
+
1. **Clone the repository (or download the source):**
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/grufr/grucli.git
|
|
63
|
+
cd gru-agentic-cli
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
2. **Install the package:**
|
|
67
|
+
* **For regular use:** This installs the CLI like any other tool.
|
|
68
|
+
```bash
|
|
69
|
+
pip install .
|
|
70
|
+
```
|
|
71
|
+
* **For development:** This links the command to your source code for instant updates.
|
|
72
|
+
```bash
|
|
73
|
+
pip install -e .
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
This will install the `grucli` command and all its dependencies.
|
|
77
|
+
|
|
78
|
+
## WARNING !!
|
|
79
|
+
<div align="right"><sub style="position:relative; top:15px;">windows sucks anyways...</sub></div>
|
|
80
|
+
This project was **NOT** tested on Windows, it was only tested on Linux and macOS
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
## Usage
|
|
84
|
+
|
|
85
|
+
Once installed, you can start the CLI by simply running:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
grucli
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
This will ask you to select an available model and then take you to the chatting interface.
|
|
92
|
+
|
|
93
|
+
### Commands
|
|
94
|
+
|
|
95
|
+
* `/help`: Show available commands.
|
|
96
|
+
* `/model`: Re-open the model selection menu.
|
|
97
|
+
* `/clear`: Clear the current chat history.
|
|
98
|
+
* `/manage-api-keys`: Manage your saved API keys.
|
|
99
|
+
* `/gemini-login`: Login with your Google account.
|
|
100
|
+
* `/gemini-auth-mode`: Switch Gemini authentication between API Key and Google Auth.
|
|
101
|
+
* `/exit`: Exit the application.
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
If you want to contribute to or modify `grucli`, you should install it in "editable" mode as described above (`pip install -e .`).
|
|
106
|
+
|
|
107
|
+
This allows you to edit the Python files in the `src/grucli/` directory and have your changes be live the next time you run the `grucli` command. There is no need to run `pip install .` again after every code change.
|
|
108
|
+
|
|
109
|
+
If you change dependencies in `requirements.txt` or settings in `setup.py`, you should run `pip install -e .` again to apply those changes.
|
|
110
|
+
## Testing
|
|
111
|
+
|
|
112
|
+
`grucli` uses `pytest` for testing. To run the tests, you must install `pytest` manually as it is not included in `requirements.txt` to keep the production installation lightweight for end-users.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pip install pytest
|
|
116
|
+
pytest tests/
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Current Tests
|
|
120
|
+
|
|
121
|
+
The test suite covers:
|
|
122
|
+
* **API Interactions:** Validating stream processing and tool call parsing.
|
|
123
|
+
* **UI Formatting:** Ensuring tool blocks, diffs, and spinners render correctly.
|
|
124
|
+
* **Permissions System:** Verifying that tool execution permissions are respected.
|
|
125
|
+
* **ANSI Utilities:** Testing color and icon formatting.
|
|
126
|
+
|
|
127
|
+
If you want to contribute your code must pass all tests.
|
|
128
|
+
|
|
129
|
+
## Uninstallation
|
|
130
|
+
|
|
131
|
+
To remove `grucli` from your system, simply run:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip uninstall grucli
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
This project is licensed under the MIT License. See the `LICENSE` file for details.
|
|
140
|
+
|
|
141
|
+
## funny
|
|
142
|
+
|
|
143
|
+
some of the code here was generated by grucli itself :)
|
|
144
|
+
|
|
145
|
+
My first open source project 💖🎀
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
grucli/__init__.py,sha256=MPSbkJS_-QSkLK7sMlFXFf5iWlbcSL18Dj2ZiMCtS9c,22
|
|
2
|
+
grucli/api.py,sha256=gLSy-4I1XKdg_6IIqfG_cZJmnrw0vQFuiJPnVnb-2AU,28031
|
|
3
|
+
grucli/auth.py,sha256=aHsxv3SDpVcA-KViJvIf6kRZjGBVH0oVq7YuqhNcSBc,3992
|
|
4
|
+
grucli/chat_manager.py,sha256=xfPv9d7-0JtS0mGE7ELLCjfUdjGvpIi-_roKQlNvUrw,6790
|
|
5
|
+
grucli/commands.py,sha256=p50HUvRTnY72ULtIagvqtONWKhFOF5qb5j2ZPXAxwU8,13640
|
|
6
|
+
grucli/config.py,sha256=c0J4jZJqYjIT03JaGscw1BS8LJrLyDp4s6Ib0I38-k4,8048
|
|
7
|
+
grucli/handlers.py,sha256=BXGCEQ39MFEN11y-tpLDVhLnlLcJmc8UUNsEZen3WDU,2520
|
|
8
|
+
grucli/interrupt.py,sha256=bf1WYKaXtXWogC10aSSinJ9kQAd4w663tr39N60lkng,4558
|
|
9
|
+
grucli/main.py,sha256=9wBuPY8ulTcslJMgm0ny8SCEiKqDUDnyCNc_xk4gNnw,23807
|
|
10
|
+
grucli/permissions.py,sha256=2vP9CSIhSKuyZYcU5aqn7RuL_a2pzb6l3Ckpbv7sxTw,6287
|
|
11
|
+
grucli/stats.py,sha256=PMBPTjxTGQ9_x1U5C-ikHe8baCwKioqiAy5twyHKk00,4269
|
|
12
|
+
grucli/theme.py,sha256=UypI5vF2XGPd_NULxUZZQ8YQyF2FYfU8XOqVk4U-6Lo,3845
|
|
13
|
+
grucli/tools.py,sha256=jvj6TgwseX5Kj7bm7zeInALsluG2F51wj1yeEJDTFGU,13261
|
|
14
|
+
grucli/ui.py,sha256=kbb2nua-iUxKnZF7WhrCbNoNYgkSIUpssJzkl5xRsgo,18774
|
|
15
|
+
grucli/sysprompts/main_sysprompt.txt,sha256=UwytPbRbCKKnaeQ06upELNM2UZOhCm7y2n4ZKVwlRRM,4984
|
|
16
|
+
grucli-3.3.0.dist-info/licenses/LICENSE,sha256=supNcZqGUNNSRFpxuC7mFg2phvHT0lsE-hirZxEVj0o,1062
|
|
17
|
+
grucli-3.3.0.dist-info/METADATA,sha256=EMAMQc0Om8QS7JEHbPZfUY5FheTBHNOPtQxJBBqN_7Y,5022
|
|
18
|
+
grucli-3.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
19
|
+
grucli-3.3.0.dist-info/entry_points.txt,sha256=1QpTF511iXtt2AA7Ux-X39e10YyyQ44PJfRs2j0jbV4,44
|
|
20
|
+
grucli-3.3.0.dist-info/top_level.txt,sha256=oqE29Dh1Mn0uBqx4wdXDD_W7PAMMF1pdCcV4PJwqj1M,7
|
|
21
|
+
grucli-3.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 grufr
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT of OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|