dulus 0.2.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.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- webchat_server.py +1761 -0
gui/chat_widget.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""Chat display widget for Dulus GUI.
|
|
2
|
+
|
|
3
|
+
Provides a scrollable chat view with message bubbles, markdown-like rendering,
|
|
4
|
+
code blocks with copy buttons, tool execution pills, and a typing indicator.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import tkinter as tk
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import customtkinter as ctk
|
|
15
|
+
except ImportError:
|
|
16
|
+
raise ImportError("customtkinter is required. Install: pip install customtkinter")
|
|
17
|
+
|
|
18
|
+
from gui.themes import get_theme
|
|
19
|
+
|
|
20
|
+
# ── Theme constants (loaded from active theme) ──────────────────────────────
|
|
21
|
+
_t = get_theme()
|
|
22
|
+
BG_COLOR = _t["bg"]
|
|
23
|
+
CARD_COLOR = _t["card"]
|
|
24
|
+
ACCENT_COLOR = _t["accent"]
|
|
25
|
+
ACCENT_HOVER = _t["accent_hover"]
|
|
26
|
+
TEXT_COLOR = _t["text"]
|
|
27
|
+
TEXT_DIM = _t["dim"]
|
|
28
|
+
USER_BUBBLE = _t["user_bubble"]
|
|
29
|
+
ASSISTANT_BUBBLE = _t["assistant_bubble"]
|
|
30
|
+
CODE_BG = _t["code_bg"]
|
|
31
|
+
BORDER_COLOR = _t["border"]
|
|
32
|
+
|
|
33
|
+
# Tag colors (updated by apply_theme)
|
|
34
|
+
TAG_BOLD_COLOR = _t.get("text", "#ffffff")
|
|
35
|
+
TAG_CODE_COLOR = _t.get("dim", "#c9d1d9")
|
|
36
|
+
TAG_ITALIC_COLOR = _t.get("dim", "#bbbbbb")
|
|
37
|
+
|
|
38
|
+
FONT_FAMILY = "Segoe UI"
|
|
39
|
+
FONT_NORMAL = (FONT_FAMILY, 13)
|
|
40
|
+
FONT_BOLD = (FONT_FAMILY, 13, "bold")
|
|
41
|
+
FONT_SMALL = (FONT_FAMILY, 11)
|
|
42
|
+
FONT_CODE = ("Consolas", 12)
|
|
43
|
+
FONT_TIMESTAMP = (FONT_FAMILY, 10)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _sanitize_markdown(text: str) -> str:
|
|
47
|
+
"""Escape HTML-like chars so tkinter Text widget stays safe."""
|
|
48
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ChatWidget(ctk.CTkFrame):
|
|
52
|
+
"""Scrollable chat widget with message bubbles and rich formatting."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
master,
|
|
57
|
+
on_copy_callback: Callable | None = None,
|
|
58
|
+
**kwargs,
|
|
59
|
+
):
|
|
60
|
+
super().__init__(master, fg_color="transparent", **kwargs)
|
|
61
|
+
|
|
62
|
+
self.on_copy = on_copy_callback
|
|
63
|
+
self._message_frames: list[ctk.CTkFrame] = []
|
|
64
|
+
self._current_bubble_text: ctk.CTkTextbox | None = None
|
|
65
|
+
self._current_bubble_is_user: bool = False
|
|
66
|
+
self._current_bubble_outer: ctk.CTkFrame | None = None
|
|
67
|
+
|
|
68
|
+
# Grid layout
|
|
69
|
+
self.grid_columnconfigure(0, weight=1)
|
|
70
|
+
self.grid_rowconfigure(0, weight=1)
|
|
71
|
+
|
|
72
|
+
# Scrollable container
|
|
73
|
+
self._scroll = ctk.CTkScrollableFrame(
|
|
74
|
+
self,
|
|
75
|
+
fg_color="transparent",
|
|
76
|
+
scrollbar_fg_color=BORDER_COLOR,
|
|
77
|
+
scrollbar_button_color=ACCENT_COLOR,
|
|
78
|
+
scrollbar_button_hover_color=ACCENT_HOVER,
|
|
79
|
+
)
|
|
80
|
+
self._scroll.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
|
|
81
|
+
self._scroll.grid_columnconfigure(0, weight=1)
|
|
82
|
+
|
|
83
|
+
# Inner frame where messages live
|
|
84
|
+
self._container = ctk.CTkFrame(self._scroll, fg_color="transparent")
|
|
85
|
+
self._container.pack(fill="both", expand=True)
|
|
86
|
+
self._container.grid_columnconfigure(0, weight=1)
|
|
87
|
+
|
|
88
|
+
# Thinking indicator (hidden by default)
|
|
89
|
+
self._thinking_frame = ctk.CTkFrame(
|
|
90
|
+
self._container, fg_color=ASSISTANT_BUBBLE, corner_radius=12
|
|
91
|
+
)
|
|
92
|
+
self._thinking_label = ctk.CTkLabel(
|
|
93
|
+
self._thinking_frame,
|
|
94
|
+
text="🌀 Dulus ta pensando…",
|
|
95
|
+
font=FONT_NORMAL,
|
|
96
|
+
text_color=ACCENT_COLOR,
|
|
97
|
+
)
|
|
98
|
+
self._thinking_label.pack(padx=16, pady=10)
|
|
99
|
+
|
|
100
|
+
# ── Public API ──────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def add_user_message(self, text: str) -> None:
|
|
103
|
+
"""Add a user message bubble on the right."""
|
|
104
|
+
self._hide_thinking()
|
|
105
|
+
self._finish_current_stream()
|
|
106
|
+
|
|
107
|
+
bubble, txt = self._create_bubble(
|
|
108
|
+
text=text,
|
|
109
|
+
is_user=True,
|
|
110
|
+
timestamp=datetime.now().strftime("%H:%M"),
|
|
111
|
+
)
|
|
112
|
+
bubble.pack(anchor="e", padx=12, pady=(6, 2), fill="x")
|
|
113
|
+
self._message_frames.append(bubble)
|
|
114
|
+
self._scroll_to_bottom()
|
|
115
|
+
|
|
116
|
+
def add_assistant_message(self, text: str) -> None:
|
|
117
|
+
"""Start a new assistant message bubble on the left."""
|
|
118
|
+
self._hide_thinking()
|
|
119
|
+
self._finish_current_stream()
|
|
120
|
+
|
|
121
|
+
bubble, txt = self._create_bubble(
|
|
122
|
+
text=text,
|
|
123
|
+
is_user=False,
|
|
124
|
+
timestamp=datetime.now().strftime("%H:%M"),
|
|
125
|
+
)
|
|
126
|
+
bubble.pack(anchor="w", padx=12, pady=(6, 2), fill="x")
|
|
127
|
+
self._message_frames.append(bubble)
|
|
128
|
+
self._current_bubble_text = txt
|
|
129
|
+
self._current_bubble_is_user = False
|
|
130
|
+
self._current_bubble_outer = bubble
|
|
131
|
+
self._scroll_to_bottom()
|
|
132
|
+
|
|
133
|
+
def append_to_last_message(self, text: str) -> None:
|
|
134
|
+
"""Append text to the current assistant bubble (streaming)."""
|
|
135
|
+
if self._current_bubble_text is None or self._current_bubble_is_user:
|
|
136
|
+
self.add_assistant_message(text)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
widget = self._current_bubble_text
|
|
140
|
+
widget.configure(state="normal")
|
|
141
|
+
current = widget.get("1.0", "end-1c")
|
|
142
|
+
widget.delete("1.0", "end")
|
|
143
|
+
self._render_formatted(widget, current + text)
|
|
144
|
+
self._adjust_text_height(widget)
|
|
145
|
+
widget.configure(state="disabled")
|
|
146
|
+
self._scroll_to_bottom()
|
|
147
|
+
|
|
148
|
+
def add_tool_indicator(self, name: str, status: str = "running") -> None:
|
|
149
|
+
"""Add a small inline pill showing a tool execution."""
|
|
150
|
+
self._hide_thinking()
|
|
151
|
+
|
|
152
|
+
pill = ctk.CTkFrame(
|
|
153
|
+
self._container,
|
|
154
|
+
fg_color=CODE_BG if status == "running" else "#1b3a1b",
|
|
155
|
+
corner_radius=8,
|
|
156
|
+
border_width=1,
|
|
157
|
+
border_color=ACCENT_COLOR if status == "running" else "#4caf50",
|
|
158
|
+
)
|
|
159
|
+
icon = "⚙" if status == "running" else "✓"
|
|
160
|
+
lbl = ctk.CTkLabel(
|
|
161
|
+
pill,
|
|
162
|
+
text=f"{icon} {name}",
|
|
163
|
+
font=FONT_SMALL,
|
|
164
|
+
text_color=ACCENT_COLOR if status == "running" else "#4caf50",
|
|
165
|
+
)
|
|
166
|
+
lbl.pack(padx=10, pady=4)
|
|
167
|
+
|
|
168
|
+
# Stack tools above the current assistant message bubble
|
|
169
|
+
if self._current_bubble_outer is not None:
|
|
170
|
+
pill.pack(
|
|
171
|
+
before=self._current_bubble_outer,
|
|
172
|
+
anchor="w",
|
|
173
|
+
padx=20,
|
|
174
|
+
pady=(2, 4),
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
pill.pack(anchor="w", padx=20, pady=(2, 4))
|
|
178
|
+
|
|
179
|
+
self._message_frames.append(pill)
|
|
180
|
+
self._scroll_to_bottom()
|
|
181
|
+
|
|
182
|
+
def show_thinking(self) -> None:
|
|
183
|
+
"""Show the 'thinking' indicator at the bottom."""
|
|
184
|
+
self._thinking_frame.pack(anchor="w", padx=12, pady=6, fill="x")
|
|
185
|
+
self._scroll_to_bottom()
|
|
186
|
+
|
|
187
|
+
def hide_thinking(self) -> None:
|
|
188
|
+
"""Hide the thinking indicator."""
|
|
189
|
+
self._hide_thinking()
|
|
190
|
+
|
|
191
|
+
def clear_chat(self) -> None:
|
|
192
|
+
"""Remove all messages and reset state."""
|
|
193
|
+
self._finish_current_stream()
|
|
194
|
+
for w in self._message_frames:
|
|
195
|
+
w.destroy()
|
|
196
|
+
self._message_frames.clear()
|
|
197
|
+
self._current_bubble_text = None
|
|
198
|
+
self._current_bubble_is_user = False
|
|
199
|
+
self._hide_thinking()
|
|
200
|
+
|
|
201
|
+
def load_messages(self, messages: list[dict]) -> None:
|
|
202
|
+
"""Bulk load messages into the chat view without repetitive scrolling."""
|
|
203
|
+
self.clear_chat()
|
|
204
|
+
|
|
205
|
+
for m in messages:
|
|
206
|
+
role = m.get("role", "")
|
|
207
|
+
content = m.get("content", "")
|
|
208
|
+
|
|
209
|
+
# Skip system/soul messages and empty ones
|
|
210
|
+
if role == "system" or not content:
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
is_user = (role == "user")
|
|
214
|
+
display_text = content
|
|
215
|
+
if role == "assistant" and m.get("thinking"):
|
|
216
|
+
display_text = f"*[Pensamiento]*\n{m['thinking']}\n\n{content}"
|
|
217
|
+
elif role == "thinking":
|
|
218
|
+
display_text = f"*[Pensamiento]*\n{content}"
|
|
219
|
+
is_user = False
|
|
220
|
+
|
|
221
|
+
bubble, txt = self._create_bubble(
|
|
222
|
+
text=display_text,
|
|
223
|
+
is_user=is_user,
|
|
224
|
+
timestamp=m.get("timestamp", datetime.now().strftime("%H:%M")),
|
|
225
|
+
)
|
|
226
|
+
anchor = "e" if is_user else "w"
|
|
227
|
+
bubble.pack(anchor=anchor, padx=12, pady=(6, 2), fill="x")
|
|
228
|
+
self._message_frames.append(bubble)
|
|
229
|
+
|
|
230
|
+
# Only one scroll at the end (safe access)
|
|
231
|
+
def _final_scroll():
|
|
232
|
+
try:
|
|
233
|
+
canvas = getattr(self._scroll, "_parent_canvas", None)
|
|
234
|
+
if canvas:
|
|
235
|
+
canvas.update_idletasks()
|
|
236
|
+
canvas.yview_moveto(1.0)
|
|
237
|
+
except: pass
|
|
238
|
+
self.after(100, _final_scroll)
|
|
239
|
+
|
|
240
|
+
def apply_theme(self) -> None:
|
|
241
|
+
"""Re-apply current theme colors to existing widgets."""
|
|
242
|
+
t = get_theme()
|
|
243
|
+
global BG_COLOR, CARD_COLOR, ACCENT_COLOR, ACCENT_HOVER, TEXT_COLOR, TEXT_DIM
|
|
244
|
+
global USER_BUBBLE, ASSISTANT_BUBBLE, CODE_BG, BORDER_COLOR
|
|
245
|
+
global TAG_BOLD_COLOR, TAG_CODE_COLOR, TAG_ITALIC_COLOR
|
|
246
|
+
BG_COLOR = t["bg"]
|
|
247
|
+
CARD_COLOR = t["card"]
|
|
248
|
+
ACCENT_COLOR = t["accent"]
|
|
249
|
+
ACCENT_HOVER = t["accent_hover"]
|
|
250
|
+
TEXT_COLOR = t["text"]
|
|
251
|
+
TEXT_DIM = t["dim"]
|
|
252
|
+
USER_BUBBLE = t["user_bubble"]
|
|
253
|
+
ASSISTANT_BUBBLE = t["assistant_bubble"]
|
|
254
|
+
CODE_BG = t["code_bg"]
|
|
255
|
+
BORDER_COLOR = t["border"]
|
|
256
|
+
TAG_BOLD_COLOR = t["text"]
|
|
257
|
+
TAG_CODE_COLOR = t["dim"]
|
|
258
|
+
TAG_ITALIC_COLOR = t["dim"]
|
|
259
|
+
|
|
260
|
+
self.configure(fg_color="transparent")
|
|
261
|
+
self._scroll.configure(
|
|
262
|
+
fg_color=t["bg"],
|
|
263
|
+
scrollbar_fg_color=t["border"],
|
|
264
|
+
scrollbar_button_color=t["accent"],
|
|
265
|
+
scrollbar_button_hover_color=t["accent_hover"],
|
|
266
|
+
)
|
|
267
|
+
self._container.configure(fg_color=t["bg"])
|
|
268
|
+
self._thinking_frame.configure(fg_color=t["assistant_bubble"])
|
|
269
|
+
self._thinking_label.configure(text_color=t["accent"])
|
|
270
|
+
# Recolor existing message bubbles
|
|
271
|
+
for outer in self._message_frames:
|
|
272
|
+
if hasattr(outer, "_bubble"):
|
|
273
|
+
new_fg = t["user_bubble"] if outer._is_user else t["assistant_bubble"]
|
|
274
|
+
outer._bubble.configure(fg_color=new_fg)
|
|
275
|
+
for child in outer._bubble.winfo_children():
|
|
276
|
+
if isinstance(child, ctk.CTkTextbox):
|
|
277
|
+
child.configure(text_color=t["text"])
|
|
278
|
+
elif isinstance(child, ctk.CTkLabel):
|
|
279
|
+
child.configure(text_color=t["dim"])
|
|
280
|
+
|
|
281
|
+
self.update_idletasks()
|
|
282
|
+
|
|
283
|
+
# ── Internal helpers ────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
def _hide_thinking(self) -> None:
|
|
286
|
+
self._thinking_frame.pack_forget()
|
|
287
|
+
|
|
288
|
+
def _finish_current_stream(self) -> None:
|
|
289
|
+
"""Lock the current bubble so future appends start a new one."""
|
|
290
|
+
if self._current_bubble_text is not None:
|
|
291
|
+
self._current_bubble_text = None
|
|
292
|
+
self._current_bubble_is_user = False
|
|
293
|
+
self._current_bubble_outer = None
|
|
294
|
+
|
|
295
|
+
def _scroll_to_bottom(self) -> None:
|
|
296
|
+
"""Auto-scroll to the latest message."""
|
|
297
|
+
def _do_scroll():
|
|
298
|
+
try:
|
|
299
|
+
canvas = getattr(self._scroll, "_parent_canvas", None)
|
|
300
|
+
if canvas:
|
|
301
|
+
canvas.yview_moveto(1.0)
|
|
302
|
+
else:
|
|
303
|
+
# fallback for different customtkinter versions
|
|
304
|
+
self._scroll._scrollbar._command("moveto", 1.0)
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
self.after(50, _do_scroll)
|
|
308
|
+
|
|
309
|
+
def _create_bubble(
|
|
310
|
+
self, text: str, is_user: bool, timestamp: str
|
|
311
|
+
) -> tuple[ctk.CTkFrame, ctk.CTkTextbox]:
|
|
312
|
+
"""Create a message bubble frame with formatted text widget inside."""
|
|
313
|
+
fg = USER_BUBBLE if is_user else ASSISTANT_BUBBLE
|
|
314
|
+
anchor = "e" if is_user else "w"
|
|
315
|
+
|
|
316
|
+
# Outer frame for alignment
|
|
317
|
+
outer = ctk.CTkFrame(self._container, fg_color="transparent")
|
|
318
|
+
outer.grid_columnconfigure(0, weight=1 if not is_user else 0)
|
|
319
|
+
outer.grid_columnconfigure(1, weight=0 if not is_user else 1)
|
|
320
|
+
|
|
321
|
+
# Bubble frame
|
|
322
|
+
bubble = ctk.CTkFrame(outer, fg_color=fg, corner_radius=14)
|
|
323
|
+
outer._bubble = bubble
|
|
324
|
+
outer._is_user = is_user
|
|
325
|
+
if is_user:
|
|
326
|
+
bubble.grid(row=0, column=1, sticky="e", padx=(80, 0))
|
|
327
|
+
else:
|
|
328
|
+
bubble.grid(row=0, column=0, sticky="w", padx=(0, 80))
|
|
329
|
+
|
|
330
|
+
# Timestamp label
|
|
331
|
+
ts_label = ctk.CTkLabel(
|
|
332
|
+
bubble,
|
|
333
|
+
text=timestamp,
|
|
334
|
+
font=FONT_TIMESTAMP,
|
|
335
|
+
text_color=TEXT_DIM,
|
|
336
|
+
)
|
|
337
|
+
ts_label.pack(anchor="nw" if not is_user else "ne", padx=12, pady=(6, 0))
|
|
338
|
+
|
|
339
|
+
# Text widget for formatted content
|
|
340
|
+
txt = ctk.CTkTextbox(
|
|
341
|
+
bubble,
|
|
342
|
+
fg_color="transparent",
|
|
343
|
+
text_color=TEXT_COLOR,
|
|
344
|
+
font=FONT_NORMAL,
|
|
345
|
+
wrap="word",
|
|
346
|
+
state="disabled",
|
|
347
|
+
activate_scrollbars=False,
|
|
348
|
+
height=20,
|
|
349
|
+
width=500, # Initial width
|
|
350
|
+
corner_radius=0,
|
|
351
|
+
)
|
|
352
|
+
txt.pack(fill="both", expand=True, padx=12, pady=(2, 10))
|
|
353
|
+
txt.configure(state="normal")
|
|
354
|
+
self._render_formatted(txt, text)
|
|
355
|
+
txt.configure(state="disabled")
|
|
356
|
+
|
|
357
|
+
# Dynamic height adjustment
|
|
358
|
+
self._adjust_text_height(txt)
|
|
359
|
+
|
|
360
|
+
return outer, txt
|
|
361
|
+
|
|
362
|
+
def _adjust_text_height(self, txt: ctk.CTkTextbox) -> None:
|
|
363
|
+
"""Dynamic height based on content lines."""
|
|
364
|
+
content = txt.get("1.0", "end-1c")
|
|
365
|
+
if not content:
|
|
366
|
+
txt.configure(height=40)
|
|
367
|
+
return
|
|
368
|
+
# Improved line counting: detect actual text lines
|
|
369
|
+
# and factor in wrapping (approx match to bubble width)
|
|
370
|
+
# We increase the chars-per-line to 65 since we made it wider
|
|
371
|
+
wrapped = sum((len(line) // 65) + 1 for line in content.split("\n"))
|
|
372
|
+
# Add a small buffer to prevent scrollbars (26px per line is safer than 24)
|
|
373
|
+
height = max(40, min(1200, wrapped * 26 + 10))
|
|
374
|
+
txt.configure(height=height)
|
|
375
|
+
|
|
376
|
+
def _render_formatted(self, txt: ctk.CTkTextbox, text: str) -> None:
|
|
377
|
+
"""Parse and insert markdown-like formatting into a CTkTextbox.
|
|
378
|
+
|
|
379
|
+
NOTE: CTkTextbox forbids 'font' in tag_config, so we use colors only.
|
|
380
|
+
"""
|
|
381
|
+
txt.delete("1.0", "end")
|
|
382
|
+
try:
|
|
383
|
+
# CTkTextbox does not allow 'font' in tag_config — use foreground only
|
|
384
|
+
txt.tag_config("bold", foreground=TAG_BOLD_COLOR)
|
|
385
|
+
txt.tag_config("code", foreground=TAG_CODE_COLOR)
|
|
386
|
+
txt.tag_config("code_block", foreground=TAG_CODE_COLOR)
|
|
387
|
+
txt.tag_config("italic", foreground=TAG_ITALIC_COLOR)
|
|
388
|
+
except Exception:
|
|
389
|
+
# Fallback: tags unsupported, render plain text
|
|
390
|
+
txt.insert("end", text)
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
# Simple regex-based parsing
|
|
394
|
+
# Process code blocks first (```...```)
|
|
395
|
+
parts = re.split(r"(```(?:[\w]*\n)?[\s\S]*?```)", text)
|
|
396
|
+
|
|
397
|
+
idx = 0
|
|
398
|
+
for part in parts:
|
|
399
|
+
if part.startswith("```") and part.endswith("```"):
|
|
400
|
+
# Extract language and code
|
|
401
|
+
inner = part[3:-3]
|
|
402
|
+
lang = ""
|
|
403
|
+
if "\n" in inner:
|
|
404
|
+
first, rest = inner.split("\n", 1)
|
|
405
|
+
first = first.strip()
|
|
406
|
+
if first and " " not in first:
|
|
407
|
+
lang = first
|
|
408
|
+
inner = rest
|
|
409
|
+
else:
|
|
410
|
+
inner = first + "\n" + rest if rest else first
|
|
411
|
+
self._insert_code_block(txt, inner.strip(), lang)
|
|
412
|
+
else:
|
|
413
|
+
self._insert_inline_formatted(txt, part)
|
|
414
|
+
idx += 1
|
|
415
|
+
|
|
416
|
+
def _insert_code_block(self, txt: ctk.CTkTextbox, code: str, lang: str = "") -> None:
|
|
417
|
+
"""Insert a code block with a dark background and copy button."""
|
|
418
|
+
# Code block frame (we use text widget bg color simulation via tag)
|
|
419
|
+
txt.insert("end", "\n")
|
|
420
|
+
|
|
421
|
+
if lang:
|
|
422
|
+
txt.insert("end", f" {lang}\n", "code_block")
|
|
423
|
+
txt.insert("end", code, "code_block")
|
|
424
|
+
txt.insert("end", "\n")
|
|
425
|
+
|
|
426
|
+
# We can't easily add a real button inside CTkTextbox,
|
|
427
|
+
# so we append a small copy hint at the end of the block
|
|
428
|
+
txt.insert("end", " [📋 copiar]\n", "code_block")
|
|
429
|
+
|
|
430
|
+
def _insert_inline_formatted(self, txt: ctk.CTkTextbox, text: str) -> None:
|
|
431
|
+
"""Process inline bold, italic, and inline code within a text segment."""
|
|
432
|
+
# Pattern order: bold **text**, italic *text*, inline `code`
|
|
433
|
+
pattern = re.compile(r"(\*\*.*?\*\*|\*.*?\*|`.+?`)")
|
|
434
|
+
pos = 0
|
|
435
|
+
for m in pattern.finditer(text):
|
|
436
|
+
# Text before match
|
|
437
|
+
if m.start() > pos:
|
|
438
|
+
txt.insert("end", text[pos:m.start()])
|
|
439
|
+
token = m.group(0)
|
|
440
|
+
if token.startswith("**") and token.endswith("**"):
|
|
441
|
+
txt.insert("end", token[2:-2], "bold")
|
|
442
|
+
elif token.startswith("*") and token.endswith("*"):
|
|
443
|
+
txt.insert("end", token[1:-1], "italic")
|
|
444
|
+
elif token.startswith("`") and token.endswith("`"):
|
|
445
|
+
txt.insert("end", token[1:-1], "code")
|
|
446
|
+
pos = m.end()
|
|
447
|
+
if pos < len(text):
|
|
448
|
+
txt.insert("end", text[pos:])
|