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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +5 -3
- data/lib/clacky/agent/tool_executor.rb +0 -12
- data/lib/clacky/agent.rb +77 -11
- data/lib/clacky/agent_config.rb +2 -14
- data/lib/clacky/api_extension.rb +343 -0
- data/lib/clacky/api_extension_loader.rb +168 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +47 -25
- data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +23 -1
- data/lib/clacky/default_agents/base_prompt.md +1 -0
- data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
- data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
- data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
- data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +72 -6
- data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
- data/lib/clacky/idle_compression_timer.rb +3 -1
- data/lib/clacky/json_ui_controller.rb +1 -1
- data/lib/clacky/locales/en.rb +26 -0
- data/lib/clacky/locales/i18n.rb +26 -0
- data/lib/clacky/locales/zh.rb +26 -0
- data/lib/clacky/media/base.rb +60 -0
- data/lib/clacky/media/dashscope.rb +385 -21
- data/lib/clacky/media/gemini.rb +9 -0
- data/lib/clacky/media/generator.rb +52 -0
- data/lib/clacky/media/openai_compat.rb +166 -0
- data/lib/clacky/null_ui_controller.rb +13 -0
- data/lib/clacky/plain_ui_controller.rb +1 -1
- data/lib/clacky/providers.rb +50 -2
- data/lib/clacky/rich_ui/components/base_component.rb +50 -0
- data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
- data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
- data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
- data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
- data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
- data/lib/clacky/rich_ui/components/status_view.rb +58 -0
- data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
- data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
- data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
- data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
- data/lib/clacky/rich_ui/view_renderer.rb +291 -0
- data/lib/clacky/rich_ui.rb +57 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1549
- data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
- data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
- data/lib/clacky/server/http_server.rb +291 -109
- data/lib/clacky/server/session_registry.rb +5 -3
- data/lib/clacky/server/web_ui_controller.rb +3 -2
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/skill_loader.rb +14 -2
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
- data/lib/clacky/tools/terminal.rb +0 -43
- data/lib/clacky/ui2/components/modal_component.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +147 -31
- data/lib/clacky/ui_interface.rb +10 -1
- data/lib/clacky/utils/encoding.rb +25 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +201 -81
- data/lib/clacky/web/app.js +65 -7
- data/lib/clacky/web/components/onboard.js +19 -16
- data/lib/clacky/web/core/aside.js +8 -3
- data/lib/clacky/web/core/ext.js +1 -1
- data/lib/clacky/web/features/brand/view.js +8 -5
- data/lib/clacky/web/features/channels/store.js +1 -20
- data/lib/clacky/web/features/mcp/store.js +1 -20
- data/lib/clacky/web/features/profile/store.js +1 -13
- data/lib/clacky/web/features/profile/view.js +16 -4
- data/lib/clacky/web/features/skills/store.js +36 -23
- data/lib/clacky/web/features/skills/view.js +32 -1
- data/lib/clacky/web/features/version/store.js +2 -0
- data/lib/clacky/web/features/workspace/view.js +1 -1
- data/lib/clacky/web/i18n.js +48 -13
- data/lib/clacky/web/index.html +24 -17
- data/lib/clacky/web/sessions.js +427 -79
- data/lib/clacky/web/settings.js +143 -113
- data/lib/clacky/web/ws-dispatcher.js +18 -6
- data/lib/clacky.rb +27 -5
- metadata +45 -2
- 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)
|
data/lib/clacky/skill_loader.rb
CHANGED
|
@@ -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
|
data/lib/clacky/tools/edit.rb
CHANGED
|
@@ -67,8 +67,20 @@ module Clacky
|
|
|
67
67
|
}
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
# Perform replacement
|
|
71
|
-
|
|
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
|
|
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
|
|
@@ -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?
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -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
|
data/lib/clacky/version.rb
CHANGED