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.
Files changed (101) hide show
  1. agent.py +363 -0
  2. backend/__init__.py +63 -0
  3. backend/compressor.py +261 -0
  4. backend/context.py +329 -0
  5. backend/githook.py +166 -0
  6. backend/marketplace.py +141 -0
  7. backend/mempalace_bridge.py +182 -0
  8. backend/personas.py +297 -0
  9. backend/plugins.py +222 -0
  10. backend/server.py +411 -0
  11. backend/tasks.py +213 -0
  12. batch_api.py +307 -0
  13. checkpoint/__init__.py +27 -0
  14. checkpoint/hooks.py +90 -0
  15. checkpoint/store.py +314 -0
  16. checkpoint/types.py +80 -0
  17. claude_code_watcher.py +214 -0
  18. clipboard_utils.py +246 -0
  19. cloudsave.py +159 -0
  20. common.py +177 -0
  21. compaction.py +378 -0
  22. config.py +180 -0
  23. context.py +241 -0
  24. dulus-0.2.0.dist-info/METADATA +600 -0
  25. dulus-0.2.0.dist-info/RECORD +101 -0
  26. dulus-0.2.0.dist-info/WHEEL +5 -0
  27. dulus-0.2.0.dist-info/entry_points.txt +2 -0
  28. dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
  29. dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
  30. dulus-0.2.0.dist-info/top_level.txt +36 -0
  31. dulus.py +8455 -0
  32. dulus_gui.py +331 -0
  33. dulus_mcp/__init__.py +43 -0
  34. dulus_mcp/client.py +546 -0
  35. dulus_mcp/config.py +133 -0
  36. dulus_mcp/tools.py +131 -0
  37. dulus_mcp/types.py +124 -0
  38. gui/__init__.py +18 -0
  39. gui/agent_bridge.py +283 -0
  40. gui/chat_widget.py +448 -0
  41. gui/main_window.py +485 -0
  42. gui/personas.py +230 -0
  43. gui/session_utils.py +189 -0
  44. gui/settings_dialog.py +146 -0
  45. gui/sidebar.py +515 -0
  46. gui/tasks_view.py +499 -0
  47. gui/themes.py +256 -0
  48. gui/tool_panel.py +94 -0
  49. input.py +1030 -0
  50. license_manager.py +187 -0
  51. memory/__init__.py +93 -0
  52. memory/audit.py +51 -0
  53. memory/consolidator.py +312 -0
  54. memory/context.py +270 -0
  55. memory/offload.py +148 -0
  56. memory/palace.py +127 -0
  57. memory/scan.py +146 -0
  58. memory/sessions.py +100 -0
  59. memory/store.py +395 -0
  60. memory/tools.py +408 -0
  61. memory/types.py +114 -0
  62. memory/vector_search.py +92 -0
  63. multi_agent/__init__.py +23 -0
  64. multi_agent/subagent.py +501 -0
  65. multi_agent/tools.py +393 -0
  66. offload_helper.py +183 -0
  67. plugin/__init__.py +22 -0
  68. plugin/autoadapter.py +1641 -0
  69. plugin/loader.py +156 -0
  70. plugin/recommend.py +211 -0
  71. plugin/store.py +387 -0
  72. plugin/types.py +147 -0
  73. providers.py +3750 -0
  74. skill/__init__.py +14 -0
  75. skill/builtin.py +100 -0
  76. skill/clawhub.py +270 -0
  77. skill/executor.py +66 -0
  78. skill/loader.py +199 -0
  79. skill/tools.py +110 -0
  80. skills.py +14 -0
  81. spinner.py +42 -0
  82. string_utils.py +42 -0
  83. subagent.py +11 -0
  84. task/__init__.py +12 -0
  85. task/store.py +199 -0
  86. task/tools.py +265 -0
  87. task/types.py +92 -0
  88. tmux_offloader.py +177 -0
  89. tmux_tools.py +410 -0
  90. tool_registry.py +214 -0
  91. tools.py +2694 -0
  92. ui/__init__.py +1 -0
  93. ui/input.py +464 -0
  94. ui/render.py +272 -0
  95. voice/__init__.py +56 -0
  96. voice/keyterms.py +179 -0
  97. voice/recorder.py +263 -0
  98. voice/stt.py +408 -0
  99. voice/tts.py +570 -0
  100. webchat.py +432 -0
  101. webchat_server.py +1761 -0
gui/personas.py ADDED
@@ -0,0 +1,230 @@
1
+ """Persona system for Dulus GUI.
2
+
3
+ Loads the canonical persona definitions from .dulus-context/personas.json
4
+ and provides helpers for retrieving persona data and rendering cards in
5
+ customtkinter interfaces.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ # ── Paths ───────────────────────────────────────────────────────────────────
15
+
16
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
17
+ _DEFAULT_JSON_PATH = _REPO_ROOT / ".dulus-context" / "personas.json"
18
+
19
+
20
+ # ── Cache ───────────────────────────────────────────────────────────────────
21
+
22
+ _persona_data: dict[str, Any] | None = None
23
+
24
+
25
+ def _load_json(path: Path | str | None = None) -> dict[str, Any]:
26
+ """Load and cache personas.json. Raises FileNotFoundError if missing."""
27
+ global _persona_data
28
+ if _persona_data is not None:
29
+ return _persona_data
30
+
31
+ target = Path(path) if path else _DEFAULT_JSON_PATH
32
+ with target.open("r", encoding="utf-8") as fh:
33
+ _persona_data = json.load(fh)
34
+ return _persona_data
35
+
36
+
37
+ def reload() -> dict[str, Any]:
38
+ """Force reload personas.json from disk and return the raw data."""
39
+ global _persona_data
40
+ _persona_data = None
41
+ return _load_json()
42
+
43
+
44
+ # ── Core API ────────────────────────────────────────────────────────────────
45
+
46
+ def get_all_personas(path: Path | str | None = None) -> list[dict[str, Any]]:
47
+ """Return all persona definitions as a list of dicts."""
48
+ data = _load_json(path)
49
+ return list(data.get("personas", []))
50
+
51
+
52
+ def get_persona(persona_id: str, path: Path | str | None = None) -> dict[str, Any] | None:
53
+ """Return a single persona by its ``id`` (e.g. ``'kevrojo'``)."""
54
+ for p in get_all_personas(path):
55
+ if p.get("id") == persona_id:
56
+ return p
57
+ return None
58
+
59
+
60
+ def get_color_for_agent(agent_name: str, path: Path | str | None = None) -> str:
61
+ """Return the hex color for an agent name/id (case-insensitive).
62
+
63
+ Falls back to the default Dulus accent ``#ff6b1f`` if unknown.
64
+ """
65
+ lookup = agent_name.lower().strip()
66
+ for p in get_all_personas(path):
67
+ if p.get("id", "").lower() == lookup or p.get("name", "").lower() == lookup:
68
+ return p.get("color", "#ff6b1f")
69
+ return "#ff6b1f"
70
+
71
+
72
+ def get_display_name(agent_name: str, path: Path | str | None = None) -> str:
73
+ """Return the pretty display name for an agent, or the raw name as fallback."""
74
+ lookup = agent_name.lower().strip()
75
+ for p in get_all_personas(path):
76
+ if p.get("id", "").lower() == lookup or p.get("name", "").lower() == lookup:
77
+ return p.get("display_name") or p.get("name") or agent_name
78
+ return agent_name
79
+
80
+
81
+ # ── customtkinter Widget (optional) ─────────────────────────────────────────
82
+
83
+ try:
84
+ import customtkinter as ctk
85
+ _HAS_CTK = True
86
+ except Exception: # pragma: no cover
87
+ _HAS_CTK = False
88
+
89
+
90
+ class PersonaCard(ctk.CTkFrame if _HAS_CTK else object): # type: ignore[misc]
91
+ """A small card widget that displays a single persona's identity.
92
+
93
+ Usage::
94
+
95
+ card = PersonaCard(parent, persona=get_persona("kimi-code"))
96
+ card.pack(padx=10, pady=10, fill="both", expand=True)
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ master: Any,
102
+ persona: dict[str, Any],
103
+ width: int = 340,
104
+ height: int = 280,
105
+ **kwargs: Any,
106
+ ) -> None:
107
+ if not _HAS_CTK:
108
+ raise RuntimeError("customtkinter is required to use PersonaCard")
109
+
110
+ self._persona = persona
111
+ self._color = persona.get("color", "#ff6b1f")
112
+ self._accent = persona.get("accent_color", self._color)
113
+
114
+ super().__init__(
115
+ master,
116
+ width=width,
117
+ height=height,
118
+ corner_radius=8,
119
+ fg_color=("#f9f9f9", "#15151a"),
120
+ border_width=1,
121
+ border_color=self._color,
122
+ **kwargs,
123
+ )
124
+
125
+ self._build()
126
+
127
+ def _build(self) -> None:
128
+ # Top accent bar
129
+ self._top = ctk.CTkFrame(self, height=3, fg_color=self._color)
130
+ self._top.pack(fill="x", padx=0, pady=0)
131
+
132
+ # Header row: ASCII avatar + meta
133
+ self._header = ctk.CTkFrame(self, fg_color="transparent")
134
+ self._header.pack(fill="x", padx=12, pady=(12, 8))
135
+
136
+ # Avatar label (monospace)
137
+ avatar_text = self._persona.get("avatar_ascii", "?")
138
+ self._avatar = ctk.CTkLabel(
139
+ self._header,
140
+ text=avatar_text,
141
+ font=ctk.CTkFont(family="Consolas", size=9),
142
+ text_color=self._color,
143
+ width=120,
144
+ height=110,
145
+ fg_color=("#eeeeee", "#0f0f12"),
146
+ corner_radius=6,
147
+ )
148
+ self._avatar.pack(side="left", padx=(0, 12))
149
+
150
+ # Meta column
151
+ self._meta = ctk.CTkFrame(self._header, fg_color="transparent")
152
+ self._meta.pack(side="left", fill="both", expand=True)
153
+
154
+ display = self._persona.get("display_name", self._persona.get("name", "???"))
155
+ self._name_lbl = ctk.CTkLabel(
156
+ self._meta,
157
+ text=display,
158
+ font=ctk.CTkFont(family="JetBrains Mono", size=16, weight="bold"),
159
+ text_color=self._color,
160
+ anchor="w",
161
+ )
162
+ self._name_lbl.pack(fill="x")
163
+
164
+ role = self._persona.get("role", "Agent")
165
+ self._role_lbl = ctk.CTkLabel(
166
+ self._meta,
167
+ text=role,
168
+ font=ctk.CTkFont(family="JetBrains Mono", size=10),
169
+ text_color=self._accent,
170
+ anchor="w",
171
+ )
172
+ self._role_lbl.pack(fill="x", pady=(2, 6))
173
+
174
+ ptype = self._persona.get("type", "unknown")
175
+ self._type_lbl = ctk.CTkLabel(
176
+ self._meta,
177
+ text=f"● {ptype}",
178
+ font=ctk.CTkFont(family="JetBrains Mono", size=10),
179
+ text_color=("#888888", "#6a6470"),
180
+ anchor="w",
181
+ )
182
+ self._type_lbl.pack(fill="x")
183
+
184
+ # Catchphrase
185
+ catch = self._persona.get("catchphrase", "")
186
+ if catch:
187
+ self._catch = ctk.CTkLabel(
188
+ self,
189
+ text=f'"{catch}"',
190
+ font=ctk.CTkFont(family="JetBrains Mono", size=11, slant="italic"),
191
+ text_color=("#555555", "#8a8490"),
192
+ anchor="w",
193
+ wraplength=300,
194
+ )
195
+ self._catch.pack(fill="x", padx=12, pady=(0, 8))
196
+
197
+ # Skills tags
198
+ skills = self._persona.get("skills", [])
199
+ if skills:
200
+ self._skills_frame = ctk.CTkFrame(self, fg_color="transparent")
201
+ self._skills_frame.pack(fill="x", padx=12, pady=(0, 8))
202
+ for skill in skills[:5]:
203
+ tag = ctk.CTkLabel(
204
+ self._skills_frame,
205
+ text=skill,
206
+ font=ctk.CTkFont(family="JetBrains Mono", size=9),
207
+ text_color=self._color,
208
+ fg_color=("#eeeeee", "#0a0a0a"),
209
+ corner_radius=4,
210
+ padx=6,
211
+ pady=2,
212
+ )
213
+ tag.pack(side="left", padx=(0, 4), pady=(0, 4))
214
+
215
+
216
+ # ── Quick smoke-test ────────────────────────────────────────────────────────
217
+ if __name__ == "__main__":
218
+ import pprint
219
+
220
+ print("=== All personas ===")
221
+ pprint.pprint(get_all_personas())
222
+
223
+ print("\n=== Get persona 'kevrojo' ===")
224
+ pprint.pprint(get_persona("kevrojo"))
225
+
226
+ print("\n=== Color for 'kimi-code2' ===")
227
+ print(get_color_for_agent("kimi-code2"))
228
+
229
+ print("\n=== Display name for 'dulus' ===")
230
+ print(get_display_name("dulus"))
gui/session_utils.py ADDED
@@ -0,0 +1,189 @@
1
+ """Utility functions for managing Dulus GUI sessions."""
2
+ import json
3
+ import datetime
4
+ import uuid
5
+ from pathlib import Path
6
+ from config import SESSIONS_DIR, DAILY_DIR, MR_SESSION_DIR, SESSION_HIST_FILE
7
+
8
+ def build_title(messages: list[dict]) -> str:
9
+ """Generate a descriptive title from the first user message."""
10
+ for m in messages:
11
+ if m.get("role") == "user":
12
+ content = m.get("content", "")
13
+ if isinstance(content, list):
14
+ # Handle multi-modal or list content
15
+ text = " ".join(part.get("text", "") for part in content if isinstance(part, dict))
16
+ else:
17
+ text = str(content)
18
+
19
+ if text.strip():
20
+ clean = text.strip().replace("\n", " ")
21
+ return clean[:40] + ("..." if len(clean) > 40 else "")
22
+ return "Nueva conversación"
23
+
24
+ def scan_sessions() -> list[dict]:
25
+ """Scan session directories and return sorted list of metadata."""
26
+ sessions: list[dict] = []
27
+ seen: set[str] = set()
28
+ files: list[Path] = []
29
+
30
+ # Daily sessions (newest first)
31
+ if DAILY_DIR.exists():
32
+ for day_dir in sorted(DAILY_DIR.iterdir(), reverse=True):
33
+ if day_dir.is_dir():
34
+ files.extend(sorted(day_dir.glob("session_*.json"), reverse=True))
35
+
36
+ # MR sessions
37
+ if MR_SESSION_DIR.exists():
38
+ files.extend(
39
+ s for s in sorted(MR_SESSION_DIR.glob("*.json"), reverse=True)
40
+ if s.name != "session_latest.json"
41
+ )
42
+
43
+ # Root sessions
44
+ if SESSIONS_DIR.exists():
45
+ files.extend(sorted(SESSIONS_DIR.glob("session_*.json"), reverse=True))
46
+
47
+ for path in files:
48
+ try:
49
+ data = json.loads(path.read_text(encoding="utf-8", errors="replace"))
50
+ sid = data.get("session_id", path.stem)
51
+ if sid in seen:
52
+ continue
53
+ seen.add(sid)
54
+
55
+ messages = data.get("messages", [])
56
+ title = build_title(messages)
57
+
58
+ saved_at = data.get("saved_at", "")
59
+ if saved_at and len(saved_at) >= 19:
60
+ # Add time prefix: "HH:MM Title"
61
+ title = f"{saved_at[11:16]} {title}"
62
+
63
+ sessions.append({
64
+ "id": sid,
65
+ "title": title,
66
+ "path": str(path),
67
+ "messages": messages,
68
+ "saved_at": saved_at
69
+ })
70
+ except Exception:
71
+ continue
72
+
73
+ # Sort all found sessions by saved_at DESC
74
+ sessions.sort(key=lambda x: x.get("saved_at", ""), reverse=True)
75
+ return sessions[:50]
76
+
77
+ def save_session(state, config: dict, session_id: str | None = None) -> str:
78
+ """Save AgentState to disk in standard Dulus format. Returns the session_id."""
79
+ if not state.messages:
80
+ return ""
81
+
82
+ # User request: Only save if there is at least one user message
83
+ has_user_msg = any(m.get("role") == "user" for m in state.messages)
84
+ if not has_user_msg:
85
+ return ""
86
+
87
+ sid = session_id or uuid.uuid4().hex[:8]
88
+ now = datetime.datetime.now()
89
+ ts = now.strftime("%H%M%S")
90
+ date_str = now.strftime("%Y-%m-%d")
91
+
92
+ # 1. Build payload
93
+ data = {
94
+ "session_id": sid,
95
+ "saved_at": now.strftime("%Y-%m-%d %H:%M:%S"),
96
+ "messages": state.messages,
97
+ "turn_count": getattr(state, "turn_count", len(state.messages) // 2),
98
+ "total_input_tokens": getattr(state, "total_input_tokens", 0),
99
+ "total_output_tokens": getattr(state, "total_output_tokens", 0),
100
+ }
101
+ payload = json.dumps(data, indent=2, default=str)
102
+
103
+ # 2. Save latest for /resume
104
+ MR_SESSION_DIR.mkdir(parents=True, exist_ok=True)
105
+ (MR_SESSION_DIR / "session_latest.json").write_text(payload, encoding="utf-8")
106
+
107
+ # 3. Save to daily folder
108
+ day_dir = DAILY_DIR / date_str
109
+ day_dir.mkdir(parents=True, exist_ok=True)
110
+ daily_path = day_dir / f"session_{ts}_{sid}.json"
111
+ daily_path.write_text(payload, encoding="utf-8")
112
+
113
+ # 4. Update history.json
114
+ try:
115
+ hist = {"total_turns": 0, "sessions": []}
116
+ if SESSION_HIST_FILE.exists():
117
+ try:
118
+ hist = json.loads(SESSION_HIST_FILE.read_text())
119
+ except Exception:
120
+ pass
121
+
122
+ # Update or append
123
+ existing_idx = -1
124
+ for i, s in enumerate(hist.get("sessions", [])):
125
+ if s.get("session_id") == sid:
126
+ existing_idx = i
127
+ break
128
+
129
+ if existing_idx >= 0:
130
+ hist["sessions"][existing_idx] = data
131
+ else:
132
+ hist["sessions"].append(data)
133
+
134
+ # Prune history (keep 200)
135
+ limit = config.get("session_history_limit", 200)
136
+ if len(hist["sessions"]) > limit:
137
+ hist["sessions"] = hist["sessions"][-limit:]
138
+
139
+ SESSION_HIST_FILE.write_text(json.dumps(hist, indent=2, default=str), encoding="utf-8")
140
+ except Exception:
141
+ pass # Don't crash UI if history.json fails
142
+
143
+ return sid
144
+
145
+ def delete_session(session_id: str) -> bool:
146
+ """Delete all session files related to the given ID. Returns True if any deleted."""
147
+ if not session_id:
148
+ return False
149
+
150
+ deleted = False
151
+
152
+ # 1. Scan and delete in MR_SESSION_DIR (except latest maybe?)
153
+ if MR_SESSION_DIR.exists():
154
+ for p in MR_SESSION_DIR.glob(f"*{session_id}*"):
155
+ try:
156
+ p.unlink()
157
+ deleted = True
158
+ except: pass
159
+
160
+ # 2. Daily sessions
161
+ if DAILY_DIR.exists():
162
+ for d in DAILY_DIR.iterdir():
163
+ if d.is_dir():
164
+ for p in d.glob(f"*{session_id}*"):
165
+ try:
166
+ p.unlink()
167
+ deleted = True
168
+ except: pass
169
+
170
+ # 3. Root sessions
171
+ if SESSIONS_DIR.exists():
172
+ for p in SESSIONS_DIR.glob(f"*{session_id}*"):
173
+ try:
174
+ p.unlink()
175
+ deleted = True
176
+ except: pass
177
+
178
+ # 4. Update history.json
179
+ if SESSION_HIST_FILE.exists():
180
+ try:
181
+ hist = json.loads(SESSION_HIST_FILE.read_text())
182
+ original_len = len(hist.get("sessions", []))
183
+ hist["sessions"] = [s for s in hist.get("sessions", []) if s.get("session_id") != session_id]
184
+ if len(hist["sessions"]) < original_len:
185
+ SESSION_HIST_FILE.write_text(json.dumps(hist, indent=2, default=str), encoding="utf-8")
186
+ deleted = True
187
+ except: pass
188
+
189
+ return deleted
gui/settings_dialog.py ADDED
@@ -0,0 +1,146 @@
1
+ """Settings popup for Dulus GUI."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from typing import Optional
6
+
7
+ import customtkinter as ctk
8
+
9
+ from config import save_config
10
+ from gui.themes import list_themes, set_theme
11
+
12
+ THEME = {
13
+ "bg": "#1a1a2e",
14
+ "card": "#16213e",
15
+ "accent": "#00BCD4",
16
+ "accent_hover": "#00acc1",
17
+ "text": "#eaeaea",
18
+ "dim": "#888888",
19
+ "border": "#2a2a4a",
20
+ }
21
+
22
+ FONT_FAMILY = "Segoe UI"
23
+
24
+
25
+ def _build_model_list() -> list[str]:
26
+ """Build list of provider/model strings from PROVIDERS registry."""
27
+ try:
28
+ from providers import PROVIDERS
29
+ models: list[str] = []
30
+ for pname, pmeta in PROVIDERS.items():
31
+ for m in pmeta.get("models", []):
32
+ models.append(f"{pname}/{m}")
33
+ return sorted(models) if models else ["kimi/kimi-k2.5"]
34
+ except Exception:
35
+ return [
36
+ "kimi/kimi-k2.5",
37
+ "openai/gpt-4o",
38
+ "anthropic/claude-3-5-sonnet",
39
+ "deepseek/deepseek-chat",
40
+ "ollama/llama3.3",
41
+ ]
42
+
43
+
44
+ class SettingsDialog(ctk.CTkToplevel):
45
+ """Floating settings window."""
46
+
47
+ def __init__(self, master, config: dict) -> None:
48
+ super().__init__(master)
49
+ self.config = config
50
+ self.title("Settings")
51
+ self.geometry("480x520")
52
+ self.configure(fg_color=THEME["bg"])
53
+ self.transient(master)
54
+ self.grab_set()
55
+
56
+ # Center on parent
57
+ self.update_idletasks()
58
+ x = master.winfo_x() + (master.winfo_width() - self.winfo_width()) // 2
59
+ y = master.winfo_y() + (master.winfo_height() - self.winfo_height()) // 2
60
+ self.geometry(f"+{x}+{y}")
61
+
62
+ # Header
63
+ ctk.CTkLabel(
64
+ self,
65
+ text="⚙ Settings",
66
+ font=(FONT_FAMILY, 18, "bold"),
67
+ text_color=THEME["accent"],
68
+ ).pack(pady=(20, 15))
69
+
70
+ # Scrollable content
71
+ scroll = ctk.CTkScrollableFrame(self, fg_color="transparent", width=440)
72
+ scroll.pack(fill="both", expand=True, padx=20, pady=5)
73
+
74
+ # Model
75
+ ctk.CTkLabel(scroll, text="Model", font=(FONT_FAMILY, 12, "bold"), text_color=THEME["text"]).pack(anchor="w", pady=(10, 2))
76
+ self.model_var = ctk.StringVar(value=config.get("model", "kimi/kimi-k2.5"))
77
+ models = _build_model_list()
78
+ ctk.CTkOptionMenu(scroll, values=models, variable=self.model_var, fg_color=THEME["card"]).pack(fill="x", pady=2)
79
+
80
+ # Thinking
81
+ ctk.CTkLabel(scroll, text="Thinking Level", font=(FONT_FAMILY, 12, "bold"), text_color=THEME["text"]).pack(anchor="w", pady=(15, 2))
82
+ think_val = {0: "off", 1: "min", 2: "med", 3: "max", 4: "raw"}.get(config.get("thinking", 0), "off")
83
+ self.think_var = ctk.StringVar(value=think_val)
84
+ ctk.CTkOptionMenu(scroll, values=["off", "min", "med", "max", "raw"], variable=self.think_var, fg_color=THEME["card"]).pack(fill="x", pady=2)
85
+
86
+ # Verbose
87
+ ctk.CTkLabel(scroll, text="Verbose Mode", font=(FONT_FAMILY, 12, "bold"), text_color=THEME["text"]).pack(anchor="w", pady=(15, 2))
88
+ self.verbose_var = ctk.BooleanVar(value=config.get("verbose", False))
89
+ ctk.CTkSwitch(scroll, text="Enable verbose output", variable=self.verbose_var, progress_color=THEME["accent"]).pack(anchor="w", pady=2)
90
+
91
+ # Appearance mode
92
+ ctk.CTkLabel(scroll, text="Appearance", font=(FONT_FAMILY, 12, "bold"), text_color=THEME["text"]).pack(anchor="w", pady=(15, 2))
93
+ self.appearance_var = ctk.StringVar(value=config.get("appearance", "Dark"))
94
+ ctk.CTkOptionMenu(scroll, values=["Dark", "Light", "System"], variable=self.appearance_var, fg_color=THEME["card"]).pack(fill="x", pady=2)
95
+
96
+ # Color theme
97
+ ctk.CTkLabel(scroll, text="Color Theme", font=(FONT_FAMILY, 12, "bold"), text_color=THEME["text"]).pack(anchor="w", pady=(15, 2))
98
+ self.theme_var = ctk.StringVar(value=config.get("theme", "midnight"))
99
+ ctk.CTkOptionMenu(scroll, values=list_themes(), variable=self.theme_var, fg_color=THEME["card"]).pack(fill="x", pady=2)
100
+
101
+ # API Key (masked)
102
+ ctk.CTkLabel(scroll, text="API Key (active provider)", font=(FONT_FAMILY, 12, "bold"), text_color=THEME["text"]).pack(anchor="w", pady=(15, 2))
103
+ self.api_var = ctk.StringVar()
104
+ ctk.CTkEntry(scroll, textvariable=self.api_var, show="●", fg_color=THEME["card"], text_color=THEME["text"]).pack(fill="x", pady=2)
105
+
106
+ # Buttons
107
+ btn_frame = ctk.CTkFrame(self, fg_color="transparent")
108
+ btn_frame.pack(fill="x", padx=20, pady=15)
109
+
110
+ ctk.CTkButton(
111
+ btn_frame,
112
+ text="Cancel",
113
+ fg_color=THEME["border"],
114
+ hover_color="red",
115
+ command=self.destroy,
116
+ ).pack(side="left", padx=5)
117
+
118
+ ctk.CTkButton(
119
+ btn_frame,
120
+ text="Save",
121
+ fg_color=THEME["accent"],
122
+ hover_color=THEME["accent_hover"],
123
+ command=self._save,
124
+ ).pack(side="right", padx=5)
125
+
126
+ def _save(self) -> None:
127
+ self.config["model"] = self.model_var.get()
128
+ think_map = {"off": 0, "min": 1, "med": 2, "max": 3, "raw": 4}
129
+ self.config["thinking"] = think_map.get(self.think_var.get(), 0)
130
+ self.config["verbose"] = self.verbose_var.get()
131
+ self.config["appearance"] = self.appearance_var.get()
132
+ self.config["theme"] = self.theme_var.get()
133
+ ctk.set_appearance_mode(self.appearance_var.get())
134
+ # Notify parent to apply color theme
135
+ if hasattr(self.master, "apply_theme"):
136
+ self.master.apply_theme(self.theme_var.get())
137
+ key = self.api_var.get().strip()
138
+ if key:
139
+ pname = self.config.get("model", "").split("/")[0]
140
+ if pname:
141
+ self.config[f"{pname}_api_key"] = key
142
+ try:
143
+ save_config(self.config)
144
+ except Exception:
145
+ pass
146
+ self.destroy()