openclacky 1.0.2 → 1.0.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/benchmark/fixtures/sample_project/Gemfile +3 -0
  4. data/benchmark/fixtures/sample_project/lib/api_handler.rb +32 -0
  5. data/benchmark/fixtures/sample_project/lib/order_calculator.rb +23 -0
  6. data/benchmark/fixtures/sample_project/lib/user_renderer.rb +20 -0
  7. data/benchmark/fixtures/sample_project/spec/order_calculator_spec.rb +20 -0
  8. data/benchmark/results/EVALUATION_REPORT.md +165 -0
  9. data/benchmark/results/baseline_20260511_174424.json +128 -0
  10. data/benchmark/results/report_20260511_175256.json +271 -0
  11. data/benchmark/results/report_20260511_175444.json +271 -0
  12. data/benchmark/results/treatment_20260511_175103.json +130 -0
  13. data/benchmark/runner.rb +441 -0
  14. data/docs/proposals/2026-05-11-system-prompt-alignment.md +325 -0
  15. data/docs/proposals/2026-05-12-memory-mechanism-optimization.md +89 -0
  16. data/lib/clacky/agent/cost_tracker.rb +8 -2
  17. data/lib/clacky/agent/llm_caller.rb +218 -0
  18. data/lib/clacky/agent/memory_updater.rb +41 -30
  19. data/lib/clacky/agent/message_compressor.rb +15 -4
  20. data/lib/clacky/agent/message_compressor_helper.rb +41 -2
  21. data/lib/clacky/agent/skill_manager.rb +5 -2
  22. data/lib/clacky/agent/skill_reflector.rb +10 -1
  23. data/lib/clacky/agent/tool_registry.rb +109 -0
  24. data/lib/clacky/agent.rb +20 -0
  25. data/lib/clacky/agent_config.rb +17 -0
  26. data/lib/clacky/cli.rb +65 -0
  27. data/lib/clacky/client.rb +15 -0
  28. data/lib/clacky/default_agents/base_prompt.md +20 -20
  29. data/lib/clacky/default_agents/coding/system_prompt.md +51 -1
  30. data/lib/clacky/default_skills/channel-setup/SKILL.md +113 -5
  31. data/lib/clacky/default_skills/channel-setup/import_lark_skills.rb +97 -0
  32. data/lib/clacky/default_skills/onboard/SKILL.md +1 -1
  33. data/lib/clacky/default_skills/persist-memory/SKILL.md +59 -0
  34. data/lib/clacky/providers.rb +48 -6
  35. data/lib/clacky/server/channel/adapters/weixin/adapter.rb +7 -0
  36. data/lib/clacky/server/channel/channel_manager.rb +91 -0
  37. data/lib/clacky/server/discover.rb +77 -0
  38. data/lib/clacky/server/epipe_safe_io.rb +105 -0
  39. data/lib/clacky/server/http_server.rb +121 -41
  40. data/lib/clacky/server/server_master.rb +6 -0
  41. data/lib/clacky/skill.rb +30 -0
  42. data/lib/clacky/utils/file_processor.rb +71 -0
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky/web/app.css +58 -22
  45. data/lib/clacky/web/i18n.js +4 -2
  46. data/lib/clacky/web/sessions.js +29 -17
  47. metadata +33 -2
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Clacky
6
+ module Server
7
+ # EPIPESafeIO — wraps an IO ($stdout / $stderr) so that writes never raise
8
+ # Errno::EPIPE / IOError to the calling code.
9
+ #
10
+ # Why this exists:
11
+ # The server worker process inherits fd 0/1/2 from the Master. If the
12
+ # Master itself was launched in a way where its stdout/stderr eventually
13
+ # becomes a broken pipe (e.g. launched by an installer that exits, or by
14
+ # a GUI/IDE process that closes its end), the worker's first `puts` after
15
+ # that pipe breaks raises Errno::EPIPE. Unhandled, this kills the worker
16
+ # — taking all in-memory sessions, agent loops, and SSE connections down
17
+ # with it, and triggering a crash loop because the new worker inherits
18
+ # the same broken fd.
19
+ #
20
+ # Behavior:
21
+ # - Healthy state: delegates every method to the underlying IO. Users
22
+ # see normal terminal output (banner, request logs, etc.).
23
+ # - First broken-pipe failure: catches Errno::EPIPE / IOError, swaps
24
+ # the underlying IO to /dev/null permanently, and returns silently.
25
+ # Subsequent writes succeed (into /dev/null) so the worker stays alive.
26
+ # - Session state, agent loops, SSE connections all preserved.
27
+ #
28
+ # Scope:
29
+ # We only wrap $stdout / $stderr (the global variables that Kernel#puts,
30
+ # Kernel#print, Kernel#warn, etc. use under the hood). We do NOT touch
31
+ # the STDOUT / STDERR constants — a codebase audit confirmed nothing in
32
+ # Clacky writes to those constants directly (only `STDOUT.flush` which
33
+ # cannot raise EPIPE).
34
+ class EPIPESafeIO < SimpleDelegator
35
+ # Methods that perform writes and may raise Errno::EPIPE.
36
+ # We override each one to rescue and degrade gracefully.
37
+ WRITE_METHODS = %i[write write_nonblock syswrite puts print printf << putc].freeze
38
+
39
+ WRITE_METHODS.each do |m|
40
+ define_method(m) do |*args, **kwargs, &blk|
41
+ if kwargs.empty?
42
+ __getobj__.public_send(m, *args, &blk)
43
+ else
44
+ __getobj__.public_send(m, *args, **kwargs, &blk)
45
+ end
46
+ rescue Errno::EPIPE, IOError => e
47
+ fall_back_to_null!(e)
48
+ # Retry the write into /dev/null so semantics (return value type) stay
49
+ # close to what the caller expects. If even this fails, swallow it —
50
+ # we must not raise from inside a write to $stdout/$stderr.
51
+ begin
52
+ if kwargs.empty?
53
+ __getobj__.public_send(m, *args, &blk)
54
+ else
55
+ __getobj__.public_send(m, *args, **kwargs, &blk)
56
+ end
57
+ rescue StandardError
58
+ nil
59
+ end
60
+ end
61
+ end
62
+
63
+ # Some callers do `$stdout.flush`. Make it safe too.
64
+ def flush
65
+ __getobj__.flush
66
+ rescue Errno::EPIPE, IOError => e
67
+ fall_back_to_null!(e)
68
+ nil
69
+ end
70
+
71
+ # Whether this wrapper has already fallen back to /dev/null.
72
+ # Useful for tests and diagnostics.
73
+ def fell_back?
74
+ @fell_back == true
75
+ end
76
+
77
+ private def fall_back_to_null!(error)
78
+ return if @fell_back
79
+
80
+ @fell_back = true
81
+ begin
82
+ # Best-effort: try to log once via Clacky::Logger if available.
83
+ # Wrapped in rescue because Logger itself might be mid-init.
84
+ if defined?(Clacky::Logger)
85
+ Clacky::Logger.warn(
86
+ "[EPIPESafeIO] Underlying IO broken (#{error.class}: #{error.message}); " \
87
+ "falling back to /dev/null. Worker stays alive."
88
+ )
89
+ end
90
+ rescue StandardError
91
+ # ignore
92
+ end
93
+
94
+ begin
95
+ null = File.open(File::NULL, "w")
96
+ null.sync = true
97
+ __setobj__(null)
98
+ rescue StandardError
99
+ # If even opening /dev/null fails, leave the original object — at
100
+ # worst the next write raises again and we rescue again.
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -57,7 +57,9 @@ module Clacky
57
57
  def show_assistant_message(content, files:)
58
58
  return if content.nil? || content.to_s.strip.empty?
59
59
 
60
- @events << { type: "assistant_message", session_id: @session_id, content: content }
60
+ # Rewrite local image paths to /api/local-image proxy URLs for browser rendering
61
+ rewritten = Utils::FileProcessor.rewrite_local_image_urls(content.to_s)
62
+ @events << { type: "assistant_message", session_id: @session_id, content: rewritten }
61
63
  end
62
64
 
63
65
  def show_tool_call(name, args)
@@ -203,15 +205,6 @@ module Clacky
203
205
 
204
206
  Clacky::Logger.info("[HttpServer PID=#{Process.pid}] start() mode=#{@inherited_socket ? 'worker' : 'standalone'} inherited_socket=#{@inherited_socket.inspect} master_pid=#{@master_pid.inspect}")
205
207
 
206
- # In standalone mode (no master), kill any stale server and manage our own PID file.
207
- # In worker mode the master owns the PID file; we just skip this block.
208
- if @inherited_socket.nil?
209
- kill_existing_server(@port)
210
- pid_file = File.join(Dir.tmpdir, "clacky-server-#{@port}.pid")
211
- File.write(pid_file, Process.pid.to_s)
212
- at_exit { File.delete(pid_file) if File.exist?(pid_file) }
213
- end
214
-
215
208
  # Expose server address and brand name to all child processes (skill scripts, shell commands, etc.)
216
209
  # so they can call back into the server without hardcoding the port,
217
210
  # and use the correct product name without re-reading brand.yml.
@@ -406,13 +399,20 @@ module Clacky
406
399
  when ["POST", "/api/tool/browser"] then api_tool_browser(req, res)
407
400
  when ["POST", "/api/upload"] then api_upload_file(req, res)
408
401
  when ["POST", "/api/open-file"] then api_open_file(req, res)
402
+ when ["GET", "/api/local-image"] then api_serve_local_image(req, res)
409
403
  when ["GET", "/api/version"] then api_get_version(res)
410
404
  when ["POST", "/api/version/upgrade"] then api_upgrade_version(req, res)
411
405
  when ["POST", "/api/restart"] then api_restart(req, res)
412
406
  when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
413
407
  when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
414
408
  else
415
- if method == "POST" && path.match?(%r{^/api/channels/[^/]+/test$})
409
+ if method == "POST" && path.match?(%r{^/api/channels/[^/]+/send$})
410
+ platform = path.sub("/api/channels/", "").sub("/send", "")
411
+ api_send_channel_message(platform, req, res)
412
+ elsif method == "GET" && path.match?(%r{^/api/channels/[^/]+/users$})
413
+ platform = path.sub("/api/channels/", "").sub("/users", "")
414
+ api_list_channel_users(platform, res)
415
+ elsif method == "POST" && path.match?(%r{^/api/channels/[^/]+/test$})
416
416
  platform = path.sub("/api/channels/", "").sub("/test", "")
417
417
  api_test_channel(platform, req, res)
418
418
  elsif method == "POST" && path.start_with?("/api/channels/")
@@ -1439,6 +1439,79 @@ module Clacky
1439
1439
  json_response(res, 200, { channels: platforms })
1440
1440
  end
1441
1441
 
1442
+ # POST /api/channels/:platform/send
1443
+ # Proactively send a message to a user via the given IM platform.
1444
+ #
1445
+ # Body:
1446
+ # { "message": "hello", # required
1447
+ # "user_id": "some_user_id" } # optional — defaults to most-recently active user
1448
+ #
1449
+ # Response:
1450
+ # 200 { ok: true }
1451
+ # 400 { ok: false, error: "..." } — missing/invalid params or platform not running
1452
+ # 503 { ok: false, error: "..." } — no known users (nobody has messaged the bot yet)
1453
+ #
1454
+ # Constraints:
1455
+ # - The platform adapter must be running (channel must be enabled + connected).
1456
+ # - For Weixin (iLink protocol), a context_token is required per message. This is
1457
+ # automatically looked up from the in-memory cache populated by inbound messages.
1458
+ # If no token exists for the target user (i.e. the user has never messaged the bot
1459
+ # in this server session), the message cannot be delivered.
1460
+ def api_send_channel_message(platform, req, res)
1461
+ platform = platform.to_sym
1462
+ body = parse_json_body(req)
1463
+ message = body["message"].to_s.strip
1464
+
1465
+ if message.empty?
1466
+ json_response(res, 400, { ok: false, error: "message is required" })
1467
+ return
1468
+ end
1469
+
1470
+ # Resolve target user_id
1471
+ user_id = body["user_id"].to_s.strip
1472
+ if user_id.empty?
1473
+ # Default to the most-recently active user for this platform
1474
+ known = @channel_manager.known_users(platform)
1475
+ if known.empty?
1476
+ json_response(res, 503, {
1477
+ ok: false,
1478
+ error: "No known users for :#{platform}. The user must send a message to the bot first."
1479
+ })
1480
+ return
1481
+ end
1482
+ user_id = known.last
1483
+ end
1484
+
1485
+ result = @channel_manager.send_to_user(platform, user_id, message)
1486
+ if result.nil?
1487
+ json_response(res, 400, {
1488
+ ok: false,
1489
+ error: "Failed to send message. The :#{platform} adapter may not be running, or no context_token is available for user #{user_id}."
1490
+ })
1491
+ else
1492
+ json_response(res, 200, { ok: true, platform: platform, user_id: user_id })
1493
+ end
1494
+ rescue StandardError => e
1495
+ json_response(res, 500, { ok: false, error: e.message })
1496
+ end
1497
+
1498
+ # GET /api/channels/:platform/users
1499
+ # Returns the list of known user IDs for the given platform.
1500
+ # These are users who have sent at least one message to the bot in this server session.
1501
+ #
1502
+ # For Weixin: returns users with a cached context_token (required for proactive messaging).
1503
+ # For Feishu / WeCom: returns user IDs extracted from channel session bindings.
1504
+ #
1505
+ # Response:
1506
+ # 200 { users: ["uid1", "uid2", ...] }
1507
+ def api_list_channel_users(platform, res)
1508
+ platform = platform.to_sym
1509
+ users = @channel_manager.known_users(platform)
1510
+ json_response(res, 200, { platform: platform, users: users })
1511
+ rescue StandardError => e
1512
+ json_response(res, 500, { ok: false, error: e.message })
1513
+ end
1514
+
1442
1515
  # POST /api/upload
1443
1516
  # Accepts a multipart/form-data file upload (field name: "file").
1444
1517
  # Runs the file through FileProcessor: saves original + generates structured
@@ -1485,6 +1558,43 @@ module Clacky
1485
1558
  json_response(res, 500, { ok: false, error: e.message })
1486
1559
  end
1487
1560
 
1561
+ # GET /api/local-image?path=file:///path/to/image.png
1562
+ # GET /api/local-image?path=/path/to/image.png
1563
+ #
1564
+ # Serves a local image file with the correct Content-Type.
1565
+ # Used by the Web UI to render local images that would otherwise be blocked
1566
+ # by the browser's security policy (file:// from http:// origin).
1567
+ #
1568
+ def api_serve_local_image(req, res)
1569
+ raw_path = URI.decode_www_form(req.query_string.to_s).to_h["path"].to_s
1570
+ return json_response(res, 400, { error: "path is required" }) if raw_path.empty?
1571
+
1572
+ # Strip file:// prefix if present
1573
+ path = raw_path.sub(%r{\Afile://}, "")
1574
+ path = CGI.unescape(path)
1575
+ path = File.expand_path(path)
1576
+
1577
+ # On WSL the file may be specified as a Windows path (e.g. "C:/Users/…").
1578
+ # Convert it to the Linux-side path so File.exist? works.
1579
+ path = Utils::EnvironmentDetector.win_to_linux_path(path)
1580
+
1581
+ # Security: only serve image files
1582
+ ext = File.extname(path).downcase
1583
+ unless Utils::FileProcessor::LOCAL_IMAGE_EXTENSIONS.include?(ext)
1584
+ return json_response(res, 403, { error: "not an image file" })
1585
+ end
1586
+
1587
+ return json_response(res, 404, { error: "file not found" }) unless File.exist?(path)
1588
+
1589
+ mime = Utils::FileProcessor::MIME_TYPES[ext] || "application/octet-stream"
1590
+ res.status = 200
1591
+ res["Content-Type"] = mime
1592
+ res["Cache-Control"] = "private, max-age=3600"
1593
+ res.body = File.binread(path)
1594
+ rescue => e
1595
+ json_response(res, 500, { error: e.message })
1596
+ end
1597
+
1488
1598
  # POST /api/channels/:platform
1489
1599
  # Body: { fields... } (platform-specific credential fields)
1490
1600
  # Saves credentials and optionally (re)starts the adapter.
@@ -3648,36 +3758,6 @@ module Clacky
3648
3758
  res.body = "Not Found"
3649
3759
  end
3650
3760
 
3651
- # Stop any previously running server on the given port via its PID file.
3652
- private def kill_existing_server(port)
3653
- pid_file = File.join(Dir.tmpdir, "clacky-server-#{port}.pid")
3654
- return unless File.exist?(pid_file)
3655
-
3656
- pid = File.read(pid_file).strip.to_i
3657
- return if pid <= 0
3658
- # After exec-restart, the new process inherits the same PID as the old one.
3659
- # Skip sending TERM to ourselves — we are already the new server.
3660
- if pid == Process.pid
3661
- Clacky::Logger.info("[Server] exec-restart detected (PID=#{pid}), skipping self-kill.")
3662
- return
3663
- end
3664
-
3665
- begin
3666
- Process.kill("TERM", pid)
3667
- Clacky::Logger.info("[Server] Stopped existing server (PID=#{pid}) on port #{port}.")
3668
- puts "Stopped existing server (PID: #{pid}) on port #{port}."
3669
- # Give it a moment to release the port
3670
- sleep 0.5
3671
- rescue Errno::ESRCH
3672
- Clacky::Logger.info("[Server] Existing server PID=#{pid} already gone.")
3673
- rescue Errno::EPERM
3674
- Clacky::Logger.warn("[Server] Could not stop existing server (PID=#{pid}) — permission denied.")
3675
- puts "Could not stop existing server (PID: #{pid}) — permission denied."
3676
- ensure
3677
- File.delete(pid_file) if File.exist?(pid_file)
3678
- end
3679
- end
3680
-
3681
3761
  # ── Inner classes ─────────────────────────────────────────────────────────
3682
3762
 
3683
3763
  # Wraps a raw TCP socket, providing thread-safe WebSocket frame sending.
@@ -143,10 +143,16 @@ module Clacky
143
143
 
144
144
  Clacky::Logger.info("[Master PID=#{Process.pid}] spawn: #{ruby} #{script} #{worker_argv.join(' ')}")
145
145
  Clacky::Logger.info("[Master PID=#{Process.pid}] env: #{env.inspect}")
146
+
146
147
  # pgroup: 0 puts worker in its own process group.
147
148
  # This lets Master send TERM/KILL to the entire group (-pid) on shutdown,
148
149
  # ensuring grandchildren (e.g. chrome-devtools-mcp node process) are also
149
150
  # cleaned up even if the worker is force-killed before its shutdown_proc runs.
151
+ #
152
+ # NOTE on stdio: we deliberately let the worker inherit Master's fd 0/1/2
153
+ # so users see startup banner / request logs in their terminal. Protection
154
+ # against Errno::EPIPE on broken parent stdout is installed inside the
155
+ # worker itself (see cli.rb worker entry — EPIPESafeIO wrapper).
150
156
  pid = spawn(env, ruby, script, *worker_argv, pgroup: 0)
151
157
  Clacky::Logger.info("[Master PID=#{Process.pid}] Spawned worker PID=#{pid} pgroup=#{pid}")
152
158
  pid
data/lib/clacky/skill.rb CHANGED
@@ -287,6 +287,36 @@ module Clacky
287
287
  end
288
288
  end
289
289
 
290
+ # Environment hint: if the skill references ${CLACKY_SERVER_HOST/PORT} but
291
+ # those vars were not injected (bare-CLI mode without a running server),
292
+ # the `${...}` placeholders will survive expansion as literal text. In that
293
+ # case append a non-fatal note so the LLM knows the skill's HTTP callbacks
294
+ # will not work, without blocking the skill entirely (the user may still
295
+ # want to read instructions, explore files, etc.).
296
+ if processed_content.match?(/\$\{CLACKY_SERVER_(HOST|PORT)\}/)
297
+ processed_content += <<~HINT
298
+
299
+
300
+ ---
301
+
302
+ > ⚠️ **Environment note (auto-injected)**: this skill calls back into the
303
+ > Clacky HTTP server (via `${CLACKY_SERVER_HOST}` / `${CLACKY_SERVER_PORT}`),
304
+ > but those variables are **not set** in the current process. That means
305
+ > no local Clacky server was detected.
306
+ >
307
+ > Any `curl http://${CLACKY_SERVER_HOST}:...` command in the steps above
308
+ > will fail with a DNS/connection error. Before running those steps you
309
+ > should either:
310
+ >
311
+ > 1. Ask the user to start the server in another terminal: `clacky server`
312
+ > (then retry — the CLI auto-detects it via `/tmp/clacky-master-*.pid`), or
313
+ > 2. If the task can be completed without the server API, skip those steps
314
+ > and tell the user which parts require the server.
315
+ >
316
+ > This is an informational hint, not an error. Proceed with judgment.
317
+ HINT
318
+ end
319
+
290
320
  processed_content
291
321
  end
292
322
 
@@ -523,8 +523,79 @@ module Clacky
523
523
  nil
524
524
  end
525
525
 
526
+ # Image extensions that can be inlined as data URLs in markdown content.
527
+ LOCAL_IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp].freeze
528
+
529
+ # Replace local image paths in markdown content with base64 data URLs.
530
+ #
531
+ # Handles both `file:///path/to/img.png` and bare `/path/to/img.png` in
532
+ # markdown image syntax `![alt](src)`.
533
+ #
534
+ # @param content [String] markdown text potentially containing local image references
535
+ # @return [String] content with local images replaced by data URLs
536
+ def self.inline_local_images(content)
537
+ return content if content.nil? || content.empty?
538
+
539
+ content.gsub(%r{(!\[[^\]]*\])\((file://)?(/[^)]+)\)}) do
540
+ prefix = $1
541
+ _scheme = $2
542
+ raw_path = $3
543
+ path = CGI.unescape(raw_path)
544
+ ext = File.extname(path).downcase
545
+ full_match = $&
546
+
547
+ unless LOCAL_IMAGE_EXTENSIONS.include?(ext) && File.exist?(path)
548
+ next full_match
549
+ end
550
+
551
+ begin
552
+ data_url = image_path_to_data_url(path)
553
+ Clacky::Logger.info("file_processor.inline_local_images", path: path, size: File.size(path))
554
+ "#{prefix}(#{data_url})"
555
+ rescue StandardError => e
556
+ Clacky::Logger.warn("file_processor.inline_local_images.failed", path: path, error: e.message)
557
+ full_match
558
+ end
559
+ end
560
+ end
561
+
526
562
  private_class_method :parse_zip_listing, :parse_tar_listing, :save_preview, :sanitize_filename,
527
563
  :downscale_png_chunky, :downscale_via_cli
564
+
565
+ # -------------------------------------------------------------------------
566
+ # Local image URL rewriting
567
+ # -------------------------------------------------------------------------
568
+
569
+ # Rewrite local image paths in markdown content to use the /api/local-image proxy.
570
+ #
571
+ # Matches two patterns inside `![alt](url)`:
572
+ # 1. file:// URLs → ![alt](/api/local-image?path=file:///abs/path.png)
573
+ # 2. bare absolute paths → ![alt](/api/local-image?path=/abs/path.png)
574
+ #
575
+ # https:// URLs and non-image files are left untouched.
576
+ #
577
+ # @param content [String, nil] markdown text
578
+ # @return [String, nil] rewritten content (or original if nothing matched)
579
+ def self.rewrite_local_image_urls(content)
580
+ return content if content.nil? || content.empty?
581
+
582
+ content.gsub(/!\[([^\]]*)\]\(((?:file:\/\/)?\/[^)]+)\)/) do |match|
583
+ alt = Regexp.last_match(1)
584
+ href = Regexp.last_match(2)
585
+
586
+ # Extract the filesystem path from the href
587
+ path = href.sub(%r{\Afile://}, "")
588
+ path = CGI.unescape(path)
589
+
590
+ ext = File.extname(path).downcase
591
+ if LOCAL_IMAGE_EXTENSIONS.include?(ext) && File.exist?(path)
592
+ encoded = CGI.escape(href)
593
+ "![#{alt}](/api/local-image?path=#{encoded})"
594
+ else
595
+ match # return original match unchanged
596
+ end
597
+ end
598
+ end
528
599
  end
529
600
  end
530
601
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.0.2"
4
+ VERSION = "1.0.4"
5
5
  end
@@ -854,69 +854,106 @@ body {
854
854
  display: none;
855
855
  align-items: center;
856
856
  justify-content: center;
857
- width: 18px;
858
- height: 18px;
857
+ width: 24px;
858
+ height: 24px;
859
859
  background: transparent;
860
860
  border: none;
861
- border-radius: 3px;
861
+ border-radius: 4px;
862
862
  color: var(--color-text-muted);
863
- font-size: 14px;
864
- line-height: 1;
865
863
  cursor: pointer;
866
864
  padding: 0;
867
- margin-top: -3px;
868
865
  transition: background .15s, color .15s;
869
- font-weight: bold;
870
- letter-spacing: -1px;
866
+ align-self: center;
871
867
  }
872
868
  .session-item:hover .session-actions-btn { display: flex; }
873
869
  .session-actions-btn:hover {
874
- background: var(--color-bg-hover);
870
+ background: var(--color-border-primary);
875
871
  color: var(--color-text-primary);
876
872
  }
877
873
 
878
874
  /* Pin icon in session name */
879
875
  .session-pin-icon {
880
876
  flex-shrink: 0;
881
- font-size: 11px;
882
- opacity: 0.7;
877
+ display: inline-flex;
878
+ align-items: center;
879
+ opacity: 0.6;
883
880
  margin-left: 2px;
881
+ color: var(--color-text-tertiary);
884
882
  }
885
883
  .session-item.active .session-pin-icon {
886
884
  opacity: 1;
885
+ color: var(--color-accent-primary);
887
886
  }
888
887
 
889
888
  /* Actions menu dropdown */
890
889
  .session-actions-menu {
891
890
  background: var(--color-bg-secondary);
892
891
  border: 1px solid var(--color-border-primary);
893
- border-radius: 6px;
894
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
895
- padding: 3px;
896
- min-width: 120px;
892
+ border-radius: 8px;
893
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04);
894
+ padding: 4px;
895
+ min-width: 160px;
897
896
  z-index: 1000;
898
897
  animation: fadeIn 0.15s ease;
899
898
  }
900
899
  [data-theme="dark"] .session-actions-menu {
901
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
900
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
902
901
  }
903
902
  .session-actions-menu-item {
903
+ display: flex;
904
+ align-items: center;
905
+ gap: 8px;
904
906
  padding: 6px 10px;
905
- border-radius: 4px;
907
+ border-radius: 5px;
906
908
  cursor: pointer;
907
909
  color: var(--color-text-primary);
908
- font-size: 12px;
909
- transition: background .15s;
910
+ font-size: 13px;
911
+ line-height: 1.4;
912
+ transition: background .12s ease, color .12s ease;
910
913
  user-select: none;
911
914
  }
915
+ .session-actions-menu-icon {
916
+ display: inline-flex;
917
+ align-items: center;
918
+ justify-content: center;
919
+ width: 14px;
920
+ height: 14px;
921
+ color: var(--color-text-secondary);
922
+ flex-shrink: 0;
923
+ }
924
+ .session-actions-menu-label {
925
+ flex: 1;
926
+ min-width: 0;
927
+ }
912
928
  .session-actions-menu-item:hover {
913
929
  background: var(--color-bg-hover);
914
930
  }
931
+ .session-actions-menu-item:hover .session-actions-menu-icon {
932
+ color: var(--color-text-primary);
933
+ }
934
+ .session-actions-menu-item--danger .session-actions-menu-icon {
935
+ color: var(--color-text-secondary);
936
+ }
915
937
  .session-actions-menu-item--danger {
916
- color: var(--color-error);
938
+ margin-top: 5px;
939
+ position: relative;
940
+ }
941
+ .session-actions-menu-item--danger::before {
942
+ content: "";
943
+ position: absolute;
944
+ left: 0;
945
+ right: 0;
946
+ top: -3px;
947
+ height: 1px;
948
+ background: var(--color-border-secondary);
949
+ pointer-events: none;
917
950
  }
918
951
  .session-actions-menu-item--danger:hover {
919
952
  background: var(--color-error-bg);
953
+ color: var(--color-error);
954
+ }
955
+ .session-actions-menu-item--danger:hover .session-actions-menu-icon {
956
+ color: var(--color-error);
920
957
  }
921
958
 
922
959
  @keyframes fadeIn {
@@ -1557,7 +1594,7 @@ body {
1557
1594
  .msg-user .msg-time { color: var(--color-text-secondary); right: 0; left: auto; padding-right: 4px; }
1558
1595
  .msg-assistant .msg-time { color: var(--color-text-secondary); left: 0; right: auto; padding-left: 4px; }
1559
1596
 
1560
- .msg-user { background: var(--color-accent-primary); color: var(--color-button-primary-text); align-self: flex-end; }
1597
+ .msg-user { background: var(--color-accent-primary); color: var(--color-button-primary-text); align-self: flex-end; white-space: pre-wrap; }
1561
1598
  [data-theme="dark"] .msg-user { background: var(--color-accent-hover); }
1562
1599
  .msg-assistant { background: var(--color-bg-tertiary); border: 1px solid var(--color-border-primary); align-self: flex-start; }
1563
1600
 
@@ -1610,7 +1647,6 @@ body {
1610
1647
  }
1611
1648
 
1612
1649
  /* ── Markdown rendering inside assistant messages ────────────────────────── */
1613
- .msg-assistant p { margin: 0 0 0.6em; }
1614
1650
  .msg-assistant p:last-child { margin-bottom: 0; }
1615
1651
  .msg-assistant h1, .msg-assistant h2, .msg-assistant h3,
1616
1652
  .msg-assistant h4, .msg-assistant h5, .msg-assistant h6 {
@@ -43,11 +43,13 @@ const I18n = (() => {
43
43
  "chat.history_load_failed": "Could not load history",
44
44
  "chat.history_start": "No more history",
45
45
  "chat.image_expired": "Expired",
46
- "chat.done": "Done — {{n}} iteration(s), ${{cost}}",
46
+ "chat.done": "Done — {{n}} iteration(s), {{cost}}",
47
47
  "chat.interrupted": "Interrupted.",
48
48
  "chat.feedback_hint": "Or type your own answer below ↓",
49
49
  "chat.newMessageHint": "New messages ↓",
50
50
  "chat.retry": "Retry",
51
+ "chat.resetSession": "Reset session",
52
+ "chat.resetSessionConfirm": "Reset will start a brand-new session. The current conversation history stays in your sidebar but will no longer be active. Continue?",
51
53
  "chat.copy": "Copy",
52
54
  "chat.copied": "Copied",
53
55
  "chat.empty.title": "Start the conversation",
@@ -541,7 +543,7 @@ const I18n = (() => {
541
543
  "chat.history_load_failed": "历史记录加载失败",
542
544
  "chat.history_start": "没有更多历史了",
543
545
  "chat.image_expired": "已过期",
544
- "chat.done": "完成 — {{n}} 步,${{cost}}",
546
+ "chat.done": "完成 — {{n}} 步,{{cost}}",
545
547
  "chat.interrupted": "已中断。",
546
548
  "chat.feedback_hint": "或在下方输入框自由作答 ↓",
547
549
  "chat.newMessageHint": "有新消息 ↓",