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.

@@ -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()