devduck 0.4.0__py3-none-any.whl → 0.5.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.
Potentially problematic release.
This version of devduck might be problematic. Click here for more details.
- devduck/__init__.py +64 -179
- devduck/_version.py +2 -2
- devduck/tools/__init__.py +3 -1
- devduck/tools/_ambient_input.py +423 -0
- devduck/tools/_tray_app.py +522 -0
- devduck/tools/ambient.py +157 -0
- devduck/tools/ipc.py +543 -0
- devduck/tools/system_prompt.py +485 -0
- devduck/tools/tcp.py +0 -4
- devduck/tools/tray.py +246 -0
- devduck-0.5.0.dist-info/METADATA +554 -0
- devduck-0.5.0.dist-info/RECORD +24 -0
- {devduck-0.4.0.dist-info → devduck-0.5.0.dist-info}/entry_points.txt +1 -0
- devduck-0.5.0.dist-info/licenses/LICENSE +201 -0
- devduck-0.4.0.dist-info/METADATA +0 -260
- devduck-0.4.0.dist-info/RECORD +0 -18
- devduck-0.4.0.dist-info/licenses/LICENSE +0 -21
- {devduck-0.4.0.dist-info → devduck-0.5.0.dist-info}/WHEEL +0 -0
- {devduck-0.4.0.dist-info → devduck-0.5.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""
|
|
2
|
+
🦆 DevDuck Ambient Input - Preserves scroll position
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import tkinter as tk
|
|
6
|
+
from tkinter import font as tkfont, scrolledtext
|
|
7
|
+
import threading
|
|
8
|
+
import json
|
|
9
|
+
import tempfile
|
|
10
|
+
import queue
|
|
11
|
+
import socket as unix_socket
|
|
12
|
+
import os
|
|
13
|
+
import uuid
|
|
14
|
+
import subprocess
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IPCClient:
|
|
18
|
+
"""IPC client for connecting to devduck main server"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, socket_path="/tmp/devduck_main.sock"):
|
|
21
|
+
self.socket_path = socket_path
|
|
22
|
+
self.sock = None
|
|
23
|
+
self.connected = False
|
|
24
|
+
self.message_queue = queue.Queue()
|
|
25
|
+
|
|
26
|
+
def connect(self):
|
|
27
|
+
try:
|
|
28
|
+
self.sock = unix_socket.socket(unix_socket.AF_UNIX, unix_socket.SOCK_STREAM)
|
|
29
|
+
self.sock.connect(self.socket_path)
|
|
30
|
+
self.connected = True
|
|
31
|
+
threading.Thread(target=self._listen, daemon=True).start()
|
|
32
|
+
return True
|
|
33
|
+
except Exception as e:
|
|
34
|
+
print(f"Failed to connect: {e}")
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
def send_message(self, message, turn_id=None):
|
|
38
|
+
if not self.connected:
|
|
39
|
+
print("Not connected!")
|
|
40
|
+
return False
|
|
41
|
+
try:
|
|
42
|
+
data = {"message": message, "turn_id": turn_id or str(uuid.uuid4())}
|
|
43
|
+
msg = json.dumps(data).encode() + b"\n"
|
|
44
|
+
self.sock.sendall(msg)
|
|
45
|
+
print(f"✓ Sent: {message[:50]} [turn: {data['turn_id'][:8]}]")
|
|
46
|
+
return True
|
|
47
|
+
except Exception as e:
|
|
48
|
+
print(f"Send failed: {e}")
|
|
49
|
+
self.connected = False
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
def _listen(self):
|
|
53
|
+
buffer = b""
|
|
54
|
+
while self.connected:
|
|
55
|
+
try:
|
|
56
|
+
chunk = self.sock.recv(4096)
|
|
57
|
+
if not chunk:
|
|
58
|
+
self.connected = False
|
|
59
|
+
break
|
|
60
|
+
buffer += chunk
|
|
61
|
+
while b"\n" in buffer:
|
|
62
|
+
line, buffer = buffer.split(b"\n", 1)
|
|
63
|
+
try:
|
|
64
|
+
msg = json.loads(line.decode())
|
|
65
|
+
self.message_queue.put(msg)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
continue
|
|
68
|
+
except Exception as e:
|
|
69
|
+
print(f"Listen error: {e}")
|
|
70
|
+
self.connected = False
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
def disconnect(self):
|
|
74
|
+
self.connected = False
|
|
75
|
+
if self.sock:
|
|
76
|
+
try:
|
|
77
|
+
self.sock.close()
|
|
78
|
+
except:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MinimalAmbientInput:
|
|
83
|
+
def __init__(self):
|
|
84
|
+
self.root = tk.Tk()
|
|
85
|
+
self.root.title("🦆 devduck")
|
|
86
|
+
|
|
87
|
+
self.root.attributes("-topmost", True)
|
|
88
|
+
self.root.attributes("-alpha", 0.85)
|
|
89
|
+
|
|
90
|
+
screen_width = self.root.winfo_screenwidth()
|
|
91
|
+
screen_height = self.root.winfo_screenheight()
|
|
92
|
+
|
|
93
|
+
# Better size - bigger and more usable
|
|
94
|
+
window_width = 900
|
|
95
|
+
window_height = 650
|
|
96
|
+
|
|
97
|
+
# CENTER on screen
|
|
98
|
+
x = (screen_width - window_width) // 2
|
|
99
|
+
y = (screen_height - window_height) // 2
|
|
100
|
+
|
|
101
|
+
self.root.geometry(f"{window_width}x{window_height}+{x}+{y}")
|
|
102
|
+
self.root.configure(bg="#00ff88")
|
|
103
|
+
|
|
104
|
+
# Inner frame
|
|
105
|
+
inner_frame = tk.Frame(self.root, bg="#000000")
|
|
106
|
+
inner_frame.pack(fill=tk.BOTH, expand=True, padx=3, pady=3)
|
|
107
|
+
|
|
108
|
+
# Output area - NORMAL state for copying, but read-only via bindings
|
|
109
|
+
self.text = scrolledtext.ScrolledText(
|
|
110
|
+
inner_frame,
|
|
111
|
+
font=tkfont.Font(family="-apple-system", size=14),
|
|
112
|
+
bg="#000000",
|
|
113
|
+
fg="#ffffff",
|
|
114
|
+
insertbackground="#00ff88",
|
|
115
|
+
bd=0,
|
|
116
|
+
highlightthickness=0,
|
|
117
|
+
wrap=tk.WORD,
|
|
118
|
+
padx=10,
|
|
119
|
+
pady=10,
|
|
120
|
+
state="normal", # Keep normal for selection/copying
|
|
121
|
+
height=20,
|
|
122
|
+
)
|
|
123
|
+
self.text.pack(fill=tk.BOTH, expand=True)
|
|
124
|
+
|
|
125
|
+
# Make text read-only by blocking all modification events
|
|
126
|
+
self.text.bind("<Key>", lambda e: "break") # Block keyboard input
|
|
127
|
+
self.text.bind(
|
|
128
|
+
"<Button-2>", lambda e: "break"
|
|
129
|
+
) # Block middle-click paste (Linux)
|
|
130
|
+
self.text.bind("<Button-3>", lambda e: None) # Allow right-click
|
|
131
|
+
|
|
132
|
+
# Input frame at bottom - bigger and more prominent
|
|
133
|
+
input_frame = tk.Frame(inner_frame, bg="#000000", height=90)
|
|
134
|
+
input_frame.pack(fill=tk.X, side=tk.BOTTOM, pady=(10, 0))
|
|
135
|
+
input_frame.pack_propagate(False)
|
|
136
|
+
|
|
137
|
+
# Container for prompt + input
|
|
138
|
+
input_container = tk.Frame(input_frame, bg="#000000")
|
|
139
|
+
input_container.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
|
|
140
|
+
|
|
141
|
+
# Green prompt label - bigger
|
|
142
|
+
prompt_label = tk.Label(
|
|
143
|
+
input_container,
|
|
144
|
+
text=">",
|
|
145
|
+
font=tkfont.Font(family="-apple-system", size=20, weight="bold"),
|
|
146
|
+
fg="#00ff88",
|
|
147
|
+
bg="#000000",
|
|
148
|
+
)
|
|
149
|
+
prompt_label.pack(side=tk.LEFT, padx=(0, 10))
|
|
150
|
+
|
|
151
|
+
# Input entry - bigger, more visible
|
|
152
|
+
self.input_entry = tk.Entry(
|
|
153
|
+
input_container,
|
|
154
|
+
font=tkfont.Font(family="-apple-system", size=18),
|
|
155
|
+
bg="#1a1a1a",
|
|
156
|
+
fg="#ffffff",
|
|
157
|
+
insertbackground="#00ff88",
|
|
158
|
+
bd=0,
|
|
159
|
+
highlightthickness=2,
|
|
160
|
+
highlightbackground="#00ff88",
|
|
161
|
+
highlightcolor="#00ff88",
|
|
162
|
+
)
|
|
163
|
+
self.input_entry.pack(fill=tk.BOTH, expand=True, side=tk.LEFT, ipady=10)
|
|
164
|
+
|
|
165
|
+
# Tags for output - Green user text, no ugly background
|
|
166
|
+
self.text.tag_config(
|
|
167
|
+
"user",
|
|
168
|
+
foreground="#00ff88",
|
|
169
|
+
background="#000000",
|
|
170
|
+
font=tkfont.Font(family="-apple-system", size=14, weight="bold"),
|
|
171
|
+
spacing1=5,
|
|
172
|
+
spacing3=5,
|
|
173
|
+
lmargin1=0,
|
|
174
|
+
lmargin2=0,
|
|
175
|
+
rmargin=0,
|
|
176
|
+
)
|
|
177
|
+
self.text.tag_config(
|
|
178
|
+
"assistant",
|
|
179
|
+
foreground="#ffffff",
|
|
180
|
+
background="#000000",
|
|
181
|
+
spacing1=0,
|
|
182
|
+
spacing3=0,
|
|
183
|
+
lmargin1=0,
|
|
184
|
+
lmargin2=0,
|
|
185
|
+
rmargin=0,
|
|
186
|
+
)
|
|
187
|
+
self.text.tag_config(
|
|
188
|
+
"tool",
|
|
189
|
+
foreground="#00ff88",
|
|
190
|
+
font=tkfont.Font(family="-apple-system", size=12),
|
|
191
|
+
)
|
|
192
|
+
self.text.tag_config("error", foreground="#ef4444")
|
|
193
|
+
|
|
194
|
+
# Bindings
|
|
195
|
+
self.input_entry.bind("<Return>", self.on_enter)
|
|
196
|
+
self.input_entry.bind("<Escape>", lambda e: self.root.withdraw())
|
|
197
|
+
|
|
198
|
+
# State - Turn tracking
|
|
199
|
+
self.ipc_client = IPCClient()
|
|
200
|
+
self.turns = {}
|
|
201
|
+
self.turn_order = []
|
|
202
|
+
|
|
203
|
+
# Command listener
|
|
204
|
+
self.command_socket_path = os.path.join(
|
|
205
|
+
tempfile.gettempdir(), "devduck_ambient.sock"
|
|
206
|
+
)
|
|
207
|
+
self._start_command_listener()
|
|
208
|
+
|
|
209
|
+
# Start
|
|
210
|
+
self.root.after(50, self.process_messages)
|
|
211
|
+
self.root.after(1000, self._try_connect)
|
|
212
|
+
|
|
213
|
+
# Force layout update before showing
|
|
214
|
+
self.root.update_idletasks()
|
|
215
|
+
|
|
216
|
+
self.root.deiconify()
|
|
217
|
+
self._force_focus()
|
|
218
|
+
|
|
219
|
+
def _try_connect(self):
|
|
220
|
+
if self.ipc_client.connect():
|
|
221
|
+
print("✓ Connected to IPC server")
|
|
222
|
+
self._append_text("✓ Connected to DevDuck\n\n", "tool")
|
|
223
|
+
else:
|
|
224
|
+
print("✗ Connection failed, retrying...")
|
|
225
|
+
self.root.after(5000, self._try_connect)
|
|
226
|
+
|
|
227
|
+
def _append_text(self, text, tag=None):
|
|
228
|
+
"""Append text - NO auto-scroll, but stays in normal state"""
|
|
229
|
+
if tag:
|
|
230
|
+
self.text.insert(tk.END, text, tag)
|
|
231
|
+
else:
|
|
232
|
+
self.text.insert(tk.END, text)
|
|
233
|
+
# NO self.text.see(tk.END) - user controls scroll
|
|
234
|
+
|
|
235
|
+
def _render_all_turns(self):
|
|
236
|
+
"""Render all turns - PRESERVES scroll position"""
|
|
237
|
+
# Save current scroll position BEFORE deleting
|
|
238
|
+
try:
|
|
239
|
+
yview = self.text.yview()
|
|
240
|
+
scroll_position = yview[0] # Top of visible area
|
|
241
|
+
except:
|
|
242
|
+
scroll_position = 0.0
|
|
243
|
+
|
|
244
|
+
# Delete and re-render
|
|
245
|
+
self.text.delete("1.0", tk.END)
|
|
246
|
+
|
|
247
|
+
for turn_id in self.turn_order:
|
|
248
|
+
if turn_id in self.turns:
|
|
249
|
+
turn = self.turns[turn_id]
|
|
250
|
+
|
|
251
|
+
# User message with "> " prefix
|
|
252
|
+
query = turn.get("query", "")
|
|
253
|
+
if query:
|
|
254
|
+
self.text.insert(tk.END, f"> {query}\n\n", "user")
|
|
255
|
+
|
|
256
|
+
# Assistant response
|
|
257
|
+
buffer = turn.get("buffer", "")
|
|
258
|
+
if buffer:
|
|
259
|
+
self.text.insert(tk.END, buffer, "assistant")
|
|
260
|
+
|
|
261
|
+
# Add spacing between turns
|
|
262
|
+
self.text.insert(tk.END, "\n")
|
|
263
|
+
|
|
264
|
+
# Restore scroll position AFTER re-rendering
|
|
265
|
+
try:
|
|
266
|
+
self.text.yview_moveto(scroll_position)
|
|
267
|
+
except:
|
|
268
|
+
pass
|
|
269
|
+
|
|
270
|
+
def _start_command_listener(self):
|
|
271
|
+
def server_thread():
|
|
272
|
+
if os.path.exists(self.command_socket_path):
|
|
273
|
+
os.unlink(self.command_socket_path)
|
|
274
|
+
|
|
275
|
+
server = unix_socket.socket(unix_socket.AF_UNIX, unix_socket.SOCK_STREAM)
|
|
276
|
+
server.bind(self.command_socket_path)
|
|
277
|
+
server.listen(1)
|
|
278
|
+
|
|
279
|
+
while True:
|
|
280
|
+
try:
|
|
281
|
+
conn, _ = server.accept()
|
|
282
|
+
data = conn.recv(4096)
|
|
283
|
+
if data:
|
|
284
|
+
cmd = json.loads(data.decode("utf-8"))
|
|
285
|
+
action = cmd.get("action")
|
|
286
|
+
if action == "show":
|
|
287
|
+
self.root.after(0, self._force_focus)
|
|
288
|
+
elif action == "hide":
|
|
289
|
+
self.root.after(0, self.root.withdraw)
|
|
290
|
+
elif action == "set_text":
|
|
291
|
+
text = cmd.get("text", "")
|
|
292
|
+
self.root.after(0, lambda: self._set_input_text(text))
|
|
293
|
+
conn.send(json.dumps({"status": "success"}).encode("utf-8"))
|
|
294
|
+
conn.close()
|
|
295
|
+
except Exception as e:
|
|
296
|
+
print(f"Command error: {e}")
|
|
297
|
+
|
|
298
|
+
threading.Thread(target=server_thread, daemon=True).start()
|
|
299
|
+
|
|
300
|
+
def _set_input_text(self, text):
|
|
301
|
+
"""Set input text"""
|
|
302
|
+
self.input_entry.delete(0, tk.END)
|
|
303
|
+
self.input_entry.insert(0, text)
|
|
304
|
+
|
|
305
|
+
def _force_focus(self):
|
|
306
|
+
self.root.deiconify()
|
|
307
|
+
self.root.lift()
|
|
308
|
+
self.root.attributes("-topmost", True)
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
subprocess.run(
|
|
312
|
+
[
|
|
313
|
+
"osascript",
|
|
314
|
+
"-e",
|
|
315
|
+
f'tell application "System Events" to set frontmost of the first process whose unix id is {os.getpid()} to true',
|
|
316
|
+
],
|
|
317
|
+
check=False,
|
|
318
|
+
capture_output=True,
|
|
319
|
+
timeout=1,
|
|
320
|
+
)
|
|
321
|
+
except:
|
|
322
|
+
pass
|
|
323
|
+
|
|
324
|
+
self.root.focus_force()
|
|
325
|
+
self.input_entry.focus_set()
|
|
326
|
+
|
|
327
|
+
def process_messages(self):
|
|
328
|
+
"""Process messages with turn-based buffering"""
|
|
329
|
+
needs_render = False
|
|
330
|
+
|
|
331
|
+
while not self.ipc_client.message_queue.empty():
|
|
332
|
+
msg = self.ipc_client.message_queue.get()
|
|
333
|
+
msg_type = msg.get("type")
|
|
334
|
+
turn_id = msg.get("turn_id")
|
|
335
|
+
|
|
336
|
+
if msg_type == "turn_start":
|
|
337
|
+
if turn_id not in self.turns:
|
|
338
|
+
self.turns[turn_id] = {
|
|
339
|
+
"query": msg.get("data", ""),
|
|
340
|
+
"buffer": "",
|
|
341
|
+
"tools": [],
|
|
342
|
+
}
|
|
343
|
+
self.turn_order.append(turn_id)
|
|
344
|
+
needs_render = True
|
|
345
|
+
|
|
346
|
+
elif msg_type == "chunk":
|
|
347
|
+
if turn_id in self.turns:
|
|
348
|
+
chunk = msg.get("data", "")
|
|
349
|
+
self.turns[turn_id]["buffer"] += chunk
|
|
350
|
+
needs_render = True
|
|
351
|
+
|
|
352
|
+
elif msg_type == "tool_start":
|
|
353
|
+
if turn_id in self.turns:
|
|
354
|
+
tool_name = msg.get("data", "")
|
|
355
|
+
tool_num = msg.get("tool_number", 0)
|
|
356
|
+
tool_text = f"\n🛠️ #{tool_num}: {tool_name} "
|
|
357
|
+
self.turns[turn_id]["buffer"] += tool_text
|
|
358
|
+
needs_render = True
|
|
359
|
+
|
|
360
|
+
elif msg_type == "tool_end":
|
|
361
|
+
if turn_id in self.turns:
|
|
362
|
+
success = msg.get("success", False)
|
|
363
|
+
icon = "✅" if success else "❌"
|
|
364
|
+
self.turns[turn_id]["buffer"] += f"{icon}\n"
|
|
365
|
+
needs_render = True
|
|
366
|
+
|
|
367
|
+
elif msg_type == "turn_end":
|
|
368
|
+
if turn_id in self.turns:
|
|
369
|
+
self.turns[turn_id]["buffer"] += "\n"
|
|
370
|
+
needs_render = True
|
|
371
|
+
|
|
372
|
+
elif msg_type == "error":
|
|
373
|
+
error_msg = msg.get("data", "Unknown error")
|
|
374
|
+
if turn_id and turn_id in self.turns:
|
|
375
|
+
self.turns[turn_id]["buffer"] += f"\n❌ Error: {error_msg}\n"
|
|
376
|
+
needs_render = True
|
|
377
|
+
|
|
378
|
+
if needs_render:
|
|
379
|
+
self._render_all_turns()
|
|
380
|
+
|
|
381
|
+
self.root.after(50, self.process_messages)
|
|
382
|
+
|
|
383
|
+
def on_enter(self, event):
|
|
384
|
+
"""Handle Enter key in input field"""
|
|
385
|
+
query = self.input_entry.get().strip()
|
|
386
|
+
|
|
387
|
+
if not query:
|
|
388
|
+
return "break"
|
|
389
|
+
|
|
390
|
+
if not self.ipc_client.connected:
|
|
391
|
+
self._append_text("\n❌ Not connected\n", "error")
|
|
392
|
+
return "break"
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
turn_id = str(uuid.uuid4())
|
|
396
|
+
self.input_entry.delete(0, tk.END)
|
|
397
|
+
|
|
398
|
+
if not self.ipc_client.send_message(query, turn_id=turn_id):
|
|
399
|
+
self._append_text("❌ Send failed\n", "error")
|
|
400
|
+
|
|
401
|
+
except Exception as e:
|
|
402
|
+
print(f"❌ Error: {e}")
|
|
403
|
+
import traceback
|
|
404
|
+
|
|
405
|
+
traceback.print_exc()
|
|
406
|
+
|
|
407
|
+
return "break"
|
|
408
|
+
|
|
409
|
+
def run(self):
|
|
410
|
+
self.root.mainloop()
|
|
411
|
+
|
|
412
|
+
def cleanup(self):
|
|
413
|
+
self.ipc_client.disconnect()
|
|
414
|
+
if os.path.exists(self.command_socket_path):
|
|
415
|
+
os.unlink(self.command_socket_path)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
if __name__ == "__main__":
|
|
419
|
+
app = MinimalAmbientInput()
|
|
420
|
+
try:
|
|
421
|
+
app.run()
|
|
422
|
+
finally:
|
|
423
|
+
app.cleanup()
|