openclacky 0.9.28 → 0.9.30

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/docs/deploy-architecture.md +619 -0
  4. data/lib/clacky/agent/llm_caller.rb +14 -2
  5. data/lib/clacky/agent/message_compressor.rb +24 -6
  6. data/lib/clacky/agent/message_compressor_helper.rb +17 -10
  7. data/lib/clacky/agent/session_serializer.rb +69 -0
  8. data/lib/clacky/agent/skill_manager.rb +2 -2
  9. data/lib/clacky/agent.rb +3 -0
  10. data/lib/clacky/brand_config.rb +29 -3
  11. data/lib/clacky/clacky_auth_client.rb +152 -0
  12. data/lib/clacky/clacky_cloud_config.rb +123 -0
  13. data/lib/clacky/cli.rb +13 -0
  14. data/lib/clacky/client.rb +21 -7
  15. data/lib/clacky/cloud_project_client.rb +169 -0
  16. data/lib/clacky/default_agents/base_prompt.md +1 -0
  17. data/lib/clacky/default_parsers/doc_parser.rb +9 -9
  18. data/lib/clacky/default_skills/browser-setup/SKILL.md +9 -0
  19. data/lib/clacky/default_skills/channel-setup/SKILL.md +21 -4
  20. data/lib/clacky/default_skills/channel-setup/feishu_setup.rb +8 -2
  21. data/lib/clacky/default_skills/deploy/SKILL.md +96 -5
  22. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +1268 -274
  23. data/lib/clacky/default_skills/deploy/tools/create_database_service.rb +341 -0
  24. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +72 -147
  25. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +60 -50
  26. data/lib/clacky/default_skills/deploy/tools/list_services.rb +47 -60
  27. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +147 -96
  28. data/lib/clacky/default_skills/new/SKILL.md +117 -5
  29. data/lib/clacky/default_skills/new/scripts/cloud_project_init.sh +74 -0
  30. data/lib/clacky/default_skills/new/scripts/create_rails_project.sh +32 -0
  31. data/lib/clacky/deploy_api_client.rb +484 -0
  32. data/lib/clacky/json_ui_controller.rb +16 -10
  33. data/lib/clacky/message_format/bedrock.rb +3 -2
  34. data/lib/clacky/message_history.rb +8 -0
  35. data/lib/clacky/plain_ui_controller.rb +1 -6
  36. data/lib/clacky/providers.rb +23 -4
  37. data/lib/clacky/server/browser_manager.rb +3 -1
  38. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +2 -1
  39. data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +3 -1
  40. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +5 -5
  41. data/lib/clacky/server/http_server.rb +12 -2
  42. data/lib/clacky/server/server_master.rb +43 -7
  43. data/lib/clacky/server/web_ui_controller.rb +17 -9
  44. data/lib/clacky/skill.rb +6 -2
  45. data/lib/clacky/tools/run_project.rb +4 -1
  46. data/lib/clacky/tools/shell.rb +7 -1
  47. data/lib/clacky/ui2/ui_controller.rb +1 -5
  48. data/lib/clacky/ui_interface.rb +5 -7
  49. data/lib/clacky/utils/arguments_parser.rb +22 -5
  50. data/lib/clacky/version.rb +1 -1
  51. data/lib/clacky/web/app.css +45 -5
  52. data/lib/clacky/web/app.js +126 -19
  53. data/lib/clacky/web/i18n.js +57 -0
  54. data/lib/clacky/web/sessions.js +108 -39
  55. data/lib/clacky/web/skills.js +8 -2
  56. data/lib/clacky.rb +3 -0
  57. metadata +8 -1
@@ -417,11 +417,11 @@ module Clacky
417
417
  r = text.dup
418
418
  r.gsub!(/```[^\n]*\n?([\s\S]*?)```/) { Regexp.last_match(1).strip }
419
419
  r.gsub!(/!\[[^\]]*\]\([^)]*\)/, "")
420
- r.gsub!(/\[([^\]]+)\]\([^)]*\)/, '')
421
- r.gsub!(/\*\*([^*]+)\*\*/, '')
422
- r.gsub!(/\*([^*]+)\*/, '')
423
- r.gsub!(/__([^_]+)__/, '')
424
- r.gsub!(/_([^_]+)_/, '')
420
+ r.gsub!(/\[([^\]]+)\]\([^)]*\)/, '\\1')
421
+ r.gsub!(/\*\*([^*]+)\*\*/, '\\1')
422
+ r.gsub!(/\*([^*]+)\*/, '\\1')
423
+ r.gsub!(/__([^_]+)__/, '\\1')
424
+ r.gsub!(/_([^_]+)_/, '\\1')
425
425
  r.gsub!(/^#+\s+/, "")
426
426
  r.gsub!(/^[-*_]{3,}\s*$/, "")
427
427
  r.strip
@@ -333,7 +333,13 @@ module Clacky
333
333
  # hosts with OPEN_TIMEOUT=8s per attempt × 2 attempts = up to ~16s on the
334
334
  # primary alone, before failing over to the fallback domain. Give them a
335
335
  # generous 90s so retry + failover can complete without being cut short.
336
- timeout_sec = path.start_with?("/api/brand") ? 90 : 10
336
+ timeout_sec = if path.start_with?("/api/brand")
337
+ 90
338
+ elsif path == "/api/tool/browser"
339
+ 30
340
+ else
341
+ 10
342
+ end
337
343
  Timeout.timeout(timeout_sec) do
338
344
  _dispatch_rest(req, res)
339
345
  end
@@ -723,6 +729,7 @@ module Clacky
723
729
  local_skills = brand.installed_brand_skills.map do |name, meta|
724
730
  {
725
731
  "name" => meta["name"] || name,
732
+ "name_zh" => meta["name_zh"].to_s,
726
733
  # Use locally cached description so it renders correctly offline
727
734
  "description" => meta["description"].to_s,
728
735
  "description_zh" => meta["description_zh"].to_s,
@@ -1385,7 +1392,9 @@ module Clacky
1385
1392
 
1386
1393
  entry = {
1387
1394
  name: skill.identifier,
1395
+ name_zh: skill.name_zh,
1388
1396
  description: skill.context_description,
1397
+ description_zh: skill.description_zh,
1389
1398
  source: source,
1390
1399
  enabled: !skill.disabled?,
1391
1400
  invalid: skill.invalid?,
@@ -1431,10 +1440,11 @@ module Clacky
1431
1440
  loader = agent.skill_loader
1432
1441
  loaded_from = loader.loaded_from
1433
1442
 
1434
- skill_data = skills.map do |skill|
1443
+ skill_data = skills.map do |skill|
1435
1444
  source_type = loaded_from[skill.identifier]
1436
1445
  {
1437
1446
  name: skill.identifier,
1447
+ name_zh: skill.name_zh,
1438
1448
  description: skill.description || skill.context_description,
1439
1449
  description_zh: skill.description_zh,
1440
1450
  encrypted: skill.encrypted?,
@@ -42,15 +42,30 @@ module Clacky
42
42
  end
43
43
 
44
44
  def run
45
- # 0. Print banner first before any log output
46
- print_banner
47
-
48
- # 1. Kill any existing master on this port before binding.
45
+ # 0. Kill any existing master on this port before binding.
49
46
  kill_existing_master
50
47
 
51
- # 2. Bind the socket once — master holds it for the entire lifetime.
52
- @socket = TCPServer.new(@host, @port)
48
+ # 1. Try to bind the socket.
49
+ # If port is 7070 (default), try fallback ports 7071-7075 if occupied.
50
+ # If port is non-default (user-specified), only try that exact port.
51
+ original_port = @port
52
+ max_port = (@port == 7070) ? (@port + 5) : @port
53
+ @socket = bind_with_fallback(@host, @port, max_port: max_port)
54
+
55
+ if @socket.nil?
56
+ if @port == 7070
57
+ Clacky::Logger.error("[Master] No available ports in range 7070-7075")
58
+ else
59
+ Clacky::Logger.error("[Master] Port #{@port} is in use")
60
+ end
61
+ exit(1)
62
+ end
63
+
53
64
  @socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
65
+ @port = @socket.local_address.ip_port # Update to actual bound port
66
+
67
+ # 2. Print banner after port is determined
68
+ print_banner(port_changed: @port != original_port, original_port: original_port)
54
69
 
55
70
  write_pid_file
56
71
 
@@ -221,12 +236,33 @@ module Clacky
221
236
  end
222
237
  end
223
238
 
224
- def print_banner
239
+ # Try to bind to preferred_port, fall back to next ports if occupied.
240
+ # Returns the bound TCPServer, or nil if all ports in range are occupied.
241
+ def bind_with_fallback(host, preferred_port, max_port:)
242
+ (preferred_port..max_port).each do |port|
243
+ begin
244
+ server = TCPServer.new(host, port)
245
+ Clacky::Logger.info("[Master] Bound to port #{port}") if port != preferred_port
246
+ return server
247
+ rescue Errno::EADDRINUSE
248
+ next
249
+ end
250
+ end
251
+ nil
252
+ end
253
+
254
+ def print_banner(port_changed: false, original_port: nil)
225
255
  banner = Clacky::Banner.new
226
256
  puts ""
227
257
  puts banner.colored_cli_logo
228
258
  puts banner.colored_tagline
229
259
  puts ""
260
+
261
+ if port_changed
262
+ puts " [!] Port #{original_port} is in use, using #{@port} instead"
263
+ puts ""
264
+ end
265
+
230
266
  puts " Web UI: #{banner.highlight("http://#{@host}:#{@port}")}"
231
267
  puts " Version: #{Clacky::VERSION}"
232
268
  puts " Press Ctrl-C to stop."
@@ -208,13 +208,25 @@ module Clacky
208
208
 
209
209
  # === Progress ===
210
210
 
211
- def show_progress(message = nil, prefix_newline: true, output_buffer: nil)
212
- @progress_start_time = Time.now
211
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
212
+ @progress_start_time = Time.now if phase == "active"
213
213
  @live_progress_message = message
214
214
  # Reset stdout buffer for each new command so re-subscribe only replays current run
215
- @live_stdout_buffer = []
216
- emit("progress", message: message, status: "start")
215
+ @live_stdout_buffer = [] if phase == "active"
216
+
217
+ data = {
218
+ message: message,
219
+ progress_type: progress_type,
220
+ phase: phase,
221
+ status: phase == "active" ? "start" : "stop" # backward compat
222
+ }
223
+ data[:metadata] = metadata unless metadata.empty?
224
+ data[:elapsed] = (Time.now - @progress_start_time).round(1) if phase == "done" && @progress_start_time
225
+
226
+ emit("progress", **data)
217
227
  forward_to_subscribers { |sub| sub.show_progress(message) }
228
+
229
+ @progress_start_time = nil if phase == "done"
218
230
  end
219
231
 
220
232
  # Stream shell stdout/stderr lines to the browser while a command is running.
@@ -230,14 +242,10 @@ module Clacky
230
242
  end
231
243
 
232
244
  def clear_progress
233
- elapsed = @progress_start_time ? (Time.now - @progress_start_time).round(1) : 0
234
- @progress_start_time = nil
235
- @live_progress_message = nil
236
245
  @live_tool_call = nil # command finished — nothing left to replay
237
246
  # Keep @live_stdout_buffer intact — it will be reset on the next show_progress call.
238
247
  # This allows a brief replay window even after the command finishes.
239
- emit("progress", status: "stop", elapsed: elapsed)
240
- forward_to_subscribers { |sub| sub.clear_progress }
248
+ show_progress(progress_type: "thinking", phase: "done")
241
249
  end
242
250
 
243
251
  # Replay in-progress command state to a newly (re-)subscribing browser tab.
data/lib/clacky/skill.rb CHANGED
@@ -12,6 +12,7 @@ module Clacky
12
12
  # Frontmatter fields that are recognized
13
13
  FRONTMATTER_FIELDS = %w[
14
14
  name
15
+ name_zh
15
16
  description
16
17
  description_zh
17
18
  disable-model-invocation
@@ -29,7 +30,7 @@ module Clacky
29
30
  ].freeze
30
31
 
31
32
  attr_reader :directory, :frontmatter, :source_path
32
- attr_reader :name, :description, :description_zh, :content
33
+ attr_reader :name, :description, :name_zh, :description_zh, :content
33
34
  attr_reader :disable_model_invocation, :user_invocable
34
35
  attr_reader :allowed_tools, :context, :agent_type, :argument_hint, :hooks
35
36
  attr_reader :fork_agent, :model, :forbidden_tools, :auto_summarize
@@ -273,6 +274,7 @@ module Clacky
273
274
  def to_h
274
275
  {
275
276
  name: identifier,
277
+ name_zh: @name_zh,
276
278
  description: context_description,
277
279
  directory: @directory.to_s,
278
280
  source_path: @source_path.to_s,
@@ -386,6 +388,7 @@ module Clacky
386
388
  if @cached_metadata
387
389
  @frontmatter = {}
388
390
  @name = @cached_metadata["name"]
391
+ @name_zh = @cached_metadata["name_zh"]
389
392
  @description = @cached_metadata["description"]
390
393
  @description_zh = @cached_metadata["description_zh"]
391
394
  @content = plain ? plain_file.read.then { |raw| extract_content_only(raw) } : nil
@@ -451,6 +454,7 @@ module Clacky
451
454
  # Pull known fields out of @frontmatter into instance variables.
452
455
  private def extract_fields_from_frontmatter
453
456
  @name = @frontmatter["name"]
457
+ @name_zh = @frontmatter["name_zh"]
454
458
  @description = @frontmatter["description"]
455
459
  @description_zh = @frontmatter["description_zh"]
456
460
  @disable_model_invocation = @frontmatter["disable-model-invocation"]
@@ -471,7 +475,7 @@ module Clacky
471
475
  # the skill is marked @invalid so the UI can display it greyed-out.
472
476
  def sanitize_frontmatter
473
477
  dir_slug = @directory.basename.to_s
474
- valid_slug = ->(s) { s.to_s.match?(/\A[a-z0-9][a-z0-9-]*\z/) }
478
+ valid_slug = ->(s) { s.to_s.match?(/\A[a-z0-9][a-z0-9_-]*\z/) }
475
479
 
476
480
  # --- name ---
477
481
  # Brand skills loaded via cached_metadata have their name pre-sanitized by
@@ -113,7 +113,10 @@ module Clacky
113
113
  stop_existing_process if @@process_state
114
114
 
115
115
  begin
116
- stdin, stdout, stderr, wait_thr = Open3.popen3(command)
116
+ # close_others: true prevents inheriting the server's listening socket (port 7070)
117
+ # when run_project is called from openclacky server. Without this, the spawned
118
+ # project server may inherit and hold the fd, causing port conflicts.
119
+ stdin, stdout, stderr, wait_thr = Open3.popen3(command, close_others: true)
117
120
 
118
121
  @@process_state = {
119
122
  stdin: stdin,
@@ -108,7 +108,13 @@ module Clacky
108
108
  # acquiring /dev/tty as a controlling terminal, which triggers SIGTTIN
109
109
  # when the process is not in the terminal's foreground group.
110
110
  # Instead, wrap_with_shell sources the user's rc file explicitly.
111
- popen3_opts = { pgroup: 0 }
111
+ #
112
+ # close_others: true prevents the child from inheriting file descriptors
113
+ # other than stdin/stdout/stderr. This is critical when running inside
114
+ # openclacky server — without it, user commands (rails s, npm run dev, etc.)
115
+ # inherit the server's listening socket (port 7070), causing port conflicts
116
+ # when the child process spawns its own server that persists after shell exit.
117
+ popen3_opts = { pgroup: 0, close_others: true }
112
118
  popen3_opts[:chdir] = working_dir if working_dir && Dir.exist?(working_dir)
113
119
 
114
120
  begin
@@ -466,8 +466,7 @@ module Clacky
466
466
  # Show progress indicator with dynamic elapsed time
467
467
  # @param message [String] Progress message (optional, will use random thinking verb if nil)
468
468
  # @param prefix_newline [Boolean] Whether to add a blank line before progress (default: true)
469
- # @param output_buffer [Hash, nil] Shared output buffer for real-time command output (optional)
470
- def show_progress(message = nil, prefix_newline: true, output_buffer: nil)
469
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
471
470
  # Stop any existing progress thread
472
471
  stop_progress_thread
473
472
 
@@ -476,7 +475,6 @@ module Clacky
476
475
 
477
476
  @progress_message = message || Clacky::THINKING_VERBS.sample
478
477
  @progress_start_time = Time.now
479
- @progress_output_buffer = output_buffer
480
478
  # Flag used by the progress thread to know when to stop gracefully.
481
479
  # Using a flag + join is safe because Thread#kill can interrupt a thread
482
480
  # while it holds @render_mutex, causing a permanent deadlock.
@@ -562,7 +560,6 @@ module Clacky
562
560
 
563
561
  # Signal thread to stop without joining — it will exit on next loop tick
564
562
  @progress_start_time = nil
565
- @progress_output_buffer = nil
566
563
  @stdout_lines = nil
567
564
  @progress_thread_stop = true
568
565
  # Detach: let the thread die on its own; we do NOT join here
@@ -594,7 +591,6 @@ module Clacky
594
591
  # @render_mutex) and leave the mutex permanently locked.
595
592
  def stop_progress_thread
596
593
  @progress_start_time = nil
597
- @progress_output_buffer = nil
598
594
  @progress_thread_stop = true
599
595
  if @progress_thread&.alive?
600
596
  # Join with a short timeout; fall back to kill only as a last resort
@@ -30,7 +30,11 @@ module Clacky
30
30
  def log(message, level: :info); end
31
31
 
32
32
  # === Progress ===
33
- def show_progress(message = nil, prefix_newline: true, output_buffer: nil); end
33
+ # Unified progress indicator with type-based display customization.
34
+ # progress_type: "thinking" | "retrying" | "idle_compress" | custom
35
+ # phase: "active" | "done"
36
+ # metadata: extensible hash (e.g., {attempt: 3, total: 10} for retries)
37
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}); end
34
38
  def clear_progress; end
35
39
 
36
40
  # === State updates ===
@@ -39,12 +43,6 @@ module Clacky
39
43
  def set_working_status; end
40
44
  def set_idle_status; end
41
45
 
42
- # === Idle compression status ===
43
- # Emits a two-phase idle compression status update.
44
- # phase: :start → show "Idle detected. Compressing..." (with spinner)
45
- # phase: :end → update same element with final result (skipped / compressed)
46
- def show_idle_status(phase:, message:); end
47
-
48
46
  # === Blocking interaction ===
49
47
  def request_confirmation(message, default: true); end
50
48
 
@@ -10,6 +10,16 @@ module Clacky
10
10
  # 1. Try standard parsing
11
11
  begin
12
12
  args = JSON.parse(call[:arguments], symbolize_names: true)
13
+
14
+ # Check if any key contains XML tags (< or >) indicating contamination
15
+ # Even though JSON.parse succeeded, the keys might be malformed
16
+ has_xml_contamination = args.keys.any? { |k| k.to_s.include?('<') || k.to_s.include?('>') }
17
+
18
+ if has_xml_contamination
19
+ # Force repair even though JSON.parse succeeded
20
+ raise JSON::ParserError.new("Keys contain XML contamination")
21
+ end
22
+
13
23
  return validate_required_params(call, args, tool_registry)
14
24
  rescue JSON::ParserError => e
15
25
  # Continue to repair
@@ -30,24 +40,31 @@ module Clacky
30
40
 
31
41
  # Simple JSON repair: complete brackets and quotes, and remove XML contamination
32
42
  def self.repair_json(json_str)
33
- result = json_str.strip
34
43
 
44
+ result = json_str.strip
45
+ # Step 0: Convert literal \n (backslash+n) to real newlines
46
+ result = result.gsub(/\\n/, "\n")
47
+ # Step 0.5: Unescape quotes in JSON keys and values (\" -> ")
48
+ # This handles cases like {"end_line\":550 or name=\"path\"
49
+ result = result.gsub(/\\"/, '"')
35
50
  # Step 1: Remove XML-style parameter tags that Claude might mix in
36
51
  # Pattern 1: </parameter> closing tags - remove completely
37
52
  result = result.gsub(/<\/parameter>/, '')
38
-
53
+
39
54
  # Pattern 2: <parameter name="key"> or <parameter name="key": opening tags -> convert to JSON key
40
55
  # Example: \n<parameter name="end_line"> 330 -> , "end_line": 330
41
56
  # Also handles: \n<parameter name="end_line": 330 -> , "end_line": 330
42
- result = result.gsub(/<parameter\s+name="([^"]+)":\s*/) { |match| ", \"#{$1}\": " }
43
- result = result.gsub(/<parameter\s+name="([^"]+)">/) { |match| ", \"#{$1}\":" }
44
-
57
+ # result = result.gsub(/<parameter\s+name="([^"\\]+)":\s*/) { |match| ", \"#{$1}\": " }
58
+ # result = result.gsub(/<parameter\s+name="([^"\\]+)">/) { |match| ", \"#{$1}\":" }
59
+ result = result.gsub(/<parameter\s+name=\\?"([^"\\]+)\\?"[>:]?\s*/) { |match| ", \"#{$1}\": " }
60
+
45
61
  # Pattern 3: Remove any remaining XML-like tags
46
62
  result = result.gsub(/<[^>]+>/, '')
47
63
 
48
64
  # Step 2: Clean up newlines with commas
49
65
  # Example: 315\n, "end_line" -> 315, "end_line"
50
66
  result = result.gsub(/\n\s*,/, ',')
67
+ result = result.gsub(/\n,/, ',')
51
68
  result = result.gsub(/,\s*\n/, ',')
52
69
 
53
70
  # Step 3: Clean up formatting issues
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.28"
4
+ VERSION = "0.9.30"
5
5
  end
@@ -793,13 +793,23 @@ body {
793
793
  .session-item .session-name {
794
794
  flex: 1;
795
795
  min-width: 0;
796
- overflow: hidden;
797
- text-overflow: ellipsis;
798
- white-space: nowrap;
796
+ display: flex;
797
+ align-items: center;
798
+ gap: 4px;
799
799
  color: var(--color-text-secondary);
800
800
  font-weight: 500;
801
801
  transition: color .2s ease, font-weight .2s ease;
802
802
  }
803
+ .session-item .session-name .session-name__text {
804
+ flex: 1;
805
+ min-width: 0;
806
+ overflow: hidden;
807
+ text-overflow: ellipsis;
808
+ white-space: nowrap;
809
+ }
810
+ .session-item .session-name .session-badge {
811
+ flex-shrink: 0;
812
+ }
803
813
  /* Hover: subtle background overlay */
804
814
  .session-item:hover {
805
815
  background: var(--color-bg-hover);
@@ -863,8 +873,6 @@ body {
863
873
  .session-name {
864
874
  font-size: 12px;
865
875
  white-space: nowrap;
866
- overflow: hidden;
867
- text-overflow: ellipsis;
868
876
  }
869
877
  /* While renaming, lift overflow so the input is fully visible */
870
878
  .session-name.renaming {
@@ -2004,6 +2012,14 @@ body {
2004
2012
  padding: 4px 0;
2005
2013
  }
2006
2014
 
2015
+ .skill-ac-empty {
2016
+ padding: 14px 16px;
2017
+ font-size: 13px;
2018
+ color: var(--color-text-tertiary, #888);
2019
+ text-align: left;
2020
+ font-style: italic;
2021
+ }
2022
+
2007
2023
  .skill-ac-item {
2008
2024
  display: flex;
2009
2025
  align-items: baseline;
@@ -2040,6 +2056,30 @@ body {
2040
2056
  color: var(--color-accent-primary);
2041
2057
  white-space: nowrap;
2042
2058
  flex-shrink: 0;
2059
+ display: flex;
2060
+ align-items: center;
2061
+ gap: 6px;
2062
+ }
2063
+
2064
+ .skill-ac-name-zh {
2065
+ font-family: inherit;
2066
+ font-size: 13px;
2067
+ font-weight: 600;
2068
+ color: var(--color-accent-primary);
2069
+ }
2070
+
2071
+ .skill-ac-name-id {
2072
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
2073
+ font-size: 11px;
2074
+ font-weight: 400;
2075
+ color: var(--color-text-secondary);
2076
+ opacity: 0.7;
2077
+ }
2078
+
2079
+ .skill-ac-highlight {
2080
+ background: rgba(255, 200, 0, 0.35);
2081
+ color: inherit;
2082
+ font-weight: inherit;
2043
2083
  }
2044
2084
 
2045
2085
  .skill-ac-desc {