openclacky 0.8.2 → 0.8.4

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.
@@ -71,7 +71,7 @@ const Settings = (() => {
71
71
  </div>
72
72
  <div class="model-card-actions">
73
73
  ${_models.length > 1
74
- ? `<button class="btn-model-remove" data-index="${index}" title="Remove this model">✕</button>`
74
+ ? `<button class="btn-model-remove" data-index="${index}" title="Remove this model">×</button>`
75
75
  : ""}
76
76
  </div>
77
77
  </div>
@@ -79,11 +79,19 @@ const Settings = (() => {
79
79
  <div class="model-fields">
80
80
  <label class="model-field">
81
81
  <span class="field-label">Quick Setup</span>
82
- <select class="field-select provider-select" data-index="${index}">
83
- <option value="">— Choose provider —</option>
84
- ${providerOptions}
85
- <option value="custom">Custom</option>
86
- </select>
82
+ <div class="custom-select-wrapper" data-index="${index}">
83
+ <div class="custom-select-trigger">
84
+ <span class="custom-select-value placeholder">— Choose provider —</span>
85
+ <svg class="custom-select-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
86
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
87
+ </svg>
88
+ </div>
89
+ <div class="custom-select-dropdown">
90
+ <div class="custom-select-option" data-value="">— Choose provider —</div>
91
+ ${_providers.map(p => `<div class="custom-select-option" data-value="${p.id}">${_esc(p.name)}</div>`).join("")}
92
+ <div class="custom-select-option" data-value="custom">Custom</div>
93
+ </div>
94
+ </div>
87
95
  </label>
88
96
  <label class="model-field">
89
97
  <span class="field-label">Model</span>
@@ -100,14 +108,22 @@ const Settings = (() => {
100
108
  <div class="field-input-row">
101
109
  <input type="password" class="field-input api-key-input" data-key="api_key" data-index="${index}"
102
110
  placeholder="sk-…" value="${_esc(model.api_key_masked)}">
103
- <button class="btn-toggle-key" data-index="${index}" title="Show/hide key">👁</button>
111
+ <button class="btn-toggle-key" data-index="${index}" title="Show/hide key">
112
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
113
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
114
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
115
+ </svg>
116
+ </button>
104
117
  </div>
105
118
  </label>
106
119
  </div>
107
120
 
108
121
  <div class="model-card-footer">
109
122
  <span class="model-test-result" data-index="${index}"></span>
110
- <button class="btn-save-model btn-primary" data-index="${index}">Save</button>
123
+ <div class="model-card-actions-row">
124
+ ${!isDefault ? `<button class="btn-set-default" data-index="${index}" title="Set as default model">Set as Default</button>` : ""}
125
+ <button class="btn-save-model btn-primary" data-index="${index}">Save</button>
126
+ </div>
111
127
  </div>
112
128
  `;
113
129
 
@@ -116,23 +132,92 @@ const Settings = (() => {
116
132
  }
117
133
 
118
134
  function _bindCardEvents(card, index) {
119
- // Provider preset dropdown — auto-fill model & base_url
120
- card.querySelector(".provider-select").addEventListener("change", (e) => {
121
- const pid = e.target.value;
122
- if (!pid || pid === "custom") return;
123
- const preset = _providers.find(p => p.id === pid);
124
- if (!preset) return;
125
-
126
- const modelInput = card.querySelector(`[data-key="model"]`);
127
- const baseUrlInput = card.querySelector(`[data-key="base_url"]`);
128
- if (modelInput) modelInput.value = preset.default_model || "";
129
- if (baseUrlInput) baseUrlInput.value = preset.base_url || "";
135
+ // Custom dropdown interactions
136
+ const customSelectWrapper = card.querySelector(".custom-select-wrapper");
137
+ const trigger = customSelectWrapper.querySelector(".custom-select-trigger");
138
+ const dropdown = customSelectWrapper.querySelector(".custom-select-dropdown");
139
+ const valueSpan = trigger.querySelector(".custom-select-value");
140
+ const options = dropdown.querySelectorAll(".custom-select-option");
141
+
142
+ // Toggle dropdown
143
+ trigger.addEventListener("click", (e) => {
144
+ e.stopPropagation();
145
+ const isOpen = dropdown.classList.contains("open");
146
+ // Close all other dropdowns
147
+ document.querySelectorAll(".custom-select-dropdown.open").forEach(d => {
148
+ d.classList.remove("open");
149
+ d.previousElementSibling.classList.remove("open");
150
+ });
151
+ if (!isOpen) {
152
+ dropdown.classList.add("open");
153
+ trigger.classList.add("open");
154
+ }
155
+ });
156
+
157
+ // Select option
158
+ options.forEach(option => {
159
+ option.addEventListener("click", (e) => {
160
+ e.stopPropagation();
161
+ const value = option.dataset.value;
162
+ const text = option.textContent;
163
+
164
+ // Update UI
165
+ valueSpan.textContent = text;
166
+ if (value) {
167
+ valueSpan.classList.remove("placeholder");
168
+ } else {
169
+ valueSpan.classList.add("placeholder");
170
+ }
171
+
172
+ // Update selected state
173
+ options.forEach(opt => opt.classList.remove("selected"));
174
+ option.classList.add("selected");
175
+
176
+ // Close dropdown
177
+ dropdown.classList.remove("open");
178
+ trigger.classList.remove("open");
179
+
180
+ // Auto-fill model & base_url if a provider preset was selected
181
+ if (value && value !== "custom") {
182
+ const preset = _providers.find(p => p.id === value);
183
+ if (preset) {
184
+ const modelInput = card.querySelector(`[data-key="model"]`);
185
+ const baseUrlInput = card.querySelector(`[data-key="base_url"]`);
186
+ if (modelInput) modelInput.value = preset.default_model || "";
187
+ if (baseUrlInput) baseUrlInput.value = preset.base_url || "";
188
+ }
189
+ }
190
+ });
191
+ });
192
+
193
+ // Close dropdown when clicking outside
194
+ document.addEventListener("click", () => {
195
+ dropdown.classList.remove("open");
196
+ trigger.classList.remove("open");
130
197
  });
131
198
 
132
199
  // Toggle API key visibility
133
- card.querySelector(".btn-toggle-key").addEventListener("click", () => {
134
- const input = card.querySelector(".api-key-input");
135
- input.type = input.type === "password" ? "text" : "password";
200
+ const toggleKeyBtn = card.querySelector(".btn-toggle-key");
201
+ const apiKeyInput = card.querySelector(".api-key-input");
202
+ const eyeIcon = toggleKeyBtn.querySelector("svg");
203
+
204
+ toggleKeyBtn.addEventListener("click", () => {
205
+ const isPassword = apiKeyInput.type === "password";
206
+ apiKeyInput.type = isPassword ? "text" : "password";
207
+
208
+ // Update icon
209
+ if (isPassword) {
210
+ // Show eye-off icon
211
+ eyeIcon.innerHTML = `
212
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
213
+ `;
214
+ } else {
215
+ // Show eye icon
216
+ eyeIcon.innerHTML = `
217
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
218
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
219
+ `;
220
+ }
136
221
  });
137
222
 
138
223
  // Save: auto-test first, then save if passed
@@ -143,6 +228,12 @@ const Settings = (() => {
143
228
  if (removeBtn) {
144
229
  removeBtn.addEventListener("click", () => _removeModel(index));
145
230
  }
231
+
232
+ // Set as default model
233
+ const setDefaultBtn = card.querySelector(".btn-set-default");
234
+ if (setDefaultBtn) {
235
+ setDefaultBtn.addEventListener("click", () => _setAsDefault(index));
236
+ }
146
237
  }
147
238
 
148
239
  // ── Read form values from a card ────────────────────────────────────────────
@@ -233,26 +324,88 @@ const Settings = (() => {
233
324
  el.className = `model-test-result ${ok ? "result-ok" : "result-fail"}`;
234
325
  }
235
326
 
327
+ // ── Set as Default Model ───────────────────────────────────────────────────
328
+
329
+ async function _setAsDefault(index) {
330
+ const btn = document.querySelector(`.btn-set-default[data-index="${index}"]`);
331
+ if (!btn) return;
332
+
333
+ btn.disabled = true;
334
+ btn.textContent = "Setting…";
335
+
336
+ // Set the selected one as "default", others as null (not just delete)
337
+ // Using null ensures the server explicitly updates/removes the type field
338
+ const updatedModels = _models.map((m, i) => {
339
+ const model = { ...m };
340
+ if (i === index) {
341
+ model.type = "default";
342
+ } else {
343
+ model.type = null;
344
+ }
345
+ return model;
346
+ });
347
+
348
+ try {
349
+ const res = await fetch("/api/config", {
350
+ method: "POST",
351
+ headers: { "Content-Type": "application/json" },
352
+ body: JSON.stringify({ models: updatedModels })
353
+ });
354
+ const data = await res.json();
355
+
356
+ if (data.ok) {
357
+ btn.textContent = "Done ✓";
358
+ // Reload to refresh the UI
359
+ setTimeout(_load, 800);
360
+ } else {
361
+ btn.textContent = "Set as Default";
362
+ btn.disabled = false;
363
+ alert(data.error || "Failed to set default model");
364
+ }
365
+ } catch (e) {
366
+ btn.textContent = "Set as Default";
367
+ btn.disabled = false;
368
+ alert("Error: " + e.message);
369
+ }
370
+ }
371
+
236
372
  // ── Add / Remove model ───────────────────────────────────────────────────────
237
373
 
238
374
  function _addModel() {
375
+ // When adding a new model, automatically set it as default.
376
+ // Set all existing models' type to null (not just delete) so server updates them.
377
+ _models = _models.map(m => {
378
+ const model = { ...m };
379
+ model.type = null;
380
+ return model;
381
+ });
382
+
239
383
  _models.push({
240
384
  index: _models.length,
241
385
  model: "",
242
386
  base_url: "",
243
387
  api_key_masked: "",
244
388
  anthropic_format: false,
245
- type: null
389
+ type: "default" // New model automatically becomes default
246
390
  });
247
391
  _renderCards();
248
- // Scroll to the new card
392
+ // Scroll to the new card with offset
249
393
  const cards = document.querySelectorAll(".model-card");
250
- if (cards.length) cards[cards.length - 1].scrollIntoView({ behavior: "smooth" });
394
+ if (cards.length) {
395
+ const lastCard = cards[cards.length - 1];
396
+ lastCard.scrollIntoView({ behavior: "smooth", block: "start" });
397
+ // Add 20px offset after scroll completes
398
+ setTimeout(() => {
399
+ const container = document.getElementById("settings-body");
400
+ if (container) container.scrollTop -= 20;
401
+ }, 300);
402
+ }
251
403
  }
252
404
 
253
405
  async function _removeModel(index) {
254
406
  if (_models.length <= 1) return;
255
- if (!confirm(`Remove model "${_models[index]?.model || index + 1}"?`)) return;
407
+ const confirmed = await Modal.confirm(`Remove model "${_models[index]?.model || index + 1}"?`);
408
+ if (!confirmed) return;
256
409
 
257
410
  _models.splice(index, 1);
258
411
 
@@ -352,9 +352,13 @@ const Skills = (() => {
352
352
  const labelEl = $("skills-sidebar-label");
353
353
  if (!labelEl) return;
354
354
  const total = _skills.length;
355
- labelEl.textContent = total === 0
356
- ? "No skills"
357
- : `${total} skill${total !== 1 ? "s" : ""}`;
355
+ // Don't show "No skills" when the skills panel is active
356
+ const isActive = Router.current === "skills";
357
+ if (total === 0) {
358
+ labelEl.textContent = isActive ? "Skills" : "No skills";
359
+ } else {
360
+ labelEl.textContent = `${total} skill${total !== 1 ? "s" : ""}`;
361
+ }
358
362
  },
359
363
 
360
364
  // ── Actions ───────────────────────────────────────────────────────────
@@ -47,15 +47,32 @@ const Tasks = (() => {
47
47
 
48
48
  row.innerHTML = `
49
49
  <div class="task-col task-col-name">
50
- <span class="task-icon">⏰</span>
50
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="task-icon">
51
+ <circle cx="12" cy="12" r="10"/>
52
+ <polyline points="12 6 12 12 16 14"/>
53
+ </svg>
51
54
  <span class="task-name-text">${escapeHtml(t.name)}</span>
52
55
  </div>
53
56
  <div class="task-col task-col-schedule">${schedLabel}</div>
54
57
  <div class="task-col task-col-content">${previewText}</div>
55
58
  <div class="task-col task-col-actions">
56
- <button class="task-btn task-btn-run" title="Run now">▶ Run</button>
57
- <button class="task-btn task-btn-edit" title="Edit task">✎ Edit</button>
58
- <button class="task-btn task-btn-del" title="Delete">✕</button>
59
+ <button class="task-btn task-btn-run" title="Run now">
60
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
61
+ <polygon points="6 3 20 12 6 21 6 3"/>
62
+ </svg> Run
63
+ </button>
64
+ <button class="task-btn task-btn-edit" title="Edit task">
65
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
66
+ <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
67
+ <path d="m15 5 4 4"/>
68
+ </svg> Edit
69
+ </button>
70
+ <button class="task-btn task-btn-del" title="Delete">
71
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
72
+ <path d="M18 6 6 18"/>
73
+ <path d="m6 6 12 12"/>
74
+ </svg>
75
+ </button>
59
76
  </div>`;
60
77
 
61
78
  row.querySelector(".task-btn-run").addEventListener("click", e => {
@@ -113,9 +130,13 @@ const Tasks = (() => {
113
130
  // Sidebar item is static in HTML — just update the label text.
114
131
  const labelEl = $("tasks-sidebar-label");
115
132
  if (!labelEl) return;
116
- labelEl.textContent = _tasks.length === 0
117
- ? "No tasks"
118
- : `${_tasks.length} task${_tasks.length !== 1 ? "s" : ""}`;
133
+ // Don't show "No tasks" when the tasks panel is active
134
+ const isActive = Router.current === "tasks";
135
+ if (_tasks.length === 0) {
136
+ labelEl.textContent = isActive ? "Tasks" : "No tasks";
137
+ } else {
138
+ labelEl.textContent = `${_tasks.length} task${_tasks.length !== 1 ? "s" : ""}`;
139
+ }
119
140
  },
120
141
 
121
142
  // ── Main panel table ──────────────────────────────────────────────────
@@ -130,7 +151,12 @@ const Tasks = (() => {
130
151
  empty.className = "task-table-empty";
131
152
  empty.innerHTML = `
132
153
  <p>No scheduled tasks.</p>
133
- <button class="task-create-btn" id="btn-create-task-empty">+ Create Task</button>`;
154
+ <button class="task-create-btn" id="btn-create-task-empty">
155
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
156
+ <path d="M5 12h14"/>
157
+ <path d="M12 5v14"/>
158
+ </svg> Create Task
159
+ </button>`;
134
160
  table.appendChild(empty);
135
161
  const btn = table.querySelector("#btn-create-task-empty");
136
162
  if (btn) btn.addEventListener("click", () => Tasks.createInSession());
@@ -0,0 +1,67 @@
1
+ // theme.js — Theme switcher module
2
+ // Handles light/dark theme persistence and switching
3
+
4
+ const Theme = (() => {
5
+ const STORAGE_KEY = "clacky-theme";
6
+ const ATTR_NAME = "data-theme";
7
+
8
+ // Initialize theme from localStorage or system preference
9
+ function init() {
10
+ const saved = localStorage.getItem(STORAGE_KEY);
11
+ const theme = saved || (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
12
+ apply(theme);
13
+ }
14
+
15
+ // Apply theme to document
16
+ function apply(theme) {
17
+ document.documentElement.setAttribute(ATTR_NAME, theme);
18
+ localStorage.setItem(STORAGE_KEY, theme);
19
+
20
+ // Update header toggle button if it exists
21
+ const headerToggle = document.getElementById("theme-toggle-header");
22
+ if (headerToggle) {
23
+ if (theme === "light") {
24
+ headerToggle.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
25
+ <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
26
+ </svg>`;
27
+ } else {
28
+ headerToggle.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
29
+ <circle cx="12" cy="12" r="4"/>
30
+ <path d="M12 2v2"/>
31
+ <path d="M12 20v2"/>
32
+ <path d="m4.93 4.93 1.41 1.41"/>
33
+ <path d="m17.66 17.66 1.41 1.41"/>
34
+ <path d="M2 12h2"/>
35
+ <path d="M20 12h2"/>
36
+ <path d="m6.34 17.66-1.41 1.41"/>
37
+ <path d="m19.07 4.93-1.41 1.41"/>
38
+ </svg>`;
39
+ }
40
+ }
41
+
42
+ // Update settings toggle button if it exists (legacy)
43
+ const toggle = document.getElementById("theme-toggle");
44
+ if (toggle) {
45
+ const icon = theme === "light" ? "🌙" : "☀️";
46
+ const label = theme === "light" ? "Dark" : "Light";
47
+ toggle.innerHTML = `<span class="theme-icon">${icon}</span><span>${label}</span>`;
48
+ }
49
+ }
50
+
51
+ // Toggle between light and dark
52
+ function toggle() {
53
+ const current = document.documentElement.getAttribute(ATTR_NAME) || "dark";
54
+ const next = current === "dark" ? "light" : "dark";
55
+ apply(next);
56
+ }
57
+
58
+ // Get current theme
59
+ function current() {
60
+ return document.documentElement.getAttribute(ATTR_NAME) || "dark";
61
+ }
62
+
63
+ return { init, toggle, current };
64
+ })();
65
+
66
+ // Initialize theme on page load
67
+ Theme.init();
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.2
4
+ version: 0.8.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
@@ -177,6 +177,20 @@ dependencies:
177
177
  - - "~>"
178
178
  - !ruby/object:Gem::Version
179
179
  version: '2.1'
180
+ - !ruby/object:Gem::Dependency
181
+ name: rubyzip
182
+ requirement: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - "~>"
185
+ - !ruby/object:Gem::Version
186
+ version: '3.0'
187
+ type: :runtime
188
+ prerelease: false
189
+ version_requirements: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: '3.0'
180
194
  description: OpenClacky is a Ruby CLI tool for interacting with AI models via OpenAI-compatible
181
195
  APIs. It provides chat functionality and autonomous AI agent capabilities with tool
182
196
  use.
@@ -336,6 +350,7 @@ files:
336
350
  - lib/clacky/web/settings.js
337
351
  - lib/clacky/web/skills.js
338
352
  - lib/clacky/web/tasks.js
353
+ - lib/clacky/web/theme.js
339
354
  - lib/clacky/web/ws.js
340
355
  - scripts/install.sh
341
356
  - scripts/uninstall.sh