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/config.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import base64
|
|
4
|
+
from cryptography.fernet import Fernet
|
|
5
|
+
from cryptography.hazmat.primitives import hashes
|
|
6
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
7
|
+
import getpass
|
|
8
|
+
import sys
|
|
9
|
+
from . import interrupt
|
|
10
|
+
|
|
11
|
+
CONFIG_DIR_NAME = '.grucli'
|
|
12
|
+
|
|
13
|
+
def get_config_dir():
|
|
14
|
+
home_dir = os.path.expanduser("~")
|
|
15
|
+
config_dir = os.path.join(home_dir, CONFIG_DIR_NAME)
|
|
16
|
+
|
|
17
|
+
os.makedirs(config_dir, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
return config_dir
|
|
20
|
+
|
|
21
|
+
def get_key_storage_path(api_type='gemini'):
|
|
22
|
+
config_dir = get_config_dir()
|
|
23
|
+
if api_type == 'gemini':
|
|
24
|
+
return os.path.join(config_dir, 'api_keys_gemini.enc')
|
|
25
|
+
elif api_type == 'cerebras':
|
|
26
|
+
return os.path.join(config_dir, 'api_keys_cerebras.enc')
|
|
27
|
+
elif api_type == 'anthropic':
|
|
28
|
+
return os.path.join(config_dir, 'api_keys_anthropic.enc')
|
|
29
|
+
elif api_type == 'openai':
|
|
30
|
+
return os.path.join(config_dir, 'api_keys_openai.enc')
|
|
31
|
+
elif api_type == 'ollama':
|
|
32
|
+
return os.path.join(config_dir, 'api_keys_ollama.enc')
|
|
33
|
+
else:
|
|
34
|
+
return os.path.join(config_dir, 'api_keys.enc')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_settings_path():
|
|
38
|
+
config_dir = get_config_dir()
|
|
39
|
+
return os.path.join(config_dir, 'settings.json')
|
|
40
|
+
|
|
41
|
+
def load_settings():
|
|
42
|
+
path = get_settings_path()
|
|
43
|
+
if os.path.exists(path):
|
|
44
|
+
try:
|
|
45
|
+
with open(path, 'r') as f:
|
|
46
|
+
return json.load(f)
|
|
47
|
+
except Exception:
|
|
48
|
+
return {}
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
def save_settings(settings):
|
|
52
|
+
path = get_settings_path()
|
|
53
|
+
with open(path, 'w') as f:
|
|
54
|
+
json.dump(settings, f, indent=2)
|
|
55
|
+
|
|
56
|
+
def is_using_google_auth():
|
|
57
|
+
settings = load_settings()
|
|
58
|
+
return settings.get('use_google_auth', False)
|
|
59
|
+
|
|
60
|
+
def set_use_google_auth(use_google_auth: bool):
|
|
61
|
+
settings = load_settings()
|
|
62
|
+
settings['use_google_auth'] = use_google_auth
|
|
63
|
+
save_settings(settings)
|
|
64
|
+
|
|
65
|
+
def get_google_cloud_project():
|
|
66
|
+
settings = load_settings()
|
|
67
|
+
return settings.get('google_cloud_project')
|
|
68
|
+
|
|
69
|
+
def set_google_cloud_project(project_id: str):
|
|
70
|
+
settings = load_settings()
|
|
71
|
+
settings['google_cloud_project'] = project_id
|
|
72
|
+
save_settings(settings)
|
|
73
|
+
|
|
74
|
+
def get_history_file_path():
|
|
75
|
+
config_dir = get_config_dir()
|
|
76
|
+
return os.path.join(config_dir, 'history.txt')
|
|
77
|
+
|
|
78
|
+
def prune_history(file_path, max_entries=25):
|
|
79
|
+
if not os.path.exists(file_path):
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
83
|
+
lines = f.readlines()
|
|
84
|
+
|
|
85
|
+
entries = []
|
|
86
|
+
current_entry = []
|
|
87
|
+
for line in lines:
|
|
88
|
+
if line.startswith('#'):
|
|
89
|
+
if current_entry:
|
|
90
|
+
entries.append(current_entry)
|
|
91
|
+
current_entry = [line]
|
|
92
|
+
else:
|
|
93
|
+
current_entry.append(line)
|
|
94
|
+
if current_entry:
|
|
95
|
+
entries.append(current_entry)
|
|
96
|
+
|
|
97
|
+
if len(entries) > max_entries:
|
|
98
|
+
entries = entries[-max_entries:]
|
|
99
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
100
|
+
for entry in entries:
|
|
101
|
+
f.writelines(entry)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
def derive_key_from_password(password: str, salt: bytes) -> bytes:
|
|
106
|
+
kdf = PBKDF2HMAC(
|
|
107
|
+
algorithm=hashes.SHA256(),
|
|
108
|
+
length=32,
|
|
109
|
+
salt=salt,
|
|
110
|
+
iterations=100000,
|
|
111
|
+
)
|
|
112
|
+
key = base64.urlsafe_b64encode(kdf.derive(password.encode()))
|
|
113
|
+
return key
|
|
114
|
+
|
|
115
|
+
def encrypt_data(data: str, master_password: str) -> tuple[bytes, bytes]:
|
|
116
|
+
salt = os.urandom(16)
|
|
117
|
+
|
|
118
|
+
key = derive_key_from_password(master_password, salt)
|
|
119
|
+
|
|
120
|
+
cipher = Fernet(key)
|
|
121
|
+
encrypted_data = cipher.encrypt(data.encode())
|
|
122
|
+
|
|
123
|
+
return encrypted_data, salt
|
|
124
|
+
|
|
125
|
+
def decrypt_data(encrypted_data: bytes, master_password: str, salt: bytes) -> str:
|
|
126
|
+
key = derive_key_from_password(master_password, salt)
|
|
127
|
+
|
|
128
|
+
cipher = Fernet(key)
|
|
129
|
+
decrypted_data = cipher.decrypt(encrypted_data)
|
|
130
|
+
|
|
131
|
+
return decrypted_data.decode()
|
|
132
|
+
|
|
133
|
+
def save_encrypted_api_key(api_key: str, api_type='gemini'):
|
|
134
|
+
print("\nTo securely store your API key, please set a password.")
|
|
135
|
+
print("This password will be used to encrypt your API key.")
|
|
136
|
+
|
|
137
|
+
while True:
|
|
138
|
+
password = interrupt.safe_getpass("Set password: ")
|
|
139
|
+
confirm_password = interrupt.safe_getpass("Confirm password: ")
|
|
140
|
+
|
|
141
|
+
if password == confirm_password:
|
|
142
|
+
break
|
|
143
|
+
else:
|
|
144
|
+
print("Passwords do not match. Please try again.")
|
|
145
|
+
|
|
146
|
+
encrypted_data, salt = encrypt_data(api_key, password)
|
|
147
|
+
|
|
148
|
+
storage_path = get_key_storage_path(api_type)
|
|
149
|
+
key_name = f'{api_type}_api_key'
|
|
150
|
+
storage_data = {
|
|
151
|
+
key_name: base64.b64encode(encrypted_data).decode(),
|
|
152
|
+
'salt': base64.b64encode(salt).decode(),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
with open(storage_path, 'w') as f:
|
|
156
|
+
json.dump(storage_data, f)
|
|
157
|
+
|
|
158
|
+
print(f"API key saved securely in {storage_path}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def change_encrypted_api_key_password(api_type='gemini'):
|
|
162
|
+
storage_path = get_key_storage_path(api_type)
|
|
163
|
+
|
|
164
|
+
if not os.path.exists(storage_path):
|
|
165
|
+
print("No saved API key found.")
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
with open(storage_path, 'r') as f:
|
|
170
|
+
storage_data = json.load(f)
|
|
171
|
+
|
|
172
|
+
key_name = f'{api_type}_api_key'
|
|
173
|
+
if key_name not in storage_data or 'salt' not in storage_data:
|
|
174
|
+
print("Saved API key data is corrupted.")
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
encrypted_data = base64.b64decode(storage_data[key_name])
|
|
178
|
+
salt = base64.b64decode(storage_data['salt'])
|
|
179
|
+
|
|
180
|
+
current_password = interrupt.safe_getpass("\nEnter current password: ")
|
|
181
|
+
decrypted_api_key = decrypt_data(encrypted_data, current_password, salt)
|
|
182
|
+
|
|
183
|
+
while True:
|
|
184
|
+
new_password = interrupt.safe_getpass("Enter new password: ")
|
|
185
|
+
confirm_new_password = interrupt.safe_getpass("Confirm new password: ")
|
|
186
|
+
|
|
187
|
+
if new_password == confirm_new_password:
|
|
188
|
+
break
|
|
189
|
+
else:
|
|
190
|
+
print("Passwords do not match. Please try again.")
|
|
191
|
+
|
|
192
|
+
new_encrypted_data, new_salt = encrypt_data(decrypted_api_key, new_password)
|
|
193
|
+
|
|
194
|
+
key_name = f'{api_type}_api_key'
|
|
195
|
+
new_storage_data = {
|
|
196
|
+
key_name: base64.b64encode(new_encrypted_data).decode(),
|
|
197
|
+
'salt': base64.b64encode(new_salt).decode(),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
with open(storage_path, 'w') as f:
|
|
201
|
+
json.dump(new_storage_data, f)
|
|
202
|
+
|
|
203
|
+
print("Password changed successfully.")
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
except Exception as e:
|
|
207
|
+
print(f"Error changing password: {e}")
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def remove_saved_api_key(api_type='gemini'):
|
|
212
|
+
storage_path = get_key_storage_path(api_type)
|
|
213
|
+
|
|
214
|
+
if not os.path.exists(storage_path):
|
|
215
|
+
print("No saved API key found.")
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
confirm = interrupt.safe_input(f"Are you sure you want to remove the saved API key? (y/N): ").strip().lower()
|
|
219
|
+
if confirm == 'y':
|
|
220
|
+
try:
|
|
221
|
+
os.remove(storage_path)
|
|
222
|
+
print("Saved API key removed successfully.")
|
|
223
|
+
return True
|
|
224
|
+
except Exception as e:
|
|
225
|
+
print(f"Error removing saved API key: {e}")
|
|
226
|
+
return False
|
|
227
|
+
else:
|
|
228
|
+
print("Operation cancelled.")
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def load_decrypted_api_key(api_type='gemini') -> str:
|
|
233
|
+
storage_path = get_key_storage_path(api_type)
|
|
234
|
+
|
|
235
|
+
if not os.path.exists(storage_path):
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
with open(storage_path, 'r') as f:
|
|
240
|
+
storage_data = json.load(f)
|
|
241
|
+
|
|
242
|
+
key_name = f'{api_type}_api_key'
|
|
243
|
+
if key_name not in storage_data or 'salt' not in storage_data:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
encrypted_data = base64.b64decode(storage_data[key_name])
|
|
247
|
+
salt = base64.b64decode(storage_data['salt'])
|
|
248
|
+
|
|
249
|
+
password = interrupt.safe_getpass("\nEnter password to unlock API key: ")
|
|
250
|
+
|
|
251
|
+
decrypted_api_key = decrypt_data(encrypted_data, password, salt)
|
|
252
|
+
return decrypted_api_key
|
|
253
|
+
|
|
254
|
+
except Exception as e:
|
|
255
|
+
print(f"Error loading API key: {e}")
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def has_saved_api_key(api_type='gemini') -> bool:
|
|
260
|
+
storage_path = get_key_storage_path(api_type)
|
|
261
|
+
return os.path.exists(storage_path)
|
|
262
|
+
|
grucli/handlers.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Key binding handlers for grucli chat interface.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import asyncio
|
|
8
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
9
|
+
from prompt_toolkit.keys import Keys
|
|
10
|
+
from prompt_toolkit.formatted_text import HTML
|
|
11
|
+
from prompt_toolkit.filters import has_completions
|
|
12
|
+
from . import interrupt
|
|
13
|
+
from . import permissions
|
|
14
|
+
from .theme import Colors
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_chat_bindings(state):
|
|
18
|
+
"""Get key bindings for the chat prompt."""
|
|
19
|
+
kb = KeyBindings()
|
|
20
|
+
|
|
21
|
+
@kb.add(Keys.BackTab)
|
|
22
|
+
def _(event):
|
|
23
|
+
# Toggle accepting edits (Shift+Tab)
|
|
24
|
+
store = permissions.PERMISSION_STORE
|
|
25
|
+
if store.is_allowed(permissions.PermissionGroup.WRITE):
|
|
26
|
+
store.revoke_always(permissions.PermissionGroup.WRITE)
|
|
27
|
+
store.revoke_always(permissions.PermissionGroup.READ)
|
|
28
|
+
else:
|
|
29
|
+
store.allow_always(permissions.PermissionGroup.WRITE)
|
|
30
|
+
# allow_always(WRITE) automatically grants READ in permissions.py
|
|
31
|
+
event.app.invalidate()
|
|
32
|
+
|
|
33
|
+
@kb.add(Keys.ControlC)
|
|
34
|
+
@kb.add(Keys.Escape)
|
|
35
|
+
def _(event):
|
|
36
|
+
# Clear current input first
|
|
37
|
+
if event.current_buffer.text:
|
|
38
|
+
event.current_buffer.text = ""
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
if interrupt.should_quit():
|
|
42
|
+
interrupt.clear_bottom_warning()
|
|
43
|
+
sys.exit(0)
|
|
44
|
+
|
|
45
|
+
# Show styled warning in bottom toolbar
|
|
46
|
+
state['toolbar'] = HTML(
|
|
47
|
+
f'<style fg="#ffff00">{interrupt.get_quit_hint()}</style>'
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Background task to clear the message after 3 seconds
|
|
51
|
+
async def clear_warning():
|
|
52
|
+
await asyncio.sleep(3)
|
|
53
|
+
# Only clear if it hasn't been set to something else
|
|
54
|
+
if state.get('toolbar') and "ctrl+c" in str(state['toolbar']).lower():
|
|
55
|
+
state['toolbar'] = None
|
|
56
|
+
event.app.invalidate()
|
|
57
|
+
|
|
58
|
+
event.app.create_background_task(clear_warning())
|
|
59
|
+
|
|
60
|
+
@kb.add(Keys.ControlJ)
|
|
61
|
+
def _(event):
|
|
62
|
+
# Ctrl+J for newline
|
|
63
|
+
event.current_buffer.insert_text('\n')
|
|
64
|
+
|
|
65
|
+
@kb.add(Keys.Enter, filter=has_completions)
|
|
66
|
+
def _(event):
|
|
67
|
+
# If completion menu is open, Enter selects the current item
|
|
68
|
+
buf = event.current_buffer
|
|
69
|
+
if buf.complete_state and buf.complete_state.current_completion:
|
|
70
|
+
buf.apply_completion(buf.complete_state.current_completion)
|
|
71
|
+
else:
|
|
72
|
+
# Fallback in case filter was true but state vanished or no item selected
|
|
73
|
+
event.current_buffer.validate_and_handle()
|
|
74
|
+
|
|
75
|
+
return kb
|
grucli/interrupt.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interrupt handling and input utilities for grucli.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import shutil
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import termios
|
|
12
|
+
except ImportError:
|
|
13
|
+
termios = None
|
|
14
|
+
|
|
15
|
+
_last_interrupt_time = 0
|
|
16
|
+
_warning_stop_event = threading.Event()
|
|
17
|
+
_warning_stop_event.set() # Initially stopped
|
|
18
|
+
_warning_lock = threading.Lock()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BackSignal(Exception):
|
|
22
|
+
"""Signal to go back to previous menu."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_quit_hint() -> str:
|
|
27
|
+
"""Get the quit hint message."""
|
|
28
|
+
return "Press Ctrl+C again to quit"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def hide_control_chars():
|
|
32
|
+
"""Hide control character echo in terminal."""
|
|
33
|
+
try:
|
|
34
|
+
attrs = termios.tcgetattr(sys.stdin)
|
|
35
|
+
attrs[3] &= ~termios.ECHOCTL
|
|
36
|
+
termios.tcsetattr(sys.stdin, termios.TCSANOW, attrs)
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def show_control_chars():
|
|
42
|
+
"""Show control character echo in terminal."""
|
|
43
|
+
try:
|
|
44
|
+
attrs = termios.tcgetattr(sys.stdin)
|
|
45
|
+
attrs[3] |= termios.ECHOCTL
|
|
46
|
+
termios.tcsetattr(sys.stdin, termios.TCSANOW, attrs)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def set_echo(enable: bool):
|
|
52
|
+
"""Disable or enable terminal echo."""
|
|
53
|
+
if not termios:
|
|
54
|
+
return
|
|
55
|
+
try:
|
|
56
|
+
attrs = termios.tcgetattr(sys.stdin)
|
|
57
|
+
if enable:
|
|
58
|
+
attrs[3] |= termios.ECHO
|
|
59
|
+
else:
|
|
60
|
+
attrs[3] &= ~termios.ECHO
|
|
61
|
+
termios.tcsetattr(sys.stdin, termios.TCSANOW, attrs)
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def flush_input():
|
|
67
|
+
"""Flush pending terminal input."""
|
|
68
|
+
if not termios:
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
termios.tcflush(sys.stdin, termios.TCIFLUSH)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def should_quit():
|
|
77
|
+
"""Check if user has pressed Ctrl+C twice within 3 seconds."""
|
|
78
|
+
global _last_interrupt_time
|
|
79
|
+
now = time.time()
|
|
80
|
+
if now - _last_interrupt_time < 3:
|
|
81
|
+
return True
|
|
82
|
+
_last_interrupt_time = now
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _warning_loop():
|
|
87
|
+
"""Background thread that displays the quit warning."""
|
|
88
|
+
while not _warning_stop_event.is_set():
|
|
89
|
+
try:
|
|
90
|
+
columns, rows = shutil.get_terminal_size()
|
|
91
|
+
target_row = max(1, rows - 1)
|
|
92
|
+
msg = get_quit_hint()
|
|
93
|
+
# \033[0;93m resets everything and then sets foreground to bright yellow
|
|
94
|
+
sys.stdout.write(f"\0337\033[{target_row};1H\033[0;93m{msg}\033[0m\0338")
|
|
95
|
+
sys.stdout.flush()
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
for _ in range(5):
|
|
100
|
+
if _warning_stop_event.is_set():
|
|
101
|
+
break
|
|
102
|
+
time.sleep(0.1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def clear_bottom_warning():
|
|
106
|
+
"""Clear the bottom warning bar."""
|
|
107
|
+
_warning_stop_event.set()
|
|
108
|
+
try:
|
|
109
|
+
_, rows = shutil.get_terminal_size()
|
|
110
|
+
target_row = max(1, rows - 1)
|
|
111
|
+
sys.stdout.write(f"\0337\033[{target_row};1H\033[K\0338")
|
|
112
|
+
sys.stdout.flush()
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def show_bottom_warning():
|
|
118
|
+
"""Show the bottom warning bar."""
|
|
119
|
+
with _warning_lock:
|
|
120
|
+
if not _warning_stop_event.is_set():
|
|
121
|
+
return
|
|
122
|
+
_warning_stop_event.clear()
|
|
123
|
+
threading.Thread(target=_warning_loop, daemon=True).start()
|
|
124
|
+
|
|
125
|
+
def stop_later():
|
|
126
|
+
time.sleep(3)
|
|
127
|
+
clear_bottom_warning()
|
|
128
|
+
|
|
129
|
+
threading.Thread(target=stop_later, daemon=True).start()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def handle_interrupt(exit_msg="\nbye"):
|
|
133
|
+
"""Handle keyboard interrupt with double-press detection."""
|
|
134
|
+
if should_quit():
|
|
135
|
+
clear_bottom_warning()
|
|
136
|
+
if exit_msg:
|
|
137
|
+
print(exit_msg)
|
|
138
|
+
sys.exit(0)
|
|
139
|
+
|
|
140
|
+
show_bottom_warning()
|
|
141
|
+
return get_quit_hint()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def prompt_input(prompt_text, is_password=False):
|
|
145
|
+
"""Get user input with styled prompt."""
|
|
146
|
+
from prompt_toolkit import PromptSession
|
|
147
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
148
|
+
from prompt_toolkit.keys import Keys
|
|
149
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
150
|
+
|
|
151
|
+
kb = KeyBindings()
|
|
152
|
+
|
|
153
|
+
@kb.add(Keys.ControlC)
|
|
154
|
+
@kb.add(Keys.Escape)
|
|
155
|
+
def _(event):
|
|
156
|
+
event.app.exit(exception=KeyboardInterrupt)
|
|
157
|
+
|
|
158
|
+
session = PromptSession(key_bindings=kb)
|
|
159
|
+
return session.prompt(ANSI(prompt_text), is_password=is_password)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def safe_input(prompt):
|
|
163
|
+
"""Get input with interrupt handling."""
|
|
164
|
+
while True:
|
|
165
|
+
try:
|
|
166
|
+
return prompt_input(prompt)
|
|
167
|
+
except KeyboardInterrupt:
|
|
168
|
+
handle_interrupt()
|
|
169
|
+
print()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def safe_getpass(prompt):
|
|
173
|
+
"""Get password input with interrupt handling."""
|
|
174
|
+
while True:
|
|
175
|
+
try:
|
|
176
|
+
return prompt_input(prompt, is_password=True)
|
|
177
|
+
except KeyboardInterrupt:
|
|
178
|
+
handle_interrupt()
|
|
179
|
+
print()
|