openclacky 1.3.4 → 1.3.5

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -0
  3. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  4. data/lib/clacky/agent/session_serializer.rb +3 -2
  5. data/lib/clacky/agent/tool_executor.rb +0 -12
  6. data/lib/clacky/agent.rb +74 -9
  7. data/lib/clacky/api_extension.rb +81 -0
  8. data/lib/clacky/api_extension_loader.rb +13 -1
  9. data/lib/clacky/client.rb +14 -17
  10. data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
  11. data/lib/clacky/default_agents/base_prompt.md +1 -0
  12. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  13. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  14. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  15. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  16. data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
  17. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  18. data/lib/clacky/json_ui_controller.rb +1 -1
  19. data/lib/clacky/media/base.rb +60 -0
  20. data/lib/clacky/media/dashscope.rb +385 -21
  21. data/lib/clacky/media/gemini.rb +9 -0
  22. data/lib/clacky/media/generator.rb +52 -0
  23. data/lib/clacky/media/openai_compat.rb +166 -0
  24. data/lib/clacky/null_ui_controller.rb +13 -0
  25. data/lib/clacky/plain_ui_controller.rb +1 -1
  26. data/lib/clacky/providers.rb +50 -2
  27. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  28. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  29. data/lib/clacky/server/http_server.rb +144 -9
  30. data/lib/clacky/server/session_registry.rb +4 -2
  31. data/lib/clacky/server/web_ui_controller.rb +3 -2
  32. data/lib/clacky/skill_loader.rb +14 -2
  33. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  34. data/lib/clacky/tools/terminal.rb +0 -43
  35. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  36. data/lib/clacky/ui2/ui_controller.rb +140 -31
  37. data/lib/clacky/ui_interface.rb +10 -1
  38. data/lib/clacky/utils/encoding.rb +25 -0
  39. data/lib/clacky/version.rb +1 -1
  40. data/lib/clacky/web/app.css +145 -22
  41. data/lib/clacky/web/components/onboard.js +1 -14
  42. data/lib/clacky/web/features/brand/view.js +8 -5
  43. data/lib/clacky/web/features/channels/store.js +1 -20
  44. data/lib/clacky/web/features/mcp/store.js +1 -20
  45. data/lib/clacky/web/features/profile/store.js +1 -13
  46. data/lib/clacky/web/features/profile/view.js +16 -4
  47. data/lib/clacky/web/features/skills/store.js +6 -21
  48. data/lib/clacky/web/features/version/store.js +2 -0
  49. data/lib/clacky/web/i18n.js +24 -1
  50. data/lib/clacky/web/index.html +15 -0
  51. data/lib/clacky/web/sessions.js +141 -51
  52. data/lib/clacky/web/settings.js +34 -2
  53. data/lib/clacky/web/ws-dispatcher.js +11 -3
  54. data/lib/clacky.rb +12 -5
  55. metadata +8 -1
@@ -17,7 +17,7 @@ module Clacky
17
17
  include Clacky::UIInterface
18
18
 
19
19
  attr_reader :layout, :renderer, :running, :inline_input, :input_area
20
- attr_accessor :config
20
+ attr_accessor :config, :available_models
21
21
 
22
22
  def initialize(config = {})
23
23
  @renderer = ViewRenderer.new
@@ -38,6 +38,7 @@ module Clacky
38
38
  @todo_area = Components::TodoArea.new
39
39
  @welcome_banner = Components::WelcomeBanner.new
40
40
  @inline_input = nil # Created when needed
41
+ @feedback_countdown = nil # Active auto-approve feedback countdown session
41
42
  @layout = LayoutManager.new(
42
43
  input_area: @input_area,
43
44
  todo_area: @todo_area
@@ -874,7 +875,7 @@ module Clacky
874
875
 
875
876
  # Show error message
876
877
  # @param message [String] Error message
877
- def show_error(message, code: nil, top_up_url: nil)
878
+ def show_error(message, code: nil, top_up_url: nil, raw_message: nil)
878
879
  output = @renderer.render_error(message)
879
880
  append_output(output)
880
881
  end
@@ -1054,6 +1055,99 @@ module Clacky
1054
1055
  end
1055
1056
  end
1056
1057
 
1058
+ # Auto-approve countdown for request_user_feedback: show a single live
1059
+ # countdown line. If the user presses any key before timeout, collect
1060
+ # their answer and return it (intervention). Otherwise return :timeout so
1061
+ # the agent auto-decides and continues.
1062
+ # @param seconds [Integer] Countdown duration
1063
+ # @return [String, Symbol] feedback string, "" (bare Enter), or :timeout
1064
+ # Show a live single-line countdown in the output area while keeping the
1065
+ # normal input box usable. The agent thread blocks here until either:
1066
+ # * the user submits a message in the regular input box -> returns the text
1067
+ # * the user starts typing (cancels the auto-timeout but keeps waiting)
1068
+ # * the countdown reaches zero with no interaction -> returns :timeout
1069
+ # @param seconds [Integer] Countdown duration
1070
+ # @return [String, Symbol] feedback text, or :timeout
1071
+ def request_feedback_with_countdown(seconds: 10)
1072
+ theme = ThemeManager.current_theme
1073
+
1074
+ queue = Queue.new
1075
+ entry_id = @layout.append_output(countdown_prompt(seconds, theme))
1076
+
1077
+ session = {
1078
+ queue: queue,
1079
+ entry_id: entry_id,
1080
+ intervened: false,
1081
+ watchdog: nil
1082
+ }
1083
+ @feedback_countdown = session
1084
+
1085
+ session[:watchdog] = Thread.new do
1086
+ remaining = seconds.to_i
1087
+ while remaining.positive?
1088
+ break if session[:intervened]
1089
+
1090
+ @layout.replace_entry(entry_id, countdown_prompt(remaining, theme))
1091
+ sleep 1
1092
+ remaining -= 1
1093
+ end
1094
+ queue.push(:timeout) unless session[:intervened]
1095
+ end
1096
+
1097
+ result = queue.pop
1098
+ session[:watchdog].kill if session[:watchdog]&.alive?
1099
+ @feedback_countdown = nil
1100
+ @layout.remove_entry(entry_id) if entry_id
1101
+ @layout.recalculate_layout
1102
+ @layout.render_all
1103
+
1104
+ if result == :timeout
1105
+ append_output(theme.format_text(" No response — continuing automatically.", :thinking))
1106
+ elsif result.to_s.strip.empty?
1107
+ append_output(theme.format_text(" → (continue)", :success))
1108
+ result = ""
1109
+ end
1110
+
1111
+ result
1112
+ end
1113
+
1114
+ # Cancel the countdown's auto-timeout when the user starts interacting,
1115
+ # but keep waiting for their submitted answer. Returns true if a
1116
+ # countdown was active.
1117
+ private def intervene_feedback_countdown
1118
+ session = @feedback_countdown
1119
+ return false unless session && !session[:intervened]
1120
+
1121
+ session[:intervened] = true
1122
+ session[:watchdog].kill if session[:watchdog]&.alive?
1123
+ if session[:entry_id]
1124
+ @layout.remove_entry(session[:entry_id])
1125
+ session[:entry_id] = nil
1126
+ @layout.recalculate_layout
1127
+ @layout.render_all
1128
+ end
1129
+ true
1130
+ end
1131
+
1132
+ private def countdown_prompt(remaining, theme)
1133
+ theme.format_text(" Auto-continuing in #{remaining}s — type your answer to step in…", :info)
1134
+ end
1135
+
1136
+ # Whether a keystroke should count as the user stepping into a countdown.
1137
+ # Plain typing and pastes do; pure scroll/navigation keys do not.
1138
+ private def countdown_intervening_key?(key)
1139
+ case key
1140
+ when Hash
1141
+ key[:type] == :rapid_input
1142
+ when String
1143
+ key.length >= 1 && key.ord >= 32
1144
+ when :backspace, :enter
1145
+ true
1146
+ else
1147
+ false
1148
+ end
1149
+ end
1150
+
1057
1151
  # Show diff between old and new content
1058
1152
  # @param old_content [String] Old content
1059
1153
  # @param new_content [String] New content
@@ -1206,7 +1300,7 @@ module Clacky
1206
1300
  # Check if API key is configured and show warning if missing
1207
1301
  private def check_api_key_configuration
1208
1302
  config = Clacky::AgentConfig.load
1209
-
1303
+
1210
1304
  if !config.models_configured?
1211
1305
  show_warning("No models configured! Please run /config to set up your models and API keys.")
1212
1306
  elsif config.api_key.nil? || config.api_key.empty?
@@ -1250,7 +1344,7 @@ module Clacky
1250
1344
  while @running
1251
1345
  # Process any pending resize events
1252
1346
  @layout.process_pending_resize
1253
-
1347
+
1254
1348
  key = @layout.screen.read_key(timeout: 0.1)
1255
1349
  next unless key
1256
1350
 
@@ -1291,6 +1385,13 @@ module Clacky
1291
1385
  return
1292
1386
  end
1293
1387
 
1388
+ # During an auto-approve feedback countdown the normal input box stays
1389
+ # live; the first meaningful keystroke cancels the auto-timeout so the
1390
+ # user can finish typing their answer without being rushed.
1391
+ if @feedback_countdown && !@feedback_countdown[:intervened] && countdown_intervening_key?(key)
1392
+ intervene_feedback_countdown
1393
+ end
1394
+
1294
1395
  result = @input_area.handle_key(key)
1295
1396
 
1296
1397
  # Handle height change first
@@ -1416,6 +1517,14 @@ module Clacky
1416
1517
  append_output(output)
1417
1518
  end
1418
1519
 
1520
+ # If an auto-approve feedback countdown is waiting for an answer, this
1521
+ # submission IS that answer — deliver it to the waiting agent thread
1522
+ # instead of starting a brand-new turn.
1523
+ if (session = @feedback_countdown)
1524
+ session[:queue].push(data[:text].to_s)
1525
+ return
1526
+ end
1527
+
1419
1528
  # Then call callback (allows interrupting previous agent before processing new input)
1420
1529
  @input_callback&.call(data[:text], data[:files])
1421
1530
  end
@@ -1425,31 +1534,31 @@ module Clacky
1425
1534
  # @return [Hash, nil] Hash with updated config values, or nil if cancelled
1426
1535
  public def show_config_modal(current_config, test_callback: nil)
1427
1536
  modal = Components::ModalComponent.new
1428
-
1537
+
1429
1538
  loop do
1430
1539
  # Build menu choices
1431
1540
  choices = []
1432
-
1541
+
1433
1542
  # Add model list
1434
1543
  current_config.models.each_with_index do |model, idx|
1435
1544
  is_current = (idx == current_config.current_model_index)
1436
1545
  model_name = model["model"] || "unnamed"
1437
1546
  masked_key = mask_api_key(model["api_key"])
1438
-
1547
+
1439
1548
  # Add type badge if present
1440
1549
  type_badge = case model["type"]
1441
1550
  when "default" then "[default] "
1442
1551
  when "lite" then "[lite] "
1443
1552
  else ""
1444
1553
  end
1445
-
1554
+
1446
1555
  display_name = "#{type_badge}#{model_name} (#{masked_key})"
1447
1556
  choices << {
1448
1557
  name: display_name,
1449
1558
  value: { action: :switch, model_id: model["id"] }
1450
1559
  }
1451
1560
  end
1452
-
1561
+
1453
1562
  # Add action buttons
1454
1563
  choices << { name: "─" * 50, disabled: true }
1455
1564
  choices << { name: "[+] Add New Model", value: { action: :add } }
@@ -1458,16 +1567,16 @@ module Clacky
1458
1567
  choices << { name: "[-] Delete Model", value: { action: :delete } } if current_config.models.length > 1
1459
1568
  end
1460
1569
  choices << { name: "[X] Close", value: { action: :close } }
1461
-
1570
+
1462
1571
  # Show menu
1463
1572
  result = modal.show(
1464
- title: "Model Configuration",
1573
+ title: "Model Configuration",
1465
1574
  choices: choices,
1466
1575
  on_close: -> { @layout.rerender_all }
1467
1576
  )
1468
-
1577
+
1469
1578
  return nil if result.nil?
1470
-
1579
+
1471
1580
  case result[:action]
1472
1581
  when :switch
1473
1582
  # Just signal the caller which model to switch to.
@@ -1699,16 +1808,16 @@ module Clacky
1699
1808
 
1700
1809
  result # Return selected task_id or nil
1701
1810
  end
1702
-
1811
+
1703
1812
  # Show form for editing a model
1704
1813
  # @param model [Hash, nil] Existing model hash or nil for new model
1705
1814
  # @return [Hash, nil] Updated model hash or nil if cancelled
1706
1815
  private def show_model_edit_form(model, test_callback: nil)
1707
1816
  modal = Components::ModalComponent.new
1708
-
1817
+
1709
1818
  is_new = model.nil?
1710
1819
  model ||= {}
1711
-
1820
+
1712
1821
  # For new models, show provider selection first
1713
1822
  selected_provider = nil
1714
1823
  if is_new
@@ -1718,32 +1827,32 @@ module Clacky
1718
1827
  end
1719
1828
  provider_choices << { name: "─" * 40, disabled: true }
1720
1829
  provider_choices << { name: "Custom (manual configuration)", value: "custom" }
1721
-
1830
+
1722
1831
  # Show provider selection
1723
1832
  selected_provider = modal.show(
1724
1833
  title: "Select Provider",
1725
1834
  choices: provider_choices,
1726
1835
  on_close: -> { @layout.rerender_all }
1727
1836
  )
1728
-
1837
+
1729
1838
  # User cancelled
1730
1839
  return nil if selected_provider.nil?
1731
1840
  end
1732
-
1841
+
1733
1842
  # Prepare masked API key for display
1734
1843
  masked_key = mask_api_key(model["api_key"])
1735
-
1844
+
1736
1845
  # Pre-fill values from provider preset if selected
1737
1846
  provider_preset = nil
1738
1847
  if selected_provider && selected_provider != "custom"
1739
1848
  provider_preset = Clacky::Providers.get(selected_provider)
1740
1849
  end
1741
-
1850
+
1742
1851
  # Get default values from provider or existing model
1743
1852
  default_model = provider_preset ? provider_preset["default_model"] : model["model"]
1744
1853
  default_base_url = provider_preset ? provider_preset["base_url"] : model["base_url"]
1745
1854
  default_api_key = model["api_key"] || ""
1746
-
1855
+
1747
1856
  # Define fields
1748
1857
  fields = [
1749
1858
  {
@@ -1763,7 +1872,7 @@ module Clacky
1763
1872
  default: default_base_url || ""
1764
1873
  }
1765
1874
  ]
1766
-
1875
+
1767
1876
  # Create validator if test_callback provided
1768
1877
  validator = if test_callback
1769
1878
  lambda do |values|
@@ -1772,14 +1881,14 @@ module Clacky
1772
1881
  model_name = values[:model].to_s.empty? ? model["model"] : values[:model]
1773
1882
  base_url = values[:base_url].to_s.empty? ? model["base_url"] : values[:base_url]
1774
1883
  anthropic_format = model["anthropic_format"] # Not editable in form, use model's value
1775
-
1884
+
1776
1885
  test_config_values = {
1777
1886
  "api_key" => api_key,
1778
1887
  "model" => model_name,
1779
1888
  "base_url" => base_url,
1780
1889
  "anthropic_format" => anthropic_format
1781
1890
  }
1782
-
1891
+
1783
1892
  # For new models, require all fields
1784
1893
  if is_new
1785
1894
  if test_config_values["api_key"].to_s.empty?
@@ -1792,7 +1901,7 @@ module Clacky
1792
1901
  return { success: false, error: "Base URL is required" }
1793
1902
  end
1794
1903
  end
1795
-
1904
+
1796
1905
  # Create a temporary config for testing
1797
1906
  temp_config = Clacky::AgentConfig.new(models: [test_config_values], current_model_index: 0)
1798
1907
  test_callback.call(temp_config)
@@ -1800,7 +1909,7 @@ module Clacky
1800
1909
  else
1801
1910
  nil
1802
1911
  end
1803
-
1912
+
1804
1913
  # Determine modal title based on provider
1805
1914
  modal_title = if is_new && selected_provider && selected_provider != "custom"
1806
1915
  provider_name = Clacky::Providers.get(selected_provider)&.dig("name") || selected_provider
@@ -1810,7 +1919,7 @@ module Clacky
1810
1919
  else
1811
1920
  "Edit Model"
1812
1921
  end
1813
-
1922
+
1814
1923
  # Show modal and collect values
1815
1924
  result = modal.show(
1816
1925
  title: modal_title,
@@ -1818,9 +1927,9 @@ module Clacky
1818
1927
  validator: validator,
1819
1928
  on_close: -> { @layout.rerender_all }
1820
1929
  )
1821
-
1930
+
1822
1931
  return nil if result.nil?
1823
-
1932
+
1824
1933
  # Merge with existing model values or provider defaults
1825
1934
  {
1826
1935
  api_key: result[:api_key].to_s.empty? ? model["api_key"] : result[:api_key],
@@ -1829,7 +1938,7 @@ module Clacky
1829
1938
  provider: selected_provider
1830
1939
  }
1831
1940
  end
1832
-
1941
+
1833
1942
  # Mask API key for display
1834
1943
  private def mask_api_key(api_key)
1835
1944
  if api_key && !api_key.empty?
@@ -28,7 +28,7 @@ module Clacky
28
28
  # === Status messages ===
29
29
  def show_info(message, prefix_newline: true); end
30
30
  def show_warning(message); end
31
- def show_error(message, code: nil, top_up_url: nil); end
31
+ def show_error(message, code: nil, top_up_url: nil, raw_message: nil); end
32
32
  def show_success(message); end
33
33
  def log(message, level: :info); end
34
34
 
@@ -131,6 +131,15 @@ module Clacky
131
131
  # === Blocking interaction ===
132
132
  def request_confirmation(message, default: true); end
133
133
 
134
+ # Auto-approve countdown for request_user_feedback. Shows a live countdown
135
+ # and lets the user press a key to take over and answer. Returns :timeout
136
+ # when no one intervenes (agent should auto-decide and continue), or a
137
+ # feedback string / "" when the user steps in. Non-interactive UIs (web,
138
+ # json, channel) have no human watching a TTY, so they default to :timeout.
139
+ def request_feedback_with_countdown(seconds: 10)
140
+ :timeout
141
+ end
142
+
134
143
  # === Input control (CLI layer) ===
135
144
  def clear_input; end
136
145
  def set_input_tips(message, type: :info); end
@@ -75,6 +75,31 @@ module Clacky
75
75
  to_utf8(data)
76
76
  end
77
77
 
78
+ # Decode a raw PTY byte stream to valid UTF-8, auto-detecting the source
79
+ # encoding. UTF-8 is tried first (Linux/macOS and modern programs); when
80
+ # the bytes are not valid UTF-8 they are decoded as GBK/CP936 (Simplified
81
+ # Chinese Windows powershell.exe / cmd.exe default output); anything that
82
+ # still fails is scrubbed.
83
+ #
84
+ # MUST be called on complete byte runs — callers slice on "\n" (0x0A),
85
+ # which is never a trailing byte of a GBK or UTF-8 multibyte sequence, so
86
+ # a character is never split across the boundary.
87
+ #
88
+ # @param data [String, nil] raw PTY bytes (binary/ASCII-8BIT)
89
+ # @return [String] valid UTF-8 string
90
+ def self.pty_to_utf8(data)
91
+ return "" if data.nil? || data.empty?
92
+
93
+ s = data.dup.force_encoding("UTF-8")
94
+ return s if s.valid_encoding?
95
+
96
+ data.dup
97
+ .force_encoding("GBK")
98
+ .encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
99
+ rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
100
+ to_utf8(data)
101
+ end
102
+
78
103
  # Return an ASCII-safe UTF-8 copy of *str* suitable for security regex
79
104
  # pattern matching. Any byte that is not valid in the source encoding, or
80
105
  # that cannot be represented in UTF-8, is replaced with '?'. The
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.3.4"
4
+ VERSION = "1.3.5"
5
5
  end
@@ -650,38 +650,38 @@ body {
650
650
  position: relative;
651
651
  display: flex;
652
652
  align-items: center;
653
- border-radius: 5px;
653
+ border-radius: 6px;
654
654
  overflow: visible;
655
655
  }
656
656
  .btn-split-main {
657
- height: 1.375rem;
658
- padding: 0 0.5rem;
657
+ height: 1.5rem;
658
+ padding: 0 0.625rem;
659
659
  border: none;
660
- border-radius: 5px 0 0 5px;
660
+ border-radius: 6px 0 0 6px;
661
661
  background: var(--color-accent-primary);
662
662
  color: #fff;
663
- font-size: 0.6875rem;
663
+ font-size: 0.75rem;
664
664
  font-weight: 500;
665
665
  white-space: nowrap;
666
666
  line-height: 1;
667
667
  cursor: pointer;
668
- transition: background 0.15s, color 0.15s, box-shadow 0.15s;
668
+ transition: background 0.15s, box-shadow 0.15s;
669
669
  }
670
670
  .btn-split-main:hover {
671
671
  background: var(--color-button-primary-hover);
672
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
672
+ box-shadow: 0 2px 6px rgba(0,0,0,0.15);
673
673
  }
674
674
  .btn-split-arrow {
675
- height: 1.375rem;
676
- padding: 0 0.25rem;
675
+ height: 1.5rem;
676
+ padding: 0 0.35rem;
677
677
  border: none;
678
678
  border-left: 1px solid rgba(255,255,255,0.3);
679
- border-radius: 0 5px 5px 0;
679
+ border-radius: 0 6px 6px 0;
680
680
  background: var(--color-accent-primary);
681
681
  color: #fff;
682
- font-size: 0.625rem;
682
+ font-size: 0.75rem;
683
683
  cursor: pointer;
684
- transition: background 0.15s, color 0.15s;
684
+ transition: background 0.15s;
685
685
  }
686
686
  .btn-split-arrow:hover {
687
687
  background: var(--color-button-primary-hover);
@@ -690,7 +690,7 @@ body {
690
690
  /* ── Dropdown menu ───────────────────────────────────────────────────────── */
691
691
  .new-session-dropdown {
692
692
  position: absolute;
693
- top: calc(100% + 0.25rem);
693
+ top: 100%;
694
694
  right: 0;
695
695
  min-width: 11.25rem;
696
696
  background: var(--color-bg-card);
@@ -698,13 +698,13 @@ body {
698
698
  border-radius: 7px;
699
699
  box-shadow: var(--shadow-md);
700
700
  z-index: 200;
701
- padding: 0.25rem 0;
701
+ padding: 0.375rem 0 0.25rem;
702
702
  overflow: hidden;
703
703
  }
704
704
  .new-session-dropdown[hidden] { display: none; }
705
705
  .dropdown-item {
706
- padding: 0.25rem 0.625rem;
707
- font-size: 0.6875rem;
706
+ padding: 0.4rem 0.875rem;
707
+ font-size: 0.8125rem;
708
708
  color: var(--color-text-secondary);
709
709
  cursor: pointer;
710
710
  white-space: nowrap;
@@ -1799,7 +1799,7 @@ body {
1799
1799
  white-space: nowrap;
1800
1800
  transition: background .12s;
1801
1801
  }
1802
- .task-run-btn:hover { background: var(--color-accent-hover); }
1802
+ .task-run-btn:hover { background: var(--color-button-primary-hover); }
1803
1803
  .task-action-btn {
1804
1804
  display: inline-flex;
1805
1805
  align-items: center;
@@ -2456,6 +2456,36 @@ body {
2456
2456
  .msg-copy-btn { opacity: 0.65; }
2457
2457
  }
2458
2458
 
2459
+ /* ── Inline "start" button on unchecked task-list items ──────────────────── */
2460
+ .msg-assistant li.task-list-item { position: relative; }
2461
+ .msg-todo-spawn {
2462
+ display: inline-flex;
2463
+ align-items: center;
2464
+ margin-left: 0.5rem;
2465
+ padding: 0.05rem 0.5rem;
2466
+ font-size: 0.75rem;
2467
+ line-height: 1.4;
2468
+ border: 1px solid var(--color-border-primary);
2469
+ border-radius: 5px;
2470
+ background: var(--color-bg-primary);
2471
+ color: var(--color-text-secondary);
2472
+ cursor: pointer;
2473
+ opacity: 0;
2474
+ vertical-align: middle;
2475
+ transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
2476
+ }
2477
+ .msg-assistant:hover .msg-todo-spawn,
2478
+ .msg-todo-spawn:focus-visible { opacity: 1; }
2479
+ .msg-todo-spawn:hover {
2480
+ color: var(--color-text-primary);
2481
+ background: var(--color-bg-tertiary);
2482
+ border-color: var(--color-border-secondary);
2483
+ }
2484
+ .msg-todo-spawn:disabled { opacity: 0.5; cursor: default; }
2485
+ @media (hover: none) {
2486
+ .msg-todo-spawn { opacity: 0.7; }
2487
+ }
2488
+
2459
2489
  /* ── Markdown rendering inside assistant messages ────────────────────────── */
2460
2490
  .msg-assistant p:last-child { margin-bottom: 0; }
2461
2491
  .msg-assistant h1, .msg-assistant h2, .msg-assistant h3,
@@ -2690,7 +2720,7 @@ body {
2690
2720
  align-self: flex-start;
2691
2721
  display: flex;
2692
2722
  flex-direction: column;
2693
- gap: 0.75rem;
2723
+ gap: 0.5rem;
2694
2724
  }
2695
2725
  .msg-error .retry-btn {
2696
2726
  align-self: flex-start;
@@ -2721,6 +2751,56 @@ body {
2721
2751
  .msg-error a:hover {
2722
2752
  opacity: 0.8;
2723
2753
  }
2754
+ .error-raw-detail {
2755
+ margin-top: -0.125rem;
2756
+ font-size: 0.75rem;
2757
+ align-self: flex-start;
2758
+ max-width: 100%;
2759
+ }
2760
+ .error-raw-detail summary {
2761
+ cursor: pointer;
2762
+ font-size: 0.7rem;
2763
+ font-weight: 500;
2764
+ user-select: none;
2765
+ color: var(--color-error);
2766
+ opacity: 0.6;
2767
+ list-style: none;
2768
+ display: inline-flex;
2769
+ align-items: center;
2770
+ gap: 0.25rem;
2771
+ transition: opacity 0.15s;
2772
+ }
2773
+ .error-raw-detail summary::-webkit-details-marker { display: none; }
2774
+ .error-raw-detail summary::before {
2775
+ content: "";
2776
+ display: inline-block;
2777
+ width: 0;
2778
+ height: 0;
2779
+ border-top: 4px solid transparent;
2780
+ border-bottom: 4px solid transparent;
2781
+ border-left: 5px solid currentColor;
2782
+ transition: transform 0.15s;
2783
+ flex-shrink: 0;
2784
+ }
2785
+ .error-raw-detail[open] summary::before {
2786
+ transform: rotate(90deg);
2787
+ }
2788
+ .error-raw-detail summary:hover {
2789
+ opacity: 0.9;
2790
+ }
2791
+ .error-raw-detail pre {
2792
+ margin: 0.375rem 0 0;
2793
+ padding: 0.5rem 0.625rem;
2794
+ background: rgba(255, 59, 48, 0.05);
2795
+ border: 1px solid rgba(255, 59, 48, 0.12);
2796
+ border-radius: 5px;
2797
+ font-family: var(--font-mono, monospace);
2798
+ font-size: 0.7rem;
2799
+ line-height: 1.5;
2800
+ white-space: pre-wrap;
2801
+ word-break: break-all;
2802
+ color: var(--color-text-secondary);
2803
+ }
2724
2804
  .msg-success { color: var(--color-success); align-self: flex-start; font-size: 0.8125rem; }
2725
2805
  .tool-name { color: var(--color-warning); font-weight: 600; }
2726
2806
  .progress-msg { color: var(--color-accent-primary); font-size: 0.75rem; align-self: center; }
@@ -6929,10 +7009,10 @@ body {
6929
7009
  background: var(--color-bg-hover);
6930
7010
  }
6931
7011
  .code-editor-body .cm-activeLine {
6932
- background: var(--color-bg-hover);
7012
+ background: color-mix(in srgb, var(--color-bg-hover) 60%, transparent);
6933
7013
  }
6934
7014
  .code-editor-body .cm-selectionBackground {
6935
- background: color-mix(in srgb, var(--color-accent-primary) 20%, transparent) !important;
7015
+ background: color-mix(in srgb, var(--color-accent-primary) 40%, transparent) !important;
6936
7016
  }
6937
7017
  .code-editor-footer {
6938
7018
  display: flex;
@@ -10046,7 +10126,7 @@ body.setup-mode[data-theme="dark"] {
10046
10126
  border-right: 1px solid var(--color-border-primary);
10047
10127
  }
10048
10128
  .billing-period-btn:hover {
10049
- background: var(--color-bg-tertiary);
10129
+ background: var(--color-bg-hover);
10050
10130
  color: var(--color-text-primary);
10051
10131
  }
10052
10132
  .billing-period-btn.active {
@@ -10906,7 +10986,7 @@ body.setup-mode[data-theme="dark"] {
10906
10986
  white-space: nowrap;
10907
10987
  transition: opacity 0.15s ease, transform 0.05s ease;
10908
10988
  }
10909
- .btn-mcp-cta:hover { opacity: 0.85; }
10989
+ .btn-mcp-cta:hover { background: var(--color-button-primary-hover) }
10910
10990
  .btn-mcp-cta:active { transform: scale(0.98); }
10911
10991
  .btn-mcp-cta-large {
10912
10992
  padding: 0.625rem 1.125rem;
@@ -11019,6 +11099,49 @@ body.setup-mode[data-theme="dark"] {
11019
11099
  color: var(--color-error, #d33);
11020
11100
  }
11021
11101
 
11102
+ /* ── Accent Color Swatches ───────────────────────────────────────────── */
11103
+ .settings-accent-swatches {
11104
+ display: flex;
11105
+ flex-wrap: wrap;
11106
+ gap: 0.5rem;
11107
+ padding: 0.25rem 0;
11108
+ }
11109
+
11110
+ .settings-accent-swatch {
11111
+ width: 2.25rem;
11112
+ height: 2.25rem;
11113
+ border-radius: 50%;
11114
+ border: none;
11115
+ cursor: pointer;
11116
+ padding: 0;
11117
+ transition: transform 0.15s;
11118
+ outline: none;
11119
+ position: relative;
11120
+ }
11121
+
11122
+ .settings-accent-swatch:hover {
11123
+ transform: scale(1.15);
11124
+ }
11125
+
11126
+ .settings-accent-swatch.active::after {
11127
+ content: "";
11128
+ position: absolute;
11129
+ width: 0.375rem;
11130
+ height: 0.625rem;
11131
+ border-right: 0.125rem solid white;
11132
+ border-bottom: 0.125rem solid white;
11133
+ top: 46%;
11134
+ left: 50%;
11135
+ transform: translate(-50%, -50%) rotate(45deg);
11136
+ }
11137
+
11138
+ .swatch-indigo { background: #4f46e5; }
11139
+ .swatch-aurora-blue { background: #3B82F6; }
11140
+ .swatch-forest-green { background: #10B981; }
11141
+ .swatch-sunrise-orange { background: #F59E0B; }
11142
+ .swatch-rose-violet { background: #8B5CF6; }
11143
+ .swatch-coral-red { background: #EF4444; }
11144
+
11022
11145
  /* ── Sessions List ───────────────────────────────────────────────────── */
11023
11146
  .billing-sessions-row {
11024
11147
  margin-top: 1rem;