ghostcode-canary 0.1.25-canary.0
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.
- package/LICENSE +201 -0
- package/PAT.txt +1 -0
- package/README.md +95 -0
- package/cli.js +92 -0
- package/error/err1.txt +12 -0
- package/package.json +31 -0
- package/setup.js +67 -0
- package/src/ghostcode/__init__.py +3 -0
- package/src/ghostcode/__main__.py +3 -0
- package/src/ghostcode/_version.py +17 -0
- package/src/ghostcode/agents/__init__.py +20 -0
- package/src/ghostcode/agents/code.py +104 -0
- package/src/ghostcode/agents/explore.py +50 -0
- package/src/ghostcode/ai/__init__.py +0 -0
- package/src/ghostcode/ai/anthropic_client.py +8 -0
- package/src/ghostcode/ai/base.py +33 -0
- package/src/ghostcode/ai/factory.py +38 -0
- package/src/ghostcode/ai/groq_client.py +7 -0
- package/src/ghostcode/ai/nvidia_client.py +7 -0
- package/src/ghostcode/ai/ollama_client.py +7 -0
- package/src/ghostcode/ai/openai_client.py +7 -0
- package/src/ghostcode/ai/openai_compat.py +139 -0
- package/src/ghostcode/ai/opencode_go_client.py +7 -0
- package/src/ghostcode/ai/opencode_zen_client.py +7 -0
- package/src/ghostcode/ai/openrouter_client.py +7 -0
- package/src/ghostcode/ai/registry.py +169 -0
- package/src/ghostcode/app.py +148 -0
- package/src/ghostcode/config.py +24 -0
- package/src/ghostcode/core/__init__.py +5 -0
- package/src/ghostcode/core/commands.py +55 -0
- package/src/ghostcode/core/file_ops.py +127 -0
- package/src/ghostcode/core/hooks.py +122 -0
- package/src/ghostcode/core/memory.py +137 -0
- package/src/ghostcode/core/prompt.py +73 -0
- package/src/ghostcode/core/reminders.py +61 -0
- package/src/ghostcode/core/security.py +119 -0
- package/src/ghostcode/core/tasks.py +125 -0
- package/src/ghostcode/tools/__init__.py +2 -0
- package/src/ghostcode/tools/defs.py +325 -0
- package/src/ghostcode/tools/executor.py +261 -0
- package/src/ghostcode/tui/__init__.py +0 -0
- package/src/ghostcode/tui/app.py +12 -0
- package/src/ghostcode/tui/dialogs.py +241 -0
- package/src/ghostcode/tui/screens.py +799 -0
- package/src/ghostcode/tui/widgets.py +32 -0
- package/src/ghostcode/utils/__init__.py +0 -0
- package/src/ghostcode/utils/config_loader.py +102 -0
- package/src/ghostcode/utils/logger.py +16 -0
- package/src/pyproject.toml +18 -0
- package/token.txt +1 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ghostcode.core.hooks import hook_registry
|
|
8
|
+
from ghostcode.core.memory import save_memory, search_memory, delete_memory, list_memories
|
|
9
|
+
from ghostcode.core.security import is_action_denied, needs_confirmation, scan_for_secrets
|
|
10
|
+
from ghostcode.core.tasks import task_manager, TaskStatus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def needs_permission(tool_name: str, args: dict) -> bool:
|
|
14
|
+
if tool_name == "run_command":
|
|
15
|
+
return True
|
|
16
|
+
denied, _ = is_action_denied(tool_name, args)
|
|
17
|
+
if denied:
|
|
18
|
+
return True
|
|
19
|
+
needs, _ = needs_confirmation(tool_name, args)
|
|
20
|
+
if needs:
|
|
21
|
+
return True
|
|
22
|
+
path_str = args.get("file_path") or args.get("path") or ""
|
|
23
|
+
if not path_str:
|
|
24
|
+
return False
|
|
25
|
+
cwd = os.getcwd()
|
|
26
|
+
abs_path = os.path.abspath(path_str) if not os.path.isabs(path_str) else os.path.abspath(path_str)
|
|
27
|
+
return not abs_path.startswith(Path(cwd).resolve().as_posix())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def execute_tool(tool_name: str, args: dict) -> str:
|
|
31
|
+
allowed, reason = hook_registry.run_before(tool_name, args)
|
|
32
|
+
if not allowed:
|
|
33
|
+
return json.dumps({"error": f"Hook blocked: {reason}"})
|
|
34
|
+
|
|
35
|
+
if tool_name == "read_file":
|
|
36
|
+
result = _exec_read_file(args)
|
|
37
|
+
elif tool_name == "write_file":
|
|
38
|
+
result = _exec_write_file(args)
|
|
39
|
+
elif tool_name == "edit_file":
|
|
40
|
+
result = _exec_edit_file(args)
|
|
41
|
+
elif tool_name == "run_command":
|
|
42
|
+
result = await _exec_run_command(args)
|
|
43
|
+
elif tool_name == "list_files":
|
|
44
|
+
result = _exec_list_files(args)
|
|
45
|
+
elif tool_name == "grep_search":
|
|
46
|
+
result = _exec_grep_search(args)
|
|
47
|
+
elif tool_name == "web_search":
|
|
48
|
+
result = json.dumps({"error": "web_search not available directly — use the provider's web search"})
|
|
49
|
+
elif tool_name == "web_fetch":
|
|
50
|
+
result = await _exec_web_fetch(args)
|
|
51
|
+
elif tool_name == "todo_write":
|
|
52
|
+
result = json.dumps({"ok": True, "note": "Todo list updated in conversation context"})
|
|
53
|
+
elif tool_name == "memory_save":
|
|
54
|
+
result = _exec_memory_save(args)
|
|
55
|
+
elif tool_name == "memory_search":
|
|
56
|
+
result = _exec_memory_search(args)
|
|
57
|
+
elif tool_name == "memory_delete":
|
|
58
|
+
result = _exec_memory_delete(args)
|
|
59
|
+
elif tool_name == "memory_list":
|
|
60
|
+
result = _exec_memory_list(args)
|
|
61
|
+
elif tool_name == "task_create":
|
|
62
|
+
result = _exec_task_create(args)
|
|
63
|
+
elif tool_name == "task_stop":
|
|
64
|
+
result = _exec_task_stop(args)
|
|
65
|
+
elif tool_name == "task_list":
|
|
66
|
+
result = _exec_task_list(args)
|
|
67
|
+
else:
|
|
68
|
+
return json.dumps({"error": f"Unknown tool: {tool_name}"})
|
|
69
|
+
|
|
70
|
+
result = hook_registry.run_after(tool_name, args, result)
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _exec_read_file(args: dict) -> str:
|
|
75
|
+
path = args["file_path"]
|
|
76
|
+
offset = args.get("offset")
|
|
77
|
+
limit = args.get("limit")
|
|
78
|
+
try:
|
|
79
|
+
p = Path(path).expanduser().resolve()
|
|
80
|
+
if not p.exists():
|
|
81
|
+
return json.dumps({"error": f"File not found: {path}"})
|
|
82
|
+
lines = p.read_text(encoding="utf-8").splitlines()
|
|
83
|
+
total = len(lines)
|
|
84
|
+
if offset:
|
|
85
|
+
start = max(0, int(offset) - 1)
|
|
86
|
+
lines = lines[start:]
|
|
87
|
+
if limit:
|
|
88
|
+
lines = lines[:int(limit)]
|
|
89
|
+
if total > len(lines):
|
|
90
|
+
content = "\n".join(lines) + f"\n\n[Read {len(lines)} of {total} lines]"
|
|
91
|
+
else:
|
|
92
|
+
content = "\n".join(lines)
|
|
93
|
+
return content
|
|
94
|
+
except Exception as e:
|
|
95
|
+
return json.dumps({"error": str(e)})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _exec_write_file(args: dict) -> str:
|
|
99
|
+
path = args["file_path"]
|
|
100
|
+
content = args["content"]
|
|
101
|
+
secrets = scan_for_secrets(content)
|
|
102
|
+
if secrets:
|
|
103
|
+
return json.dumps({"error": f"Refusing to write — potential secrets detected: {secrets[0][:20]}..."})
|
|
104
|
+
try:
|
|
105
|
+
p = Path(path).expanduser().resolve()
|
|
106
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
p.write_text(content, encoding="utf-8")
|
|
108
|
+
size = len(content.encode("utf-8"))
|
|
109
|
+
return json.dumps({"ok": True, "path": str(p), "bytes": size})
|
|
110
|
+
except Exception as e:
|
|
111
|
+
return json.dumps({"error": str(e)})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _exec_edit_file(args: dict) -> str:
|
|
115
|
+
path = args["file_path"]
|
|
116
|
+
old = args["old_string"]
|
|
117
|
+
new = args["new_string"]
|
|
118
|
+
try:
|
|
119
|
+
p = Path(path).expanduser().resolve()
|
|
120
|
+
content = p.read_text(encoding="utf-8")
|
|
121
|
+
if old not in content:
|
|
122
|
+
return json.dumps({"error": "old_string not found in file"})
|
|
123
|
+
new_content = content.replace(old, new, 1)
|
|
124
|
+
p.write_text(new_content, encoding="utf-8")
|
|
125
|
+
return json.dumps({"ok": True, "path": str(p), "replaced": 1})
|
|
126
|
+
except Exception as e:
|
|
127
|
+
return json.dumps({"error": str(e)})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _exec_run_command(args: dict) -> str:
|
|
131
|
+
command = args["command"]
|
|
132
|
+
workdir = args.get("workdir") or os.getcwd()
|
|
133
|
+
timeout = args.get("timeout", 120000) / 1000
|
|
134
|
+
try:
|
|
135
|
+
proc = await asyncio.create_subprocess_shell(
|
|
136
|
+
command,
|
|
137
|
+
stdout=subprocess.PIPE,
|
|
138
|
+
stderr=subprocess.PIPE,
|
|
139
|
+
cwd=workdir,
|
|
140
|
+
)
|
|
141
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
142
|
+
out = stdout.decode("utf-8", errors="replace")
|
|
143
|
+
err = stderr.decode("utf-8", errors="replace")
|
|
144
|
+
result = {"exit_code": proc.returncode, "stdout": out, "stderr": err}
|
|
145
|
+
return json.dumps(result)
|
|
146
|
+
except asyncio.TimeoutError:
|
|
147
|
+
return json.dumps({"error": "Command timed out"})
|
|
148
|
+
except Exception as e:
|
|
149
|
+
return json.dumps({"error": str(e)})
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _exec_list_files(args: dict) -> str:
|
|
153
|
+
path = args.get("path") or os.getcwd()
|
|
154
|
+
pattern = args.get("pattern")
|
|
155
|
+
try:
|
|
156
|
+
p = Path(path).expanduser().resolve()
|
|
157
|
+
if pattern:
|
|
158
|
+
matches = list(p.rglob(pattern))
|
|
159
|
+
return json.dumps({"files": [str(m.relative_to(p)) for m in sorted(matches) if m.is_file()]})
|
|
160
|
+
items = []
|
|
161
|
+
for entry in sorted(p.iterdir()):
|
|
162
|
+
name = entry.name + "/" if entry.is_dir() else entry.name
|
|
163
|
+
items.append(name)
|
|
164
|
+
return json.dumps({"files": items, "path": str(p)})
|
|
165
|
+
except Exception as e:
|
|
166
|
+
return json.dumps({"error": str(e)})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _exec_grep_search(args: dict) -> str:
|
|
170
|
+
pattern = args["pattern"]
|
|
171
|
+
include = args.get("include")
|
|
172
|
+
path = args.get("path") or os.getcwd()
|
|
173
|
+
try:
|
|
174
|
+
import re
|
|
175
|
+
p = Path(path).expanduser().resolve()
|
|
176
|
+
if not p.exists():
|
|
177
|
+
return json.dumps({"error": f"Path not found: {path}"})
|
|
178
|
+
if p.is_file():
|
|
179
|
+
files = [p]
|
|
180
|
+
elif include:
|
|
181
|
+
files = []
|
|
182
|
+
for ext in include.split(","):
|
|
183
|
+
files.extend(p.rglob(ext.strip()))
|
|
184
|
+
else:
|
|
185
|
+
files = [f for f in p.rglob("*") if f.is_file() and not f.name.startswith(".")]
|
|
186
|
+
results = []
|
|
187
|
+
for f in files:
|
|
188
|
+
try:
|
|
189
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
190
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
191
|
+
if re.search(pattern, line):
|
|
192
|
+
results.append({"file": str(f.relative_to(p) if f.is_relative_to(p) else f), "line": i, "content": line.strip()})
|
|
193
|
+
if len(results) >= 200:
|
|
194
|
+
break
|
|
195
|
+
except Exception:
|
|
196
|
+
continue
|
|
197
|
+
if len(results) >= 200:
|
|
198
|
+
break
|
|
199
|
+
return json.dumps({"matches": results, "total": len(results)})
|
|
200
|
+
except Exception as e:
|
|
201
|
+
return json.dumps({"error": str(e)})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def _exec_web_fetch(args: dict) -> str:
|
|
205
|
+
url = args["url"]
|
|
206
|
+
import httpx
|
|
207
|
+
try:
|
|
208
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
209
|
+
resp = await client.get(url, follow_redirects=True)
|
|
210
|
+
resp.raise_for_status()
|
|
211
|
+
return resp.text[:50000]
|
|
212
|
+
except Exception as e:
|
|
213
|
+
return json.dumps({"error": str(e)})
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _exec_memory_save(args: dict) -> str:
|
|
217
|
+
name = args["name"]
|
|
218
|
+
content = args["content"]
|
|
219
|
+
mtype = args.get("type", "reference")
|
|
220
|
+
desc = args.get("description", "")
|
|
221
|
+
pinned = args.get("pinned", False)
|
|
222
|
+
result = save_memory(name, content, {"type": mtype, "description": desc, "pinned": pinned})
|
|
223
|
+
return json.dumps(result)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _exec_memory_search(args: dict) -> str:
|
|
227
|
+
query = args["query"]
|
|
228
|
+
results = search_memory(query)
|
|
229
|
+
return json.dumps({"matches": results, "total": len(results)})
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _exec_memory_delete(args: dict) -> str:
|
|
233
|
+
name = args["name"]
|
|
234
|
+
ok = delete_memory(name)
|
|
235
|
+
return json.dumps({"ok": ok, "note": f"Memory '{name}' {'deleted' if ok else 'not found'}"})
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _exec_memory_list(args: dict) -> str:
|
|
239
|
+
mtype = args.get("type")
|
|
240
|
+
results = list_memories(mtype)
|
|
241
|
+
return json.dumps({"memories": results, "total": len(results)})
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _exec_task_create(args: dict) -> str:
|
|
245
|
+
desc = args["description"]
|
|
246
|
+
prompt = args["prompt"]
|
|
247
|
+
subagent_type = args.get("subagent_type", "worker")
|
|
248
|
+
task = task_manager.create(desc, prompt, subagent_type)
|
|
249
|
+
return json.dumps({"task_id": task.id, "status": "pending", "description": desc})
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _exec_task_stop(args: dict) -> str:
|
|
253
|
+
task_id = args["task_id"]
|
|
254
|
+
ok = task_manager.stop(task_id)
|
|
255
|
+
return json.dumps({"ok": ok, "task_id": task_id})
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _exec_task_list(args: dict) -> str:
|
|
259
|
+
status = args.get("status")
|
|
260
|
+
tasks = task_manager.list_tasks(status)
|
|
261
|
+
return json.dumps({"tasks": tasks, "total": len(tasks)})
|
|
File without changes
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import textwrap
|
|
2
|
+
|
|
3
|
+
from textual import on
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.binding import Binding
|
|
6
|
+
from textual.containers import Vertical, Horizontal
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Input, Label
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ApiKeyDialog(ModalScreen):
|
|
12
|
+
def __init__(self, provider_id: str, provider_name: str):
|
|
13
|
+
super().__init__()
|
|
14
|
+
self._pid = provider_id
|
|
15
|
+
self._pname = provider_name
|
|
16
|
+
|
|
17
|
+
def compose(self) -> ComposeResult:
|
|
18
|
+
with Vertical(id="dialog"):
|
|
19
|
+
yield Label(f"Enter API key for [bold]{self._pname}[/bold]:", id="dialog-title")
|
|
20
|
+
yield Input(id="key-input", placeholder="sk-... or nvapi-...", password=True)
|
|
21
|
+
yield Label("Press [bold]Enter[/bold] to save - [bold]Esc[/bold] to cancel", id="dialog-hint")
|
|
22
|
+
yield from ()
|
|
23
|
+
|
|
24
|
+
CSS = """
|
|
25
|
+
ApiKeyDialog {
|
|
26
|
+
align: center middle;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#dialog {
|
|
30
|
+
width: 50;
|
|
31
|
+
height: auto;
|
|
32
|
+
padding: 2 3;
|
|
33
|
+
background: $surface;
|
|
34
|
+
border: tall $primary;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#dialog-title {
|
|
38
|
+
margin-bottom: 1;
|
|
39
|
+
text-style: bold;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#key-input {
|
|
43
|
+
width: 100%;
|
|
44
|
+
margin-bottom: 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#dialog-hint {
|
|
48
|
+
color: $text-muted;
|
|
49
|
+
}
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
BINDINGS = [
|
|
53
|
+
Binding("escape", "cancel", "Cancel"),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
def action_cancel(self):
|
|
57
|
+
self.dismiss(None)
|
|
58
|
+
|
|
59
|
+
@on(Input.Submitted, "#key-input")
|
|
60
|
+
def on_key_submitted(self, event: Input.Submitted):
|
|
61
|
+
self.dismiss(event.value.strip())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class UpdateDialog(ModalScreen):
|
|
65
|
+
def __init__(self, current: str, latest: str):
|
|
66
|
+
super().__init__()
|
|
67
|
+
self._current = current
|
|
68
|
+
self._latest = latest
|
|
69
|
+
self._updating = False
|
|
70
|
+
|
|
71
|
+
def compose(self) -> ComposeResult:
|
|
72
|
+
with Vertical(id="dialog"):
|
|
73
|
+
yield Label("[bold yellow]Update available[/bold yellow]", id="dialog-title")
|
|
74
|
+
yield Label(f"Current: v{self._current}", id="label-current")
|
|
75
|
+
yield Label(f"Latest: [bold green]v{self._latest}[/bold green]", id="label-latest")
|
|
76
|
+
yield Label("")
|
|
77
|
+
yield Label("To update, run this in your terminal:")
|
|
78
|
+
yield Label("[bold cyan]ghostcode update[/bold cyan]", id="label-command")
|
|
79
|
+
yield Label("")
|
|
80
|
+
yield Button("Close", id="btn-close")
|
|
81
|
+
|
|
82
|
+
CSS = """
|
|
83
|
+
UpdateDialog {
|
|
84
|
+
align: center middle;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#dialog {
|
|
88
|
+
width: 56;
|
|
89
|
+
height: auto;
|
|
90
|
+
padding: 2 3;
|
|
91
|
+
background: $surface;
|
|
92
|
+
border: tall $primary;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#dialog-title {
|
|
96
|
+
margin-bottom: 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
Label {
|
|
100
|
+
margin-bottom: 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#label-command {
|
|
104
|
+
padding: 1 2;
|
|
105
|
+
background: $background;
|
|
106
|
+
color: $primary;
|
|
107
|
+
text-style: bold;
|
|
108
|
+
}
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
BINDINGS = [
|
|
112
|
+
Binding("escape", "close", "Close"),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
@on(Button.Pressed, "#btn-close")
|
|
116
|
+
def action_close(self):
|
|
117
|
+
self.dismiss(None)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class DevMenu(ModalScreen):
|
|
121
|
+
def compose(self) -> ComposeResult:
|
|
122
|
+
with Vertical(id="dialog"):
|
|
123
|
+
yield Label("[bold]Dev Menu[/bold]", id="dialog-title")
|
|
124
|
+
yield Label("Test UI components:", id="dialog-subtitle")
|
|
125
|
+
yield Button("ApiKeyDialog", id="btn-apikey")
|
|
126
|
+
yield Button("UpdateDialog", id="btn-update")
|
|
127
|
+
yield Button("Close", id="btn-close", variant="primary")
|
|
128
|
+
|
|
129
|
+
CSS = """
|
|
130
|
+
DevMenu {
|
|
131
|
+
align: center middle;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#dialog {
|
|
135
|
+
width: 50;
|
|
136
|
+
height: auto;
|
|
137
|
+
padding: 2 3;
|
|
138
|
+
background: $surface;
|
|
139
|
+
border: tall $primary;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#dialog-title {
|
|
143
|
+
margin-bottom: 1;
|
|
144
|
+
text-style: bold;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#dialog-subtitle {
|
|
148
|
+
color: $text-muted;
|
|
149
|
+
margin-bottom: 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
Button {
|
|
153
|
+
width: 100%;
|
|
154
|
+
margin-bottom: 1;
|
|
155
|
+
}
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
BINDINGS = [
|
|
159
|
+
Binding("escape", "close", "Close"),
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
@on(Button.Pressed, "#btn-apikey")
|
|
163
|
+
def test_apikey(self):
|
|
164
|
+
self.app.push_screen(ApiKeyDialog("groq", "Groq (test)"))
|
|
165
|
+
|
|
166
|
+
@on(Button.Pressed, "#btn-update")
|
|
167
|
+
def test_update(self):
|
|
168
|
+
self.app.push_screen(UpdateDialog("0.1.0", "99.9.9"))
|
|
169
|
+
|
|
170
|
+
@on(Button.Pressed, "#btn-close")
|
|
171
|
+
def action_close(self):
|
|
172
|
+
self.dismiss(None)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class PermissionDialog(ModalScreen):
|
|
176
|
+
def __init__(self, tool_name: str, args: dict):
|
|
177
|
+
super().__init__()
|
|
178
|
+
self._tool_name = tool_name
|
|
179
|
+
self._args = args
|
|
180
|
+
|
|
181
|
+
def compose(self) -> ComposeResult:
|
|
182
|
+
args_str = str(self._args)
|
|
183
|
+
if len(args_str) > 70:
|
|
184
|
+
args_str = args_str[:67] + "..."
|
|
185
|
+
with Vertical(id="dialog"):
|
|
186
|
+
yield Label("[bold yellow]Permission required[/bold yellow]", id="dialog-title")
|
|
187
|
+
yield Label(f"Tool: [bold]{self._tool_name}[/bold]", id="label-tool")
|
|
188
|
+
yield Label(f"Args: [dim]{args_str}[/dim]", id="label-args")
|
|
189
|
+
yield Label("")
|
|
190
|
+
with Horizontal(id="perm-buttons"):
|
|
191
|
+
yield Button("Allow", id="btn-allow", variant="primary")
|
|
192
|
+
yield Button("Deny", id="btn-deny", variant="error")
|
|
193
|
+
|
|
194
|
+
CSS = """
|
|
195
|
+
PermissionDialog {
|
|
196
|
+
align: center middle;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#dialog {
|
|
200
|
+
width: 60;
|
|
201
|
+
height: auto;
|
|
202
|
+
padding: 2 3;
|
|
203
|
+
background: $surface;
|
|
204
|
+
border: tall $primary;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#dialog-title {
|
|
208
|
+
margin-bottom: 1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#label-tool {
|
|
212
|
+
margin-bottom: 1;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#label-args {
|
|
216
|
+
margin-bottom: 1;
|
|
217
|
+
color: $text-muted;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#perm-buttons {
|
|
221
|
+
height: 3;
|
|
222
|
+
align: center middle;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#perm-buttons Button {
|
|
226
|
+
margin: 0 1;
|
|
227
|
+
width: 20;
|
|
228
|
+
}
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
BINDINGS = [
|
|
232
|
+
Binding("escape", "deny", "Deny"),
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
@on(Button.Pressed, "#btn-allow")
|
|
236
|
+
def action_allow(self):
|
|
237
|
+
self.dismiss(True)
|
|
238
|
+
|
|
239
|
+
@on(Button.Pressed, "#btn-deny")
|
|
240
|
+
def action_deny(self):
|
|
241
|
+
self.dismiss(False)
|