openclacky 1.4.0 → 1.4.1

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.
@@ -195,7 +195,9 @@ module Clacky
195
195
  dirs = children.select { |c| File.directory?(File.join(root, c)) }
196
196
 
197
197
  if File.file?(File.join(root, MANIFEST))
198
- return [File.basename(File.expand_path(root)), root]
198
+ id = manifest_id(File.join(root, MANIFEST))
199
+ raise Error, "archive has no top-level container and #{MANIFEST} declares no id" if id.empty?
200
+ return [id, root]
199
201
  end
200
202
 
201
203
  if dirs.size == 1 && File.file?(File.join(root, dirs.first, MANIFEST))
@@ -205,6 +207,13 @@ module Clacky
205
207
  raise Error, "archive does not contain a single container with #{MANIFEST}"
206
208
  end
207
209
 
210
+ private def manifest_id(manifest_path)
211
+ data = YAMLCompat.load_file(manifest_path)
212
+ data.is_a?(Hash) ? data["id"].to_s.strip : ""
213
+ rescue StandardError
214
+ ""
215
+ end
216
+
208
217
  private def verify_installed(ext_id, installed_dir)
209
218
  layers =
210
219
  if installed_dir == Clacky::ExtensionLoader::INSTALLED_DIR
@@ -24,17 +24,19 @@ module Clacky
24
24
  # # result => { success: true, data: {...} }
25
25
  # # or { success: false, error: "...", data: {} }
26
26
  class PlatformHttpClient
27
- # Primary CDN-accelerated endpoint
28
- PRIMARY_HOST = "https://www.openclacky.com"
27
+ # Primary endpoint
28
+ PRIMARY_HOST = "https://www.openclacky.com"
29
+ # Secondary CDN-accelerated endpoint (China mainland)
30
+ SECONDARY_HOST = "https://api.1024code.com"
29
31
  # Direct fallback — bypasses EdgeOne, used when the primary times out
30
- FALLBACK_HOST = "https://openclacky.up.railway.app"
32
+ FALLBACK_HOST = "https://openclacky.up.railway.app"
31
33
 
32
34
  # Number of attempts per domain (1 = no retry within the same domain)
33
- ATTEMPTS_PER_HOST = 2
35
+ ATTEMPTS_PER_HOST = 1
34
36
  # Initial back-off between retries within the same domain (seconds)
35
37
  INITIAL_BACKOFF = 0.5
36
38
  # Connection / read timeouts (seconds) for API calls
37
- OPEN_TIMEOUT = 8
39
+ OPEN_TIMEOUT = 5
38
40
  READ_TIMEOUT = 15
39
41
  # Read timeout for streaming large file downloads (seconds)
40
42
  DOWNLOAD_READ_TIMEOUT = 120
@@ -53,12 +55,12 @@ module Clacky
53
55
 
54
56
  # Auto-detects the target host(s):
55
57
  # - When CLACKY_LICENSE_SERVER is set → single host (dev override, no failover)
56
- # - Otherwise → [PRIMARY_HOST, FALLBACK_HOST]
58
+ # - Otherwise → [PRIMARY_HOST, SECONDARY_HOST, FALLBACK_HOST]
57
59
  def initialize
58
60
  if (override = ENV["CLACKY_LICENSE_SERVER"]) && !override.empty?
59
61
  @hosts = [override]
60
62
  else
61
- @hosts = [PRIMARY_HOST, FALLBACK_HOST]
63
+ @hosts = [PRIMARY_HOST, SECONDARY_HOST, FALLBACK_HOST]
62
64
  end
63
65
  end
64
66
 
@@ -260,8 +260,8 @@ module Clacky
260
260
  "name" => "Kimi (Moonshot)",
261
261
  "base_url" => "https://api.moonshot.cn/v1",
262
262
  "api" => "openai-completions",
263
- "default_model" => "kimi-k2.6",
264
- "models" => ["kimi-k2.6", "kimi-k2.5"],
263
+ "default_model" => "kimi-k3",
264
+ "models" => ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"],
265
265
  # Moonshot operates two regional endpoints with identical APIs & model
266
266
  # lineup — mainland China (.cn) and international (.ai). These are the
267
267
  # pay-as-you-go Open Platform endpoints; the subscription-billed
@@ -277,9 +277,9 @@ module Clacky
277
277
  { "label" => "Mainland China", "label_key" => "settings.models.baseurl.variant.mainland_cn", "base_url" => "https://api.moonshot.cn/v1", "region" => "cn" }.freeze,
278
278
  { "label" => "International", "label_key" => "settings.models.baseurl.variant.international", "base_url" => "https://api.moonshot.ai/v1", "region" => "intl" }.freeze
279
279
  ].freeze,
280
- # k2.5 / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
280
+ # k3 / k2.7-code / k2.5 / k2.6 are multimodal; legacy k2 text-only models need model_capabilities override if added.
281
281
  "capabilities" => { "vision" => true }.freeze,
282
- "default_ocr_model" => "kimi-k2.5",
282
+ "default_ocr_model" => "kimi-k3",
283
283
  "website_url" => "https://platform.moonshot.cn/console/api-keys"
284
284
  }.freeze,
285
285
 
@@ -36,9 +36,10 @@ module Clacky
36
36
  @events = events
37
37
  end
38
38
 
39
- def show_user_message(content, created_at: nil, files: [])
39
+ def show_user_message(content, created_at: nil, files: [], editable: true)
40
40
  ev = { type: "history_user_message", session_id: @session_id, content: content }
41
41
  ev[:created_at] = created_at if created_at
42
+ ev[:editable] = false unless editable
42
43
  rendered = Array(files).filter_map do |f|
43
44
  url = f[:data_url] || f["data_url"]
44
45
  name = f[:name] || f["name"]
@@ -964,11 +965,13 @@ module Clacky
964
965
  # Phase "key_setup" → no API key configured yet
965
966
  # Phase "soul_setup" → key configured, but ~/.clacky/agents/SOUL.md missing
966
967
  # needs_onboard: false → fully set up
968
+ # branded: true → running under a brand; hide the OpenClacky AI Keys block
967
969
  def api_onboard_status(res)
970
+ branded = Clacky::BrandConfig.load.branded?
968
971
  if !@agent_config.models_configured?
969
- json_response(res, 200, { needs_onboard: true, phase: "key_setup" })
972
+ json_response(res, 200, { needs_onboard: true, phase: "key_setup", branded: branded })
970
973
  else
971
- json_response(res, 200, { needs_onboard: false })
974
+ json_response(res, 200, { needs_onboard: false, branded: branded })
972
975
  end
973
976
  end
974
977
 
@@ -2408,6 +2411,8 @@ module Clacky
2408
2411
  {
2409
2412
  "id" => ext_id,
2410
2413
  "name" => market ? (market["name"] || ext_id) : ext_id,
2414
+ "display_name" => market&.dig("display_name"),
2415
+ "display_name_zh" => market&.dig("display_name_zh"),
2411
2416
  "name_zh" => market&.dig("name_zh"),
2412
2417
  "name_en" => market&.dig("name_en"),
2413
2418
  "slug" => ext_id,
@@ -3691,13 +3696,11 @@ module Clacky
3691
3696
 
3692
3697
  return json_response(res, 400, { error: "path is required" }) unless path && !path.empty?
3693
3698
 
3694
- # Expand ~ to the user's home directory (e.g. "~/Desktop/file.pdf").
3695
- # Ruby's File.exist? does NOT automatically expand ~ that's a shell feature.
3696
- path = File.expand_path(path)
3697
-
3698
- # On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
3699
- # Convert it to the Linux-side path so File.exist? works.
3699
+ # Path arrives already percent-decoded by the click handler; normalize
3700
+ # Windows drive letters (WSL) BEFORE expand_path or the drive is treated
3701
+ # as relative and corrupted. No-op on macOS/Linux.
3700
3702
  linux_path = Utils::EnvironmentDetector.win_to_linux_path(path)
3703
+ linux_path = File.expand_path(linux_path)
3701
3704
 
3702
3705
  return json_response(res, 404, { error: "file not found" }) unless File.exist?(linux_path)
3703
3706
 
@@ -3711,8 +3714,11 @@ module Clacky
3711
3714
  json_response(res, 200, { ok: true })
3712
3715
  when "download"
3713
3716
  serve_file_download(res, linux_path)
3717
+ when "display-path"
3718
+ display = Utils::EnvironmentDetector.linux_to_win_path(linux_path)
3719
+ json_response(res, 200, { ok: true, path: display })
3714
3720
  else
3715
- json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal' or 'download'" })
3721
+ json_response(res, 400, { error: "invalid action. Must be 'open', 'reveal', 'download' or 'display-path'" })
3716
3722
  end
3717
3723
  rescue => e
3718
3724
  json_response(res, 500, { ok: false, error: e.message })
@@ -3742,14 +3748,9 @@ module Clacky
3742
3748
  raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
3743
3749
  return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
3744
3750
 
3745
- # Strip file:// prefix if present
3746
- path = raw_path.sub(%r{\Afile://}, "")
3747
- path = CGI.unescape(path)
3748
- path = File.expand_path(path)
3749
-
3750
- # On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
3751
- # Convert it to the Linux-side path so File.exist? works.
3752
- path = Utils::EnvironmentDetector.win_to_linux_path(path)
3751
+ # Strip file://, decode, WSL drive-letter normalize, then expand — in
3752
+ # this order (normalize must precede expand_path). No-op on macOS/Linux.
3753
+ path = Utils::EnvironmentDetector.resolve_local_path(raw_path)
3753
3754
 
3754
3755
  # Security: only serve media files (images + videos)
3755
3756
  ext = File.extname(path).downcase
@@ -121,12 +121,17 @@ module Clacky
121
121
  files = if File.file?(expanded_path)
122
122
  [expanded_path]
123
123
  else
124
+ # Auto-expand bare extension patterns (e.g. "*.ts") to recursive
125
+ # (e.g. "**/*.ts"), matching ripgrep's default behaviour.
126
+ # Patterns already containing "/" are left as-is — the user is
127
+ # intentionally scoping to a specific directory.
128
+ effective_pattern = file_pattern.include?("/") ? file_pattern : "**/#{file_pattern}"
124
129
  fnmatch_flags = File::FNM_PATHNAME | File::FNM_DOTMATCH
125
130
  collected = []
126
131
  walk_status = {}
127
132
  Clacky::Utils::FileIgnoreHelper.walk_files(expanded_path, skipped: skipped, status: walk_status) do |f|
128
133
  relative = f[(expanded_path.length + 1)..]
129
- collected << f if File.fnmatch(file_pattern, relative, fnmatch_flags)
134
+ collected << f if File.fnmatch(effective_pattern, relative, fnmatch_flags)
130
135
  end
131
136
  if walk_status[:truncated]
132
137
  truncation_reason ||= "walk #{walk_status[:truncation_reason]}"
@@ -52,17 +52,24 @@ module Clacky
52
52
  end
53
53
  end
54
54
 
55
- # Convert a Windows-style path to a WSL/Linux-side path.
56
- # e.g. "C:/Users/foo/file.txt" "/mnt/c/Users/foo/file.txt"
57
- # Returns the original path unchanged on non-WSL or if already a Linux path.
58
- # @param path [String]
59
- # @return [String]
55
+ # Convert a Windows-style path (incl. the "/C:/" three-slash-URL form) to
56
+ # a WSL /mnt path. No-op on non-WSL or non-drive-letter paths.
60
57
  def self.win_to_linux_path(path)
61
- return path unless os_type == :wsl && path.match?(/\A[A-Za-z]:[\/\\]/)
58
+ return path unless os_type == :wsl
59
+
60
+ m = path.match(%r{\A/?([A-Za-z]):[/\\](.*)}m)
61
+ return path unless m
62
+
63
+ "/mnt/#{m[1].downcase}/#{m[2].gsub("\\", "/")}"
64
+ end
62
65
 
63
- drive = path[0].downcase
64
- rest = path[2..].gsub("\\", "/")
65
- "/mnt/#{drive}#{rest}"
66
+ # Resolve a "file://" URL or bare path into a real local path:
67
+ # strip "file://", percent-decode, WSL drive-letter normalize, then expand.
68
+ def self.resolve_local_path(href)
69
+ path = href.to_s.sub(%r{\Afile://}, "")
70
+ path = CGI.unescape(path)
71
+ path = win_to_linux_path(path)
72
+ File.expand_path(path)
66
73
  end
67
74
 
68
75
  # Convert a Linux-side path to a Windows-style path via wslpath.
@@ -6,6 +6,7 @@ require "securerandom"
6
6
  require "stringio"
7
7
 
8
8
  require_relative "parser_manager"
9
+ require_relative "environment_detector"
9
10
  require "zip"
10
11
 
11
12
  module Clacky
@@ -595,67 +596,63 @@ module Clacky
595
596
  return content if content.nil? || content.empty?
596
597
 
597
598
  # Rewrite markdown image syntax ![alt](file:///path) → proxy URL
598
- content = content.gsub(/!\[([^\]]*)\]\(((?:file:\/\/)?\/[^)]+)\)/) do |_match|
599
+ content = content.gsub(/!\[([^\]]*)\]\(((?:file:\/\/)?(?:~\/|\/)[^)]+)\)/) do |_match|
599
600
  alt = Regexp.last_match(1)
600
601
  href = Regexp.last_match(2)
601
602
 
602
- path = href.sub(%r{\Afile://}, "")
603
- path = CGI.unescape(path)
603
+ real = Clacky::Utils::EnvironmentDetector.resolve_local_path(href)
604
604
 
605
- ext = File.extname(path).downcase
606
- if LOCAL_MEDIA_EXTENSIONS.include?(ext) && File.exist?(path)
607
- "![#{alt}](#{local_image_proxy_url(href, path)})"
605
+ ext = File.extname(real).downcase
606
+ if LOCAL_MEDIA_EXTENSIONS.include?(ext) && File.exist?(real)
607
+ "![#{alt}](#{local_image_proxy_url(href, real)})"
608
608
  else
609
609
  _match
610
610
  end
611
611
  end
612
612
 
613
613
  # Rewrite <video src="file:///path/vid.mp4" ...> → proxy URL
614
- content = content.gsub(/<video\b([^>]*)\bsrc="((?:file:\/\/)?\/[^"]+)"([^>]*)>/) do |_match|
614
+ content = content.gsub(/<video\b([^>]*)\bsrc="((?:file:\/\/)?(?:~\/|\/)[^"]+)"([^>]*)>/) do |_match|
615
615
  pre = Regexp.last_match(1) || ""
616
616
  href = Regexp.last_match(2)
617
617
  post = Regexp.last_match(3) || ""
618
618
 
619
- path = href.sub(%r{\Afile://}, "")
620
- path = CGI.unescape(path)
619
+ real = Clacky::Utils::EnvironmentDetector.resolve_local_path(href)
621
620
 
622
- ext = File.extname(path).downcase
623
- if LOCAL_VIDEO_EXTENSIONS.include?(ext) && File.exist?(path)
624
- "<video#{pre} src=\"#{local_image_proxy_url(href, path)}\"#{post}>"
621
+ ext = File.extname(real).downcase
622
+ if LOCAL_VIDEO_EXTENSIONS.include?(ext) && File.exist?(real)
623
+ "<video#{pre} src=\"#{local_image_proxy_url(href, real)}\"#{post}>"
625
624
  else
626
625
  _match
627
626
  end
628
627
  end
629
628
 
630
629
  # Rewrite <audio src="file:///path/audio.wav" ...> → proxy URL
631
- content = content.gsub(/<audio\b([^>]*)\bsrc="((?:file:\/\/)?\/[^"]+)"([^>]*)>/) do |_match|
630
+ content = content.gsub(/<audio\b([^>]*)\bsrc="((?:file:\/\/)?(?:~\/|\/)[^"]+)"([^>]*)>/) do |_match|
632
631
  pre = Regexp.last_match(1) || ""
633
632
  href = Regexp.last_match(2)
634
633
  post = Regexp.last_match(3) || ""
635
634
 
636
- path = href.sub(%r{\Afile://}, "")
637
- path = CGI.unescape(path)
635
+ real = Clacky::Utils::EnvironmentDetector.resolve_local_path(href)
638
636
 
639
- ext = File.extname(path).downcase
640
- if LOCAL_AUDIO_EXTENSIONS.include?(ext) && File.exist?(path)
641
- "<audio#{pre} src=\"#{local_image_proxy_url(href, path)}\"#{post}>"
637
+ ext = File.extname(real).downcase
638
+ if LOCAL_AUDIO_EXTENSIONS.include?(ext) && File.exist?(real)
639
+ "<audio#{pre} src=\"#{local_image_proxy_url(href, real)}\"#{post}>"
642
640
  else
643
641
  _match
644
642
  end
645
643
  end
646
644
 
647
645
  # Rewrite video/audio markdown links [text](file:///path) → proxy URL
648
- content = content.gsub(/(?<!!)\[([^\]]*)\]\(((?:file:\/\/)?\/[^)]+)\)/) do |_match|
646
+ content = content.gsub(/(?<!!)\[([^\]]*)\]\(((?:file:\/\/)?(?:~\/|\/)[^)]+)\)/) do |_match|
649
647
  text = Regexp.last_match(1)
650
648
  href = Regexp.last_match(2)
651
649
 
652
- path = href.sub(%r{\Afile://}, "")
653
- path = CGI.unescape(path)
650
+ real = Clacky::Utils::EnvironmentDetector.resolve_local_path(href)
654
651
 
655
- ext = File.extname(path).downcase
652
+ ext = File.extname(real).downcase
656
653
  if LOCAL_VIDEO_EXTENSIONS.include?(ext) || LOCAL_AUDIO_EXTENSIONS.include?(ext)
657
- if File.exist?(path)
658
- "[#{text}](#{local_image_proxy_url(href, path)})"
654
+ if File.exist?(real)
655
+ "[#{text}](#{local_image_proxy_url(href, real)})"
659
656
  else
660
657
  _match
661
658
  end
@@ -237,6 +237,55 @@ module Clacky
237
237
  }
238
238
  },
239
239
 
240
+ # Kimi K3 flagship model (1M context, native vision, tool calling).
241
+ # Source: https://platform.moonshot.ai (USD / 1M tokens)
242
+ "kimi-k3" => {
243
+ input: {
244
+ default: 3.00, # $3.00/MTok cache miss
245
+ over_200k: 3.00
246
+ },
247
+ output: {
248
+ default: 15.00, # $15.00/MTok
249
+ over_200k: 15.00
250
+ },
251
+ cache: {
252
+ write: 3.00, # no separate write charge; bill at miss rate
253
+ read: 0.30 # $0.30/MTok cache hit
254
+ }
255
+ },
256
+
257
+ # Kimi K2.7 Code (256K context, multimodal coding model).
258
+ # Source: https://platform.moonshot.ai (USD / 1M tokens)
259
+ "kimi-k2.7-code" => {
260
+ input: {
261
+ default: 0.95, # $0.95/MTok cache miss
262
+ over_200k: 0.95
263
+ },
264
+ output: {
265
+ default: 4.00, # $4.00/MTok
266
+ over_200k: 4.00
267
+ },
268
+ cache: {
269
+ write: 0.95, # no separate write charge; bill at miss rate
270
+ read: 0.19 # $0.19/MTok cache hit
271
+ }
272
+ },
273
+
274
+ "kimi-k2.7-code-highspeed" => {
275
+ input: {
276
+ default: 1.90, # $1.90/MTok cache miss
277
+ over_200k: 1.90
278
+ },
279
+ output: {
280
+ default: 8.00, # $8.00/MTok
281
+ over_200k: 8.00
282
+ },
283
+ cache: {
284
+ write: 1.90, # no separate write charge; bill at miss rate
285
+ read: 0.38 # $0.38/MTok cache hit
286
+ }
287
+ },
288
+
240
289
  # Google Gemini 3 series (via Vertex AI). Tiered at 200K input tokens
241
290
  # for Pro; Flash has flat pricing.
242
291
  "gemini-3.1-pro" => {
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.4.0"
4
+ VERSION = "1.4.1"
5
5
  end
@@ -662,11 +662,15 @@ body {
662
662
  }
663
663
 
664
664
  /* ── New Session button ──────────────────────────────────────────────────── */
665
+ .btn-split-wrap {
666
+ display: flex;
667
+ align-items: stretch;
668
+ }
665
669
  .btn-split-main {
666
670
  height: 1.5rem;
667
- padding: 0 0.625rem;
671
+ padding: 0 0.6rem;
668
672
  border: none;
669
- border-radius: 6px;
673
+ border-radius: 6px 0 0 6px;
670
674
  background: var(--color-accent-primary);
671
675
  color: #fff;
672
676
  font-size: 0.75rem;
@@ -674,12 +678,85 @@ body {
674
678
  white-space: nowrap;
675
679
  line-height: 1;
676
680
  cursor: pointer;
681
+ display: flex;
682
+ align-items: center;
683
+ gap: 0.3rem;
677
684
  transition: background 0.15s, box-shadow 0.15s;
678
685
  }
679
686
  .btn-split-main:hover {
680
687
  background: var(--color-button-primary-hover);
681
688
  box-shadow: 0 2px 6px rgba(0,0,0,0.15);
682
689
  }
690
+ .btn-split-arrow {
691
+ display: flex;
692
+ align-items: center;
693
+ justify-content: center;
694
+ height: 1.5rem;
695
+ width: 1.375rem;
696
+ padding: 0;
697
+ border: none;
698
+ border-left: 1px solid rgba(255,255,255,0.2);
699
+ border-radius: 0 6px 6px 0;
700
+ background: var(--color-accent-primary);
701
+ color: #fff;
702
+ cursor: pointer;
703
+ transition: background 0.15s, box-shadow 0.15s;
704
+ flex-shrink: 0;
705
+ }
706
+ .btn-split-arrow:hover {
707
+ background: var(--color-button-primary-hover);
708
+ box-shadow: 0 2px 6px rgba(0,0,0,0.15);
709
+ }
710
+ .btn-split-dropdown {
711
+ position: relative;
712
+ display: flex;
713
+ }
714
+ .btn-split-menu {
715
+ display: none;
716
+ position: absolute;
717
+ top: calc(100% + 4px);
718
+ right: 0;
719
+ min-width: 9rem;
720
+ background: var(--color-bg-elevated, var(--color-bg-secondary));
721
+ border: 1px solid var(--color-border-secondary);
722
+ border-radius: 8px;
723
+ box-shadow: 0 4px 16px rgba(0,0,0,0.12);
724
+ padding: 0.25rem;
725
+ z-index: 200;
726
+ white-space: nowrap;
727
+ }
728
+ /* Bridge the 4px gap so hover doesn't break when moving to the menu */
729
+ .btn-split-menu::before {
730
+ content: "";
731
+ position: absolute;
732
+ top: -8px;
733
+ left: 0;
734
+ right: 0;
735
+ height: 8px;
736
+ }
737
+ .btn-split-dropdown:hover .btn-split-menu {
738
+ display: block;
739
+ }
740
+ .btn-split-menu-item {
741
+ display: flex;
742
+ align-items: center;
743
+ gap: 7px;
744
+ width: 100%;
745
+ padding: 0.375rem 0.75rem;
746
+ border: none;
747
+ border-radius: 5px;
748
+ background: transparent;
749
+ color: var(--color-text-secondary);
750
+ font-size: 0.8125rem;
751
+ font-family: var(--font-sans);
752
+ text-align: left;
753
+ cursor: pointer;
754
+ transition: background 0.12s, color 0.12s;
755
+ }
756
+ .btn-split-menu-item:hover {
757
+ background: var(--color-bg-tertiary);
758
+ color: var(--color-text-primary);
759
+ }
683
760
 
684
761
  /* ── Session source badge ────────────────────────────────────────────────── */
685
762
  /* Design: compact neutral chips — the visual weight is carried by the
@@ -7199,7 +7276,7 @@ body {
7199
7276
  .extension-card:hover { border-color: var(--color-text-muted); }
7200
7277
  .extension-card-main {
7201
7278
  display: flex;
7202
- align-items: stretch;
7279
+ align-items: center;
7203
7280
  gap: 0.75rem;
7204
7281
  }
7205
7282
  .extension-emoji {
@@ -7348,6 +7425,38 @@ body {
7348
7425
  letter-spacing: 0.04em;
7349
7426
  margin: 0 0 0.75rem;
7350
7427
  }
7428
+ /* Readme rendered Markdown */
7429
+ .extension-readme-body {
7430
+ font-size: 0.875rem;
7431
+ line-height: 1.7;
7432
+ color: var(--color-text-primary);
7433
+ overflow-wrap: break-word;
7434
+ }
7435
+ .extension-readme-body h1,
7436
+ .extension-readme-body h2,
7437
+ .extension-readme-body h3 { margin: 1em 0 0.4em; font-weight: 600; }
7438
+ .extension-readme-body h1 { font-size: 1.2em; }
7439
+ .extension-readme-body h2 { font-size: 1.05em; }
7440
+ .extension-readme-body h3 { font-size: 0.95em; }
7441
+ .extension-readme-body p { margin: 0 0 0.75em; }
7442
+ .extension-readme-body ul,
7443
+ .extension-readme-body ol { padding-left: 1.4em; margin: 0 0 0.75em; }
7444
+ .extension-readme-body pre {
7445
+ background: var(--color-bg-secondary);
7446
+ border-radius: 6px;
7447
+ padding: 0.75em 1em;
7448
+ overflow-x: auto;
7449
+ font-size: 0.8em;
7450
+ }
7451
+ .extension-readme-body code {
7452
+ background: var(--color-bg-secondary);
7453
+ padding: 0.1em 0.3em;
7454
+ border-radius: 3px;
7455
+ font-size: 0.85em;
7456
+ }
7457
+ .extension-readme-body pre code { background: none; padding: 0; }
7458
+ .extension-readme-body a { color: var(--color-accent); }
7459
+ .extension-readme-body img { max-width: 100%; height: auto; border-radius: 6px; display: block; margin: 8px 0; }
7351
7460
  .extension-detail-section { margin-bottom: 0.875rem; }
7352
7461
  .extension-detail-section-title {
7353
7462
  font-size: 0.6875rem;
@@ -11432,12 +11541,11 @@ body.setup-mode[data-theme="dark"] {
11432
11541
  color: var(--color-danger, #c33);
11433
11542
  }
11434
11543
  .mcp-tools-header {
11435
- font-size: 0.6875rem;
11544
+ font-size: 0.8125rem;
11436
11545
  font-weight: 600;
11437
11546
  color: var(--color-text-secondary);
11438
11547
  margin-bottom: 0.5rem;
11439
- text-transform: uppercase;
11440
- letter-spacing: 0.05em;
11548
+ letter-spacing: 0.01em;
11441
11549
  }
11442
11550
  .mcp-tool-list {
11443
11551
  list-style: none;
@@ -11449,10 +11557,9 @@ body.setup-mode[data-theme="dark"] {
11449
11557
  }
11450
11558
  .mcp-tool-item {
11451
11559
  display: flex;
11452
- align-items: baseline;
11453
- gap: 0.625rem;
11454
- font-size: 0.8125rem;
11455
- padding: 0.4375rem 0;
11560
+ flex-direction: column;
11561
+ gap: 0.3rem;
11562
+ padding: 0.625rem 0;
11456
11563
  border-bottom: 1px solid var(--color-border-primary, var(--color-border));
11457
11564
  }
11458
11565
  .mcp-tool-item:last-child {
@@ -11460,18 +11567,15 @@ body.setup-mode[data-theme="dark"] {
11460
11567
  }
11461
11568
  .mcp-tool-name {
11462
11569
  font-family: var(--font-mono);
11463
- background: var(--color-bg-elevated, var(--color-bg-secondary));
11464
- padding: 0.125rem 0.4375rem;
11465
- border-radius: 4px;
11466
- font-size: 0.75rem;
11570
+ font-size: 0.8125rem;
11571
+ font-weight: 600;
11467
11572
  color: var(--color-text-primary, var(--color-text));
11468
- flex-shrink: 0;
11469
- border: 1px solid var(--color-border-primary, var(--color-border));
11470
11573
  }
11471
11574
  .mcp-tool-desc {
11472
11575
  color: var(--color-text-secondary);
11473
- flex: 1;
11474
11576
  line-height: 1.5;
11577
+ font-size: 0.8125rem;
11578
+ opacity: 0.6;
11475
11579
  }
11476
11580
 
11477
11581
  /* ── MCP CTA banner & action buttons ─────────────────────────────────── */
@@ -17,6 +17,7 @@
17
17
  const Onboard = (() => {
18
18
  let _providers = [];
19
19
  let _selectedLang = I18n.lang(); // language chosen during setup
20
+ let _branded = false; // true when running under a brand license
20
21
 
21
22
  // ── Public API ──────────────────────────────────────────────────────────────
22
23
 
@@ -27,6 +28,7 @@ const Onboard = (() => {
27
28
  if (!data.needs_onboard) return { needsOnboard: false, phase: null };
28
29
 
29
30
  const phase = data.phase;
31
+ _branded = !!data.branded;
30
32
 
31
33
  if (phase === "key_setup") {
32
34
  // Mandatory: show full-screen setup panel, block boot.
@@ -111,9 +113,16 @@ const Onboard = (() => {
111
113
  $("setup-dot-1").className = "setup-step" + (step === "lang" ? " active" : " done");
112
114
  $("setup-dot-2").className = "setup-step" + (step === "key" ? " active" : "");
113
115
  if (step === "key") {
114
- $("setup-device-block").style.display = "";
115
- $("setup-manual-toggle").style.display = "";
116
- $("setup-manual-section").style.display = "none";
116
+ if (_branded) {
117
+ // Brand mode: skip the OpenClacky AI Keys card, go straight to manual config
118
+ $("setup-device-block").style.display = "none";
119
+ $("setup-manual-toggle").style.display = "none";
120
+ $("setup-manual-section").style.display = "";
121
+ } else {
122
+ $("setup-device-block").style.display = "";
123
+ $("setup-manual-toggle").style.display = "";
124
+ $("setup-manual-section").style.display = "none";
125
+ }
117
126
  }
118
127
  }
119
128
 
@@ -379,7 +388,8 @@ const Onboard = (() => {
379
388
  });
380
389
 
381
390
  $("setup-btn-back").addEventListener("click", () => {
382
- if ($("setup-manual-section").style.display !== "none") {
391
+ if (!_branded && $("setup-manual-section").style.display !== "none") {
392
+ // Non-brand: collapse manual section back to device card
383
393
  $("setup-device-block").style.display = "";
384
394
  $("setup-manual-toggle").style.display = "";
385
395
  $("setup-manual-section").style.display = "none";
@@ -58,7 +58,8 @@ const BackupView = (() => {
58
58
  a.click();
59
59
  a.remove();
60
60
  URL.revokeObjectURL(url);
61
- if (statusEl) { statusEl.textContent = I18n.t("settings.backup.downloaded"); statusEl.className = "model-test-result success"; }
61
+ if (statusEl) { statusEl.textContent = ""; statusEl.className = "model-test-result"; }
62
+ Modal.toast(I18n.t("settings.backup.downloaded"), "success");
62
63
  } else if (statusEl) {
63
64
  statusEl.textContent = I18n.t("settings.backup.lastError", { msg: res.error });
64
65
  statusEl.className = "model-test-result error";
@@ -108,8 +109,8 @@ const BackupView = (() => {
108
109
  if (ev.type !== "_ws_connected" || !_restoreStatusEl) return;
109
110
  const el = _restoreStatusEl;
110
111
  _restoreStatusEl = null;
111
- el.textContent = I18n.t("settings.backup.restartOk");
112
- el.className = "model-test-result success";
112
+ if (el) { el.textContent = ""; el.className = "model-test-result"; }
113
+ Modal.toast(I18n.t("settings.backup.restartOk"), "success");
113
114
  if (typeof Settings !== "undefined") Settings.open();
114
115
  });
115
116
  }