openclacky 1.3.3 → 1.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +3 -2
- data/lib/clacky/agent.rb +3 -2
- data/lib/clacky/agent_config.rb +2 -14
- data/lib/clacky/api_extension.rb +262 -0
- data/lib/clacky/api_extension_loader.rb +156 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +38 -13
- data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +1 -1
- data/lib/clacky/default_skills/media-gen/SKILL.md +9 -6
- data/lib/clacky/idle_compression_timer.rb +3 -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/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/http_server.rb +150 -103
- data/lib/clacky/server/session_registry.rb +1 -1
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/ui2/ui_controller.rb +7 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +56 -59
- data/lib/clacky/web/app.js +65 -7
- data/lib/clacky/web/components/onboard.js +18 -2
- data/lib/clacky/web/core/aside.js +8 -3
- data/lib/clacky/web/core/ext.js +1 -1
- data/lib/clacky/web/features/skills/store.js +30 -2
- data/lib/clacky/web/features/skills/view.js +32 -1
- data/lib/clacky/web/features/workspace/view.js +1 -1
- data/lib/clacky/web/i18n.js +32 -20
- data/lib/clacky/web/index.html +9 -17
- data/lib/clacky/web/sessions.js +286 -28
- data/lib/clacky/web/settings.js +109 -111
- data/lib/clacky/web/ws-dispatcher.js +7 -3
- data/lib/clacky.rb +17 -2
- metadata +38 -2
- data/lib/clacky/media/output_dir.rb +0 -43
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_rich"
|
|
4
|
+
require_relative "../components/sidebar"
|
|
5
|
+
require_relative "../components/thinking_live_view"
|
|
6
|
+
require_relative "../components/status_view"
|
|
7
|
+
|
|
8
|
+
module Clacky
|
|
9
|
+
class RichAgentShell < RubyRich::AgentShell
|
|
10
|
+
attr_reader :thinking_live, :sidebar
|
|
11
|
+
attr_accessor :clacky_controller
|
|
12
|
+
attr_reader :callbacks
|
|
13
|
+
|
|
14
|
+
def build_layout
|
|
15
|
+
@sidebar = RichUI::RichSidebar.new
|
|
16
|
+
@thinking_live = RichUI::ThinkingLiveView.new(self)
|
|
17
|
+
@viewport.instance_variable_set(:@scrollbar, false)
|
|
18
|
+
@viewport.instance_variable_set(:@auto_copy, false)
|
|
19
|
+
@viewport.instance_variable_set(:@drag_mode, :selection)
|
|
20
|
+
|
|
21
|
+
# Patch Viewport#copy_selection to also clear the visual selection
|
|
22
|
+
# highlight. The upstream copy_selection copies text to the clipboard
|
|
23
|
+
# but leaves @selection_start / @selection_end intact, so the
|
|
24
|
+
# inverted-colour highlight survives both right-click and Ctrl+C.
|
|
25
|
+
vp = @viewport
|
|
26
|
+
vp.define_singleton_method(:copy_selection) do
|
|
27
|
+
text = @selected_text.to_s
|
|
28
|
+
return false if text.empty?
|
|
29
|
+
|
|
30
|
+
copy_to_clipboard(text)
|
|
31
|
+
@selection_start = nil
|
|
32
|
+
@selection_end = nil
|
|
33
|
+
@selected_text = ""
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
root = RubyRich::Layout.new(name: :root)
|
|
37
|
+
root.split_column(
|
|
38
|
+
RubyRich::Layout.new(name: :header, size: 1),
|
|
39
|
+
RubyRich::Layout.new(name: :body, ratio: 1),
|
|
40
|
+
RubyRich::Layout.new(name: :composer, size: 6),
|
|
41
|
+
RubyRich::Layout.new(name: :status, size: 1)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
main_area = RubyRich::Layout.new(name: :main, ratio: 1)
|
|
45
|
+
main_area.split_column(
|
|
46
|
+
RubyRich::Layout.new(name: :transcript, ratio: 1),
|
|
47
|
+
RubyRich::Layout.new(name: :thinking_live, size: 0)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
root[:body].split_row(
|
|
51
|
+
main_area,
|
|
52
|
+
RubyRich::Layout.new(name: :todos, size: 36)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
root[:header].content = RubyRich::AppShell::HeaderView.new(self)
|
|
56
|
+
root[:transcript].content = @viewport
|
|
57
|
+
root[:todos].content = @sidebar
|
|
58
|
+
root[:thinking_live].content = @thinking_live
|
|
59
|
+
root[:composer].content = RubyRich::AppShell::FramedView.new(@composer, title: "Composer", theme: @theme) { @composer.focused? }
|
|
60
|
+
root[:status].content = RichUI::RichStatusView.new(self)
|
|
61
|
+
root
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def attach_components
|
|
65
|
+
@viewport.attach(@layout[:transcript])
|
|
66
|
+
@transcript.attach(@layout[:transcript])
|
|
67
|
+
@composer.focus.attach(@layout[:composer])
|
|
68
|
+
|
|
69
|
+
@focus_manager
|
|
70
|
+
.register(:transcript, @layout[:transcript], RubyRich::AppShell::FocusTarget.new(@transcript, @viewport))
|
|
71
|
+
.register(:composer, @layout[:composer], @composer)
|
|
72
|
+
.attach(@layout)
|
|
73
|
+
@focus_manager.focus(:composer)
|
|
74
|
+
|
|
75
|
+
@layout.key(:ctrl_c, 1_000) do |_event, live|
|
|
76
|
+
live.stop if @stop_on_ctrl_c != false
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def attach_agent_controls
|
|
82
|
+
@composer.instance_variable_set(:@on_interrupt, nil)
|
|
83
|
+
# Register /model command
|
|
84
|
+
shell_ref = self
|
|
85
|
+
@composer.register_command(name: "/model", description: "Switch LLM model",
|
|
86
|
+
handler: -> { shell_ref.callbacks[:model_switch]&.call })
|
|
87
|
+
# Wire vim scroll callback: j/k in single-line normal mode scrolls transcript
|
|
88
|
+
@composer.instance_variable_set(:@on_vim_scroll, ->(delta) { @viewport.scroll_by(delta) })
|
|
89
|
+
# Inject Esc cancellation stack via singleton method on the Composer instance.
|
|
90
|
+
# This avoids both the Layout @event_intercepted bug and monkey-patch complexity.
|
|
91
|
+
native_escape = @composer.method(:escape)
|
|
92
|
+
shell = self
|
|
93
|
+
@composer.define_singleton_method(:escape) do
|
|
94
|
+
handled = shell.callbacks[:esc]&.call || false
|
|
95
|
+
handled ? nil : native_escape.call
|
|
96
|
+
end
|
|
97
|
+
# Clear Ctrl+C warning as soon as the user starts typing
|
|
98
|
+
native_insert = @composer.method(:insert_text)
|
|
99
|
+
@composer.define_singleton_method(:insert_text) do |text|
|
|
100
|
+
shell.callbacks[:clear_ctrlc]&.call
|
|
101
|
+
native_insert.call(text)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@layout.key(:ctrl_c, 2_000) do |_event, live|
|
|
105
|
+
handle_interrupt(live, self)
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
@layout.key(:ctrl_m, 2_000) do |_event, _live|
|
|
110
|
+
toggle_permission_mode
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Tab toggles permission mode (overrides FocusManager's focus cycling)
|
|
115
|
+
@layout.key(:tab, 600) do |_event, _live|
|
|
116
|
+
toggle_permission_mode
|
|
117
|
+
false
|
|
118
|
+
end
|
|
119
|
+
# Re-focus composer AFTER FocusManager (priority 500) has cycled focus.
|
|
120
|
+
# Also suppress Composer's own tab handler (priority 200) which would
|
|
121
|
+
# otherwise fire open_menu_if_available.
|
|
122
|
+
@layout.key(:tab, 100) do |_event, _live|
|
|
123
|
+
@composer.instance_variable_set(:@ignore_next_tab, true)
|
|
124
|
+
@focus_manager.focus(:composer)
|
|
125
|
+
false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Sidebar panel shortcuts (F1-F4)
|
|
129
|
+
@layout.key(:f1, 1_500) do |_event, _live|
|
|
130
|
+
@sidebar.set_mode(:work)
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
@layout.key(:f2, 1_500) do |_event, _live|
|
|
134
|
+
@sidebar.set_mode(:tasks)
|
|
135
|
+
false
|
|
136
|
+
end
|
|
137
|
+
@layout.key(:f3, 1_500) do |_event, _live|
|
|
138
|
+
@sidebar.set_mode(:auto)
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
@layout.key(:f4, 1_500) do |_event, _live|
|
|
142
|
+
@sidebar.set_mode(:context)
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def handle_interrupt(_live = nil, _source = nil)
|
|
148
|
+
return false if copy_viewport_selection
|
|
149
|
+
|
|
150
|
+
input_was_empty = @composer.value.to_s.empty?
|
|
151
|
+
@callbacks[:interrupt]&.call(input_was_empty: input_was_empty)
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def copy_viewport_selection
|
|
156
|
+
if @viewport.instance_variable_get(:@selecting)
|
|
157
|
+
@viewport.send(:stop_selection)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
@viewport.copy_selection
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def toggle_permission_mode
|
|
164
|
+
current = @callbacks[:mode_toggle] ? @mode : :confirm_safes
|
|
165
|
+
# Toggle between confirm_safes and confirm_all
|
|
166
|
+
new_mode = current.to_s == "confirm_all" ? "confirm_safes" : "confirm_all"
|
|
167
|
+
@mode = new_mode.to_sym
|
|
168
|
+
@callbacks[:mode_toggle]&.call(@mode)
|
|
169
|
+
@status = "mode · #{@mode}"
|
|
170
|
+
@focus_manager.focus(:composer)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def on_esc(&block)
|
|
174
|
+
@callbacks[:esc] = block
|
|
175
|
+
self
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private :build_layout,
|
|
179
|
+
:attach_components,
|
|
180
|
+
:attach_agent_controls,
|
|
181
|
+
:handle_interrupt,
|
|
182
|
+
:copy_viewport_selection
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "ruby_rich"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module RichUI
|
|
9
|
+
# ViewRenderer provides stateless formatting helpers extracted from
|
|
10
|
+
# RichUIController. All methods are module functions — callable as
|
|
11
|
+
# ViewRenderer.format_args(...)
|
|
12
|
+
# or mixin-able via `include ViewRenderer`.
|
|
13
|
+
module ViewRenderer
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# ── Tool output formatting ──────────────────────────────────
|
|
17
|
+
|
|
18
|
+
def format_tool_output(text, status = :done)
|
|
19
|
+
marker = status == :error ? "[Error]" : "[OK]"
|
|
20
|
+
color = status == :error ? :red : :green
|
|
21
|
+
clean = text.to_s.sub(/\A\[(?:OK|Error)\]\s*/, "")
|
|
22
|
+
"#{RubyRich::AnsiCode.color(color, true)}#{marker}#{RubyRich::AnsiCode.reset} #{clean}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def format_args(args)
|
|
26
|
+
data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
|
|
27
|
+
return data.to_s unless data.is_a?(Hash) && !data.empty?
|
|
28
|
+
|
|
29
|
+
data.map { |k, v| "#{k}: #{format_tool_value(v)}" }.join("\n")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def format_tool_value(v)
|
|
33
|
+
v.is_a?(String) ? v : JSON.generate(v)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def normalize_todo(todo)
|
|
37
|
+
case todo
|
|
38
|
+
when Hash
|
|
39
|
+
title = todo[:content] || todo["content"] || todo[:title] || todo["title"] || todo[:task] || todo["task"]
|
|
40
|
+
status = todo[:status] || todo["status"] || :pending
|
|
41
|
+
{ label: title.to_s, title: title.to_s, status: status.to_sym }
|
|
42
|
+
else
|
|
43
|
+
{ label: todo.to_s, title: todo.to_s, status: :pending }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# ── Tool activity label helpers ─────────────────────────────
|
|
48
|
+
|
|
49
|
+
def tool_activity_label(name, args)
|
|
50
|
+
tool_name = name.to_s
|
|
51
|
+
data = normalize_tool_args(args)
|
|
52
|
+
|
|
53
|
+
case tool_name
|
|
54
|
+
when "web_search"
|
|
55
|
+
query = data["query"].to_s
|
|
56
|
+
return tool_name if query.empty?
|
|
57
|
+
|
|
58
|
+
%(web_search("#{escape_tool_label(truncate_tool_label(query))}"))
|
|
59
|
+
when "web_fetch"
|
|
60
|
+
url = data["url"].to_s
|
|
61
|
+
return tool_name if url.empty?
|
|
62
|
+
|
|
63
|
+
"web_fetch(#{truncate_tool_label(tool_url_host(url))})"
|
|
64
|
+
else
|
|
65
|
+
compact = compact_tool_arg(data)
|
|
66
|
+
compact ? "#{tool_name}(#{compact})" : tool_name
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def normalize_tool_args(args)
|
|
71
|
+
parsed = if args.is_a?(String)
|
|
72
|
+
JSON.parse(args)
|
|
73
|
+
else
|
|
74
|
+
args
|
|
75
|
+
end
|
|
76
|
+
return {} unless parsed.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
parsed.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value }
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
{}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def compact_tool_arg(data)
|
|
84
|
+
key = %w[query url path file command pattern task].find { |candidate| data.key?(candidate) && !data[candidate].to_s.empty? }
|
|
85
|
+
return nil unless key
|
|
86
|
+
|
|
87
|
+
value = key == "url" ? tool_url_host(data[key].to_s) : data[key].to_s
|
|
88
|
+
escaped = escape_tool_label(truncate_tool_label(value))
|
|
89
|
+
value.match?(/\A[\w.-]+\z/) ? escaped : %("#{escaped}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def tool_url_host(url)
|
|
93
|
+
URI.parse(url).host || url
|
|
94
|
+
rescue URI::InvalidURIError
|
|
95
|
+
url
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def truncate_tool_label(text, limit = 40)
|
|
99
|
+
chars = text.to_s.each_char.to_a
|
|
100
|
+
return text.to_s if chars.length <= limit
|
|
101
|
+
|
|
102
|
+
"#{chars.first(limit - 3).join}..."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def escape_tool_label(text)
|
|
106
|
+
text.to_s.gsub("\\", "\\\\\\").gsub('"', '\"')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# ── Markdown helpers ────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def normalize_markdown_for_terminal(text)
|
|
112
|
+
text.to_s
|
|
113
|
+
.gsub(/\r\n?/, "\n")
|
|
114
|
+
.gsub(/\A[ \t]*\n+/, "")
|
|
115
|
+
.gsub(/\n+[ \t]*\z/, "")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def expand_ansi_multiline_spans(text)
|
|
119
|
+
active = +""
|
|
120
|
+
text.to_s.lines.map do |line|
|
|
121
|
+
body = line.chomp
|
|
122
|
+
prefix = body.start_with?("\e[") || active.empty? ? "" : active
|
|
123
|
+
body.scan(/\e\[[0-9;:]*m/).each do |code|
|
|
124
|
+
active = code == RubyRich::AnsiCode.reset ? +"" : code
|
|
125
|
+
end
|
|
126
|
+
suffix = !active.empty? && !body.end_with?(RubyRich::AnsiCode.reset) ? RubyRich::AnsiCode.reset : ""
|
|
127
|
+
"#{prefix}#{body}#{suffix}"
|
|
128
|
+
end.join("\n")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ── Diff / stats helpers ────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def parse_diff_stats(diff_text)
|
|
134
|
+
adds = 0
|
|
135
|
+
dels = 0
|
|
136
|
+
hunks = 0
|
|
137
|
+
diff_text.each_line do |line|
|
|
138
|
+
adds += 1 if line.start_with?("+") && !line.start_with?("+++")
|
|
139
|
+
dels += 1 if line.start_with?("-") && !line.start_with?("---")
|
|
140
|
+
hunks += 1 if line.start_with?("@@")
|
|
141
|
+
end
|
|
142
|
+
return "" if adds.zero? && dels.zero?
|
|
143
|
+
|
|
144
|
+
parts = []
|
|
145
|
+
parts << "+#{adds}" if adds.positive?
|
|
146
|
+
parts << "-#{dels}" if dels.positive?
|
|
147
|
+
parts << "#{hunks} hunks" if hunks.positive?
|
|
148
|
+
" (#{parts.join(", ")})"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def extract_thinking_and_content(content)
|
|
152
|
+
return ["", content.to_s] if content.nil?
|
|
153
|
+
|
|
154
|
+
thinking_parts = []
|
|
155
|
+
clean = content.to_s.dup
|
|
156
|
+
|
|
157
|
+
clean.gsub!(%r{<think(?:ing)?>\s*([\s\S]*?)\s*</think(?:ing)?>}mi) do
|
|
158
|
+
thinking_parts << Regexp.last_match(1).strip
|
|
159
|
+
""
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
clean = clean.gsub(/\n{3,}/, "\n\n").strip
|
|
163
|
+
[thinking_parts.join("\n\n"), clean]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# ── Config / dialog helpers ─────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def mask_api_key(api_key)
|
|
169
|
+
key = api_key.to_s
|
|
170
|
+
return "not set" if key.empty?
|
|
171
|
+
|
|
172
|
+
"#{key[0..5]}...#{key[-4..]}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def config_menu_choices(current_config)
|
|
176
|
+
choices = current_config.models.each_with_index.map do |model, index|
|
|
177
|
+
type_badge = case model["type"]
|
|
178
|
+
when "default" then "[default] "
|
|
179
|
+
when "lite" then "[lite] "
|
|
180
|
+
else ""
|
|
181
|
+
end
|
|
182
|
+
{
|
|
183
|
+
label: "#{type_badge}#{model["model"] || "unnamed"} (#{mask_api_key(model["api_key"])})",
|
|
184
|
+
value: { action: :switch, model_id: model["id"] },
|
|
185
|
+
current: index == current_config.current_model_index
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
choices + [
|
|
190
|
+
{ label: "─" * 50, disabled: true },
|
|
191
|
+
{ label: "[+] Add New Model", value: { action: :add } },
|
|
192
|
+
{ label: "[*] Edit Current Model", value: { action: :edit } },
|
|
193
|
+
(current_config.models.length > 1 ? { label: "[-] Delete Model", value: { action: :delete } } : nil),
|
|
194
|
+
{ label: "[X] Close", value: { action: :close } }
|
|
195
|
+
].compact
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def config_initial_selection(choices)
|
|
199
|
+
choices.index { |choice| choice[:current] } || choices.index { |choice| !choice[:disabled] } || 0
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def merge_model_form_values(result, model:, default_model:, default_base_url:)
|
|
203
|
+
{
|
|
204
|
+
api_key: result[:api_key].to_s.empty? ? model["api_key"] : result[:api_key],
|
|
205
|
+
model: result[:model].to_s.empty? ? (model["model"] || default_model) : result[:model],
|
|
206
|
+
base_url: result[:base_url].to_s.empty? ? (model["base_url"] || default_base_url) : result[:base_url]
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def validate_model_form(values, is_new:, existing_model:, test_callback:)
|
|
211
|
+
if is_new
|
|
212
|
+
return { success: false, error: "API Key is required for new model" } if values[:api_key].to_s.empty?
|
|
213
|
+
return { success: false, error: "Model name is required" } if values[:model].to_s.empty?
|
|
214
|
+
return { success: false, error: "Base URL is required" } if values[:base_url].to_s.empty?
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
return { success: true } unless test_callback
|
|
218
|
+
|
|
219
|
+
temp_config = Clacky::AgentConfig.new(
|
|
220
|
+
models: [{
|
|
221
|
+
"api_key" => values[:api_key],
|
|
222
|
+
"model" => values[:model],
|
|
223
|
+
"base_url" => values[:base_url],
|
|
224
|
+
"anthropic_format" => existing_model["anthropic_format"]
|
|
225
|
+
}],
|
|
226
|
+
current_model_index: 0
|
|
227
|
+
)
|
|
228
|
+
test_callback.call(temp_config)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# ── Approval helpers ────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
def parse_tool_info(message)
|
|
234
|
+
return [nil, {}] unless message
|
|
235
|
+
|
|
236
|
+
tool_name = message[/\A\w+/]&.downcase
|
|
237
|
+
params = {}
|
|
238
|
+
|
|
239
|
+
case tool_name
|
|
240
|
+
when "edit", "write"
|
|
241
|
+
path = message[/\((.+?)\)/, 1]
|
|
242
|
+
params[:path] = path if path
|
|
243
|
+
when "terminal", "shell", "exec"
|
|
244
|
+
cmd = message[/"(.+?)"/, 1]
|
|
245
|
+
params[:command] = cmd if cmd
|
|
246
|
+
when "web_search", "web_fetch"
|
|
247
|
+
params[:query] = message[(message.index("(")&.+(1) || 0)..]&.chomp(")")&.strip
|
|
248
|
+
when "execute", "run"
|
|
249
|
+
params[:command] = message[(message.index("(")&.+(1) || 0)..]&.chomp(")")&.strip
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
params.reject! { |_, v| v.to_s.empty? }
|
|
253
|
+
[tool_name, params]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def tool_risk_level(tool_name)
|
|
257
|
+
case tool_name
|
|
258
|
+
when "read", "grep", "list", "search", "web_search", "web_fetch", "fetch_url"
|
|
259
|
+
:low
|
|
260
|
+
when "edit", "write", "patch", "apply_patch"
|
|
261
|
+
:medium
|
|
262
|
+
when "shell", "terminal", "exec", "execute", "run"
|
|
263
|
+
:high
|
|
264
|
+
when "install", "remove", "delete", "rm", "force"
|
|
265
|
+
:critical
|
|
266
|
+
else
|
|
267
|
+
:medium
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def tool_category(tool_name)
|
|
272
|
+
case tool_name
|
|
273
|
+
when "read", "write", "edit", "patch", "apply_patch", "grep", "list"
|
|
274
|
+
:file
|
|
275
|
+
when "shell", "terminal", "exec", "execute", "run"
|
|
276
|
+
:shell
|
|
277
|
+
when "web_search", "web_fetch", "fetch_url"
|
|
278
|
+
:network
|
|
279
|
+
when "install", "billing", "payment"
|
|
280
|
+
:paid
|
|
281
|
+
else
|
|
282
|
+
:file
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def build_fingerprint(tool_name, params)
|
|
287
|
+
"#{tool_name}:#{params.sort.to_s}"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# RichUI - RubyRich-backed TUI system for Clacky
|
|
4
|
+
# Entry point that loads all RichUI modules.
|
|
5
|
+
|
|
6
|
+
require "ruby_rich"
|
|
7
|
+
|
|
8
|
+
module Clacky
|
|
9
|
+
module RichUI
|
|
10
|
+
module ViewportSelectionPatch
|
|
11
|
+
def highlight_display_range(line, start_col, end_col)
|
|
12
|
+
end_col = [end_col, visible_text_width(line.to_s.rstrip)].min
|
|
13
|
+
return line if end_col <= start_col
|
|
14
|
+
|
|
15
|
+
super(line, start_col, end_col)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private def visible_text_width(line)
|
|
19
|
+
width = 0
|
|
20
|
+
in_escape = false
|
|
21
|
+
|
|
22
|
+
line.each_char do |char|
|
|
23
|
+
if in_escape
|
|
24
|
+
in_escape = false if char == "m"
|
|
25
|
+
next
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
if char.ord == 27
|
|
29
|
+
in_escape = true
|
|
30
|
+
next
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
width += Unicode::DisplayWidth.of(char)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
width
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
RubyRich::Viewport.prepend(Clacky::RichUI::ViewportSelectionPatch)
|
|
43
|
+
|
|
44
|
+
require_relative "rich_ui/components/base_component"
|
|
45
|
+
require_relative "rich_ui/view_renderer"
|
|
46
|
+
require_relative "rich_ui/entry_tracker"
|
|
47
|
+
require_relative "rich_ui/components/sidebar_panels"
|
|
48
|
+
require_relative "rich_ui/components/sidebar"
|
|
49
|
+
require_relative "rich_ui/components/thinking_live_view"
|
|
50
|
+
require_relative "rich_ui/components/status_view"
|
|
51
|
+
require_relative "rich_ui/shell/rich_agent_shell"
|
|
52
|
+
require_relative "rich_ui/layout_adapter"
|
|
53
|
+
require_relative "rich_ui/progress_handle_adapter"
|
|
54
|
+
require_relative "rich_ui/components/dialogs/config_menu_dialog"
|
|
55
|
+
require_relative "rich_ui/components/dialogs/form_dialog"
|
|
56
|
+
require_relative "rich_ui/components/dialogs/approval_dialog"
|
|
57
|
+
require_relative "rich_ui/rich_ui_controller"
|