openclacky 1.3.3 → 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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/docs/rich_ui_guide.md +277 -0
  4. data/docs/rich_ui_refactor_plan.md +396 -0
  5. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  6. data/lib/clacky/agent/llm_caller.rb +10 -4
  7. data/lib/clacky/agent/session_serializer.rb +5 -3
  8. data/lib/clacky/agent/tool_executor.rb +0 -12
  9. data/lib/clacky/agent.rb +77 -11
  10. data/lib/clacky/agent_config.rb +2 -14
  11. data/lib/clacky/api_extension.rb +343 -0
  12. data/lib/clacky/api_extension_loader.rb +168 -0
  13. data/lib/clacky/cli.rb +93 -3
  14. data/lib/clacky/client.rb +47 -25
  15. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  16. data/lib/clacky/default_agents/_panels/time_machine/panel.js +23 -1
  17. data/lib/clacky/default_agents/base_prompt.md +1 -0
  18. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  19. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  20. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  21. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +72 -6
  23. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  24. data/lib/clacky/idle_compression_timer.rb +3 -1
  25. data/lib/clacky/json_ui_controller.rb +1 -1
  26. data/lib/clacky/locales/en.rb +26 -0
  27. data/lib/clacky/locales/i18n.rb +26 -0
  28. data/lib/clacky/locales/zh.rb +26 -0
  29. data/lib/clacky/media/base.rb +60 -0
  30. data/lib/clacky/media/dashscope.rb +385 -21
  31. data/lib/clacky/media/gemini.rb +9 -0
  32. data/lib/clacky/media/generator.rb +52 -0
  33. data/lib/clacky/media/openai_compat.rb +166 -0
  34. data/lib/clacky/null_ui_controller.rb +13 -0
  35. data/lib/clacky/plain_ui_controller.rb +1 -1
  36. data/lib/clacky/providers.rb +50 -2
  37. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  38. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  39. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  40. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  41. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  42. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  43. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  44. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  45. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  46. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  47. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  48. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  49. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  50. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  51. data/lib/clacky/rich_ui.rb +57 -0
  52. data/lib/clacky/rich_ui_controller.rb +3 -1549
  53. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  54. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  55. data/lib/clacky/server/http_server.rb +291 -109
  56. data/lib/clacky/server/session_registry.rb +5 -3
  57. data/lib/clacky/server/web_ui_controller.rb +3 -2
  58. data/lib/clacky/shell_hook_loader.rb +1 -1
  59. data/lib/clacky/skill_loader.rb +14 -2
  60. data/lib/clacky/tools/edit.rb +14 -2
  61. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  62. data/lib/clacky/tools/terminal.rb +0 -43
  63. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  64. data/lib/clacky/ui2/ui_controller.rb +147 -31
  65. data/lib/clacky/ui_interface.rb +10 -1
  66. data/lib/clacky/utils/encoding.rb +25 -0
  67. data/lib/clacky/version.rb +1 -1
  68. data/lib/clacky/web/app.css +201 -81
  69. data/lib/clacky/web/app.js +65 -7
  70. data/lib/clacky/web/components/onboard.js +19 -16
  71. data/lib/clacky/web/core/aside.js +8 -3
  72. data/lib/clacky/web/core/ext.js +1 -1
  73. data/lib/clacky/web/features/brand/view.js +8 -5
  74. data/lib/clacky/web/features/channels/store.js +1 -20
  75. data/lib/clacky/web/features/mcp/store.js +1 -20
  76. data/lib/clacky/web/features/profile/store.js +1 -13
  77. data/lib/clacky/web/features/profile/view.js +16 -4
  78. data/lib/clacky/web/features/skills/store.js +36 -23
  79. data/lib/clacky/web/features/skills/view.js +32 -1
  80. data/lib/clacky/web/features/version/store.js +2 -0
  81. data/lib/clacky/web/features/workspace/view.js +1 -1
  82. data/lib/clacky/web/i18n.js +48 -13
  83. data/lib/clacky/web/index.html +24 -17
  84. data/lib/clacky/web/sessions.js +427 -79
  85. data/lib/clacky/web/settings.js +143 -113
  86. data/lib/clacky/web/ws-dispatcher.js +18 -6
  87. data/lib/clacky.rb +27 -5
  88. metadata +45 -2
  89. data/lib/clacky/media/output_dir.rb +0 -43
@@ -44,6 +44,7 @@ module Clacky
44
44
  error: nil,
45
45
  error_code: nil,
46
46
  top_up_url: nil,
47
+ raw_message: nil,
47
48
  updated_at: nil,
48
49
  agent: nil,
49
50
  ui: nil,
@@ -169,7 +170,7 @@ module Clacky
169
170
  live_name = s[:agent]&.name
170
171
  live_name = nil if live_name&.empty?
171
172
  live_cost_source = s[:agent]&.cost_source
172
- { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url],
173
+ { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url], raw_message: s[:raw_message],
173
174
  updated_at: s[:updated_at]&.iso8601,
174
175
  model: model_info&.dig(:model), model_id: model_info&.dig(:id), name: live_name,
175
176
  total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
@@ -269,7 +270,7 @@ module Clacky
269
270
  model_info = s[:agent]&.current_model_info
270
271
  live_name = s[:agent]&.name
271
272
  live_name = nil if live_name&.empty?
272
- { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url],
273
+ { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url], raw_message: s[:raw_message],
273
274
  updated_at: s[:updated_at]&.iso8601,
274
275
  model: model_info&.dig(:model), model_id: model_info&.dig(:id),
275
276
  name: live_name, total_tasks: s[:agent]&.total_tasks,
@@ -296,6 +297,7 @@ module Clacky
296
297
  error: ls ? ls[:error] : nil,
297
298
  error_code: ls&.dig(:error_code),
298
299
  top_up_url: ls&.dig(:top_up_url),
300
+ raw_message: ls&.dig(:raw_message),
299
301
  model: ls&.dig(:model),
300
302
  model_id: ls&.dig(:model_id),
301
303
  card_model: ls&.dig(:card_model),
@@ -440,7 +442,7 @@ module Clacky
440
442
 
441
443
  private def persist_and_release(id, session)
442
444
  agent = session[:agent]
443
- @session_manager&.save(agent.to_session_data(status: :success)) if agent
445
+ @session_manager&.save(agent.to_session_data(status: :success, preserve_updated_at: true)) if agent
444
446
 
445
447
  @mutex.synchronize do
446
448
  s = @sessions[id]
@@ -230,12 +230,13 @@ module Clacky
230
230
  @broadcaster.call(@session_id, event)
231
231
  end
232
232
 
233
- def show_error(message, code: nil, top_up_url: nil)
233
+ def show_error(message, code: nil, top_up_url: nil, raw_message: nil)
234
234
  payload = { message: message }
235
235
  payload[:code] = code if code
236
236
  payload[:top_up_url] = top_up_url if top_up_url
237
+ payload[:raw_message] = raw_message if raw_message
237
238
  emit("error", **payload)
238
- forward_to_subscribers { |sub| sub.show_error(message, code: code, top_up_url: top_up_url) }
239
+ forward_to_subscribers { |sub| sub.show_error(message, code: code, top_up_url: top_up_url, raw_message: raw_message) }
239
240
  end
240
241
 
241
242
  def show_success(message)
@@ -178,4 +178,4 @@ module Clacky
178
178
  base
179
179
  end
180
180
  end
181
- end
181
+ end
@@ -441,13 +441,11 @@ module Clacky
441
441
 
442
442
  # Load default skills from gem's default_skills directory
443
443
  private def load_default_skills
444
- # Get the gem's lib directory
445
444
  gem_lib_dir = File.expand_path("../", __dir__)
446
445
  default_skills_dir = File.join(gem_lib_dir, "clacky", "default_skills")
447
446
 
448
447
  return unless Dir.exist?(default_skills_dir)
449
448
 
450
- # Load each skill directory
451
449
  Dir.glob(File.join(default_skills_dir, "*/SKILL.md")).each do |skill_file|
452
450
  skill_dir = File.dirname(skill_file)
453
451
  skill_name = File.basename(skill_dir)
@@ -459,6 +457,20 @@ module Clacky
459
457
  @errors << "Failed to load default skill #{skill_name}: #{e.message}"
460
458
  end
461
459
  end
460
+
461
+ # Also load skills bundled inside default_extensions/*/skills/
462
+ ext_skills_dir = File.join(gem_lib_dir, "clacky", "default_extensions")
463
+ Dir.glob(File.join(ext_skills_dir, "*/skills/*/SKILL.md")).each do |skill_file|
464
+ skill_dir = File.dirname(skill_file)
465
+ skill_name = File.basename(skill_dir)
466
+
467
+ begin
468
+ skill = Skill.new(Pathname.new(skill_dir))
469
+ register_skill(skill, source: :default)
470
+ rescue StandardError => e
471
+ @errors << "Failed to load default skill #{skill_name}: #{e.message}"
472
+ end
473
+ end
462
474
  end
463
475
  end
464
476
  end
@@ -67,8 +67,20 @@ module Clacky
67
67
  }
68
68
  end
69
69
 
70
- # Perform replacement
71
- content = replace_all ? content.gsub(actual_old_string, new_string) : content.sub(actual_old_string, new_string)
70
+ # Perform literal replacement.
71
+ #
72
+ # NOTE: Use the block form (`sub(old) { new }`) instead of the
73
+ # two-arg form (`sub(old, new)`). The two-arg form interprets
74
+ # backslash escapes (\&, \1, \`, \', \\) in the replacement
75
+ # as sed-style backreferences, silently mangling literal
76
+ # backslashes and these escape sequences. The block form
77
+ # performs no such interpretation, matching the edit tool's
78
+ # literal-replacement contract.
79
+ content = if replace_all
80
+ content.gsub(actual_old_string) { new_string }
81
+ else
82
+ content.sub(actual_old_string) { new_string }
83
+ end
72
84
 
73
85
  File.write(path, content)
74
86
 
@@ -35,9 +35,7 @@ module Clacky
35
35
  def clean(raw)
36
36
  return "" if raw.nil? || raw.empty?
37
37
 
38
- s = raw.dup
39
- s.force_encoding(Encoding::UTF_8)
40
- s = s.scrub("?") unless s.valid_encoding?
38
+ s = Clacky::Utils::Encoding.pty_to_utf8(raw)
41
39
 
42
40
  s = s.gsub(CSI_REGEX, "")
43
41
  s = s.gsub(OSC_REGEX, "")
@@ -340,10 +340,6 @@ module Clacky
340
340
  project_root: cwd || Dir.pwd
341
341
  )
342
342
 
343
- # PowerShell 5 on Chinese Windows emits CP936/GBK by default; force
344
- # UTF-8 so our PTY (which decodes as UTF-8) doesn't see ??? bytes.
345
- safe_command = force_powershell_utf8(safe_command)
346
-
347
343
  # Background / dedicated path — never reuse the persistent shell,
348
344
  # because these commands stay running and would occupy the slot.
349
345
  if background
@@ -1522,45 +1518,6 @@ module Clacky
1522
1518
  return "" if lines.empty?
1523
1519
  lines.last(DISPLAY_TAIL_LINES).join("\n")
1524
1520
  end
1525
-
1526
- # PowerShell 5 on Chinese Windows defaults [Console]::OutputEncoding
1527
- # to CP936/GBK; our PTY decodes as UTF-8 so non-ASCII output becomes
1528
- # `???`. Inject UTF-8 setup into the user's PowerShell command so the
1529
- # shell emits UTF-8 bytes regardless of host locale.
1530
- POWERSHELL_PREAMBLE =
1531
- "[Console]::OutputEncoding=[Text.Encoding]::UTF8;"
1532
-
1533
- # Only rewrites simple `powershell[.exe]` / `pwsh[.exe]` invocations.
1534
- # Skips -File / -EncodedCommand / commands already handling encoding /
1535
- # pipelines (anything risky to splice).
1536
- private def force_powershell_utf8(command)
1537
- cmd = command.to_s
1538
- return command unless cmd =~ /\A\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\b/i
1539
- return command if cmd =~ /OutputEncoding/i
1540
- return command if cmd =~ /\s-(?:File|EncodedCommand|enc|f)\b/i
1541
-
1542
- # `-Command "..."` form: pipeline / chain characters inside the
1543
- # quoted body are PowerShell-internal, not shell-level, so we splice
1544
- # safely into the quoted string.
1545
- if (m = cmd.match(/\A(\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\s+(?:[^"'\s]+\s+)*?-(?:Command|c)\s+)(["'])(.*)\2(\s*(?:<\s*\S+\s*)?)\z/i))
1546
- head, quote, body, tail = m[1], m[2], m[3], m[4].to_s
1547
- return "#{head}#{quote}#{POWERSHELL_PREAMBLE}#{body}#{quote}#{tail}"
1548
- end
1549
-
1550
- # Outside the quoted-Command form, refuse to splice if there's any
1551
- # shell-level pipe / chain — too risky to get the boundaries right.
1552
- return command if cmd =~ /[|&;]/
1553
-
1554
- if (m = cmd.match(/\A(\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?))(.*)\z/i))
1555
- exe, rest = m[1], m[2].to_s.strip
1556
- return command if rest.start_with?("-") && rest !~ /\A-(?:Command|c)\b/i
1557
- rest = rest.sub(/\A-(?:Command|c)\b\s*/i, "")
1558
- inner = rest.empty? ? POWERSHELL_PREAMBLE.chomp(";") : "#{POWERSHELL_PREAMBLE}#{rest}"
1559
- return %Q{#{exe} -Command "#{inner}"}
1560
- end
1561
-
1562
- command
1563
- end
1564
1521
  end
1565
1522
  end
1566
1523
  end
@@ -326,7 +326,7 @@ module Clacky
326
326
  print "\e[?25l"
327
327
  return :cancelled
328
328
  when "\u0015" # Ctrl+U - clear line
329
- buffer = ''
329
+ buffer = String.new
330
330
  cursor_pos = 0
331
331
  else
332
332
  # Regular character input
@@ -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
@@ -47,6 +48,7 @@ module Clacky
47
48
  @input_callback = nil
48
49
  @interrupt_callback = nil
49
50
  @time_machine_callback = nil
51
+ @model_switch_callback = nil
50
52
  @tasks_count = 0
51
53
  @total_cost = 0.0
52
54
  @session_id = nil
@@ -192,6 +194,12 @@ module Clacky
192
194
  @time_machine_callback = block
193
195
  end
194
196
 
197
+ # Set callback for model switch (from /model slash command)
198
+ # @param block [Proc] Callback to execute on model switch
199
+ def on_model_switch(&block)
200
+ @model_switch_callback = block
201
+ end
202
+
195
203
  # Set agent for command suggestions
196
204
  # @param agent [Clacky::Agent] The agent instance with skill management
197
205
  # @param agent_profile [Clacky::AgentProfile, nil] Current agent profile for skill filtering
@@ -867,7 +875,7 @@ module Clacky
867
875
 
868
876
  # Show error message
869
877
  # @param message [String] Error message
870
- def show_error(message, code: nil, top_up_url: nil)
878
+ def show_error(message, code: nil, top_up_url: nil, raw_message: nil)
871
879
  output = @renderer.render_error(message)
872
880
  append_output(output)
873
881
  end
@@ -1047,6 +1055,99 @@ module Clacky
1047
1055
  end
1048
1056
  end
1049
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
+
1050
1151
  # Show diff between old and new content
1051
1152
  # @param old_content [String] Old content
1052
1153
  # @param new_content [String] New content
@@ -1199,7 +1300,7 @@ module Clacky
1199
1300
  # Check if API key is configured and show warning if missing
1200
1301
  private def check_api_key_configuration
1201
1302
  config = Clacky::AgentConfig.load
1202
-
1303
+
1203
1304
  if !config.models_configured?
1204
1305
  show_warning("No models configured! Please run /config to set up your models and API keys.")
1205
1306
  elsif config.api_key.nil? || config.api_key.empty?
@@ -1243,7 +1344,7 @@ module Clacky
1243
1344
  while @running
1244
1345
  # Process any pending resize events
1245
1346
  @layout.process_pending_resize
1246
-
1347
+
1247
1348
  key = @layout.screen.read_key(timeout: 0.1)
1248
1349
  next unless key
1249
1350
 
@@ -1284,6 +1385,13 @@ module Clacky
1284
1385
  return
1285
1386
  end
1286
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
+
1287
1395
  result = @input_area.handle_key(key)
1288
1396
 
1289
1397
  # Handle height change first
@@ -1409,6 +1517,14 @@ module Clacky
1409
1517
  append_output(output)
1410
1518
  end
1411
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
+
1412
1528
  # Then call callback (allows interrupting previous agent before processing new input)
1413
1529
  @input_callback&.call(data[:text], data[:files])
1414
1530
  end
@@ -1418,31 +1534,31 @@ module Clacky
1418
1534
  # @return [Hash, nil] Hash with updated config values, or nil if cancelled
1419
1535
  public def show_config_modal(current_config, test_callback: nil)
1420
1536
  modal = Components::ModalComponent.new
1421
-
1537
+
1422
1538
  loop do
1423
1539
  # Build menu choices
1424
1540
  choices = []
1425
-
1541
+
1426
1542
  # Add model list
1427
1543
  current_config.models.each_with_index do |model, idx|
1428
1544
  is_current = (idx == current_config.current_model_index)
1429
1545
  model_name = model["model"] || "unnamed"
1430
1546
  masked_key = mask_api_key(model["api_key"])
1431
-
1547
+
1432
1548
  # Add type badge if present
1433
1549
  type_badge = case model["type"]
1434
1550
  when "default" then "[default] "
1435
1551
  when "lite" then "[lite] "
1436
1552
  else ""
1437
1553
  end
1438
-
1554
+
1439
1555
  display_name = "#{type_badge}#{model_name} (#{masked_key})"
1440
1556
  choices << {
1441
1557
  name: display_name,
1442
1558
  value: { action: :switch, model_id: model["id"] }
1443
1559
  }
1444
1560
  end
1445
-
1561
+
1446
1562
  # Add action buttons
1447
1563
  choices << { name: "─" * 50, disabled: true }
1448
1564
  choices << { name: "[+] Add New Model", value: { action: :add } }
@@ -1451,16 +1567,16 @@ module Clacky
1451
1567
  choices << { name: "[-] Delete Model", value: { action: :delete } } if current_config.models.length > 1
1452
1568
  end
1453
1569
  choices << { name: "[X] Close", value: { action: :close } }
1454
-
1570
+
1455
1571
  # Show menu
1456
1572
  result = modal.show(
1457
- title: "Model Configuration",
1573
+ title: "Model Configuration",
1458
1574
  choices: choices,
1459
1575
  on_close: -> { @layout.rerender_all }
1460
1576
  )
1461
-
1577
+
1462
1578
  return nil if result.nil?
1463
-
1579
+
1464
1580
  case result[:action]
1465
1581
  when :switch
1466
1582
  # Just signal the caller which model to switch to.
@@ -1692,16 +1808,16 @@ module Clacky
1692
1808
 
1693
1809
  result # Return selected task_id or nil
1694
1810
  end
1695
-
1811
+
1696
1812
  # Show form for editing a model
1697
1813
  # @param model [Hash, nil] Existing model hash or nil for new model
1698
1814
  # @return [Hash, nil] Updated model hash or nil if cancelled
1699
1815
  private def show_model_edit_form(model, test_callback: nil)
1700
1816
  modal = Components::ModalComponent.new
1701
-
1817
+
1702
1818
  is_new = model.nil?
1703
1819
  model ||= {}
1704
-
1820
+
1705
1821
  # For new models, show provider selection first
1706
1822
  selected_provider = nil
1707
1823
  if is_new
@@ -1711,32 +1827,32 @@ module Clacky
1711
1827
  end
1712
1828
  provider_choices << { name: "─" * 40, disabled: true }
1713
1829
  provider_choices << { name: "Custom (manual configuration)", value: "custom" }
1714
-
1830
+
1715
1831
  # Show provider selection
1716
1832
  selected_provider = modal.show(
1717
1833
  title: "Select Provider",
1718
1834
  choices: provider_choices,
1719
1835
  on_close: -> { @layout.rerender_all }
1720
1836
  )
1721
-
1837
+
1722
1838
  # User cancelled
1723
1839
  return nil if selected_provider.nil?
1724
1840
  end
1725
-
1841
+
1726
1842
  # Prepare masked API key for display
1727
1843
  masked_key = mask_api_key(model["api_key"])
1728
-
1844
+
1729
1845
  # Pre-fill values from provider preset if selected
1730
1846
  provider_preset = nil
1731
1847
  if selected_provider && selected_provider != "custom"
1732
1848
  provider_preset = Clacky::Providers.get(selected_provider)
1733
1849
  end
1734
-
1850
+
1735
1851
  # Get default values from provider or existing model
1736
1852
  default_model = provider_preset ? provider_preset["default_model"] : model["model"]
1737
1853
  default_base_url = provider_preset ? provider_preset["base_url"] : model["base_url"]
1738
1854
  default_api_key = model["api_key"] || ""
1739
-
1855
+
1740
1856
  # Define fields
1741
1857
  fields = [
1742
1858
  {
@@ -1756,7 +1872,7 @@ module Clacky
1756
1872
  default: default_base_url || ""
1757
1873
  }
1758
1874
  ]
1759
-
1875
+
1760
1876
  # Create validator if test_callback provided
1761
1877
  validator = if test_callback
1762
1878
  lambda do |values|
@@ -1765,14 +1881,14 @@ module Clacky
1765
1881
  model_name = values[:model].to_s.empty? ? model["model"] : values[:model]
1766
1882
  base_url = values[:base_url].to_s.empty? ? model["base_url"] : values[:base_url]
1767
1883
  anthropic_format = model["anthropic_format"] # Not editable in form, use model's value
1768
-
1884
+
1769
1885
  test_config_values = {
1770
1886
  "api_key" => api_key,
1771
1887
  "model" => model_name,
1772
1888
  "base_url" => base_url,
1773
1889
  "anthropic_format" => anthropic_format
1774
1890
  }
1775
-
1891
+
1776
1892
  # For new models, require all fields
1777
1893
  if is_new
1778
1894
  if test_config_values["api_key"].to_s.empty?
@@ -1785,7 +1901,7 @@ module Clacky
1785
1901
  return { success: false, error: "Base URL is required" }
1786
1902
  end
1787
1903
  end
1788
-
1904
+
1789
1905
  # Create a temporary config for testing
1790
1906
  temp_config = Clacky::AgentConfig.new(models: [test_config_values], current_model_index: 0)
1791
1907
  test_callback.call(temp_config)
@@ -1793,7 +1909,7 @@ module Clacky
1793
1909
  else
1794
1910
  nil
1795
1911
  end
1796
-
1912
+
1797
1913
  # Determine modal title based on provider
1798
1914
  modal_title = if is_new && selected_provider && selected_provider != "custom"
1799
1915
  provider_name = Clacky::Providers.get(selected_provider)&.dig("name") || selected_provider
@@ -1803,7 +1919,7 @@ module Clacky
1803
1919
  else
1804
1920
  "Edit Model"
1805
1921
  end
1806
-
1922
+
1807
1923
  # Show modal and collect values
1808
1924
  result = modal.show(
1809
1925
  title: modal_title,
@@ -1811,9 +1927,9 @@ module Clacky
1811
1927
  validator: validator,
1812
1928
  on_close: -> { @layout.rerender_all }
1813
1929
  )
1814
-
1930
+
1815
1931
  return nil if result.nil?
1816
-
1932
+
1817
1933
  # Merge with existing model values or provider defaults
1818
1934
  {
1819
1935
  api_key: result[:api_key].to_s.empty? ? model["api_key"] : result[:api_key],
@@ -1822,7 +1938,7 @@ module Clacky
1822
1938
  provider: selected_provider
1823
1939
  }
1824
1940
  end
1825
-
1941
+
1826
1942
  # Mask API key for display
1827
1943
  private def mask_api_key(api_key)
1828
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.3"
4
+ VERSION = "1.3.5"
5
5
  end