openclacky 1.2.9 → 1.2.10
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 +23 -0
- data/lib/clacky/agent/llm_caller.rb +3 -0
- data/lib/clacky/agent/message_compressor_helper.rb +6 -5
- data/lib/clacky/agent/session_serializer.rb +4 -0
- data/lib/clacky/agent.rb +9 -0
- data/lib/clacky/agent_config.rb +20 -1
- data/lib/clacky/brand_config.rb +1 -0
- data/lib/clacky/cli.rb +49 -22
- data/lib/clacky/idle_compression_timer.rb +38 -15
- data/lib/clacky/providers.rb +7 -2
- data/lib/clacky/rich_ui_controller.rb +1549 -0
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +24 -2
- data/lib/clacky/server/channel/channel_manager.rb +89 -2
- data/lib/clacky/server/http_server.rb +124 -9
- data/lib/clacky/session_manager.rb +9 -8
- data/lib/clacky/telemetry.rb +16 -2
- data/lib/clacky/ui2/layout_manager.rb +11 -7
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +1 -1
- data/lib/clacky/utils/model_pricing.rb +75 -53
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +221 -2
- data/lib/clacky/web/billing.js +1 -1
- data/lib/clacky/web/i18n.js +28 -4
- data/lib/clacky/web/index.html +9 -1
- data/lib/clacky/web/sessions.js +443 -2
- data/lib/clacky/web/settings.js +50 -0
- data/lib/clacky/web/workspace.js +9 -1
- data/scripts/build/lib/network.sh +3 -3
- data/scripts/install.ps1 +16 -4
- data/scripts/install.sh +3 -3
- data/scripts/install_browser.sh +3 -3
- data/scripts/install_full.sh +3 -3
- data/scripts/install_rails_deps.sh +3 -3
- data/scripts/install_system_deps.sh +3 -3
- metadata +6 -2
|
@@ -0,0 +1,1549 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "base64"
|
|
6
|
+
require_relative "ui_interface"
|
|
7
|
+
require_relative "providers"
|
|
8
|
+
require_relative "ui2/components/welcome_banner"
|
|
9
|
+
|
|
10
|
+
begin
|
|
11
|
+
require "ruby_rich"
|
|
12
|
+
rescue LoadError
|
|
13
|
+
require_relative "../../../ruby_rich/lib/ruby_rich"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module RubyRich
|
|
17
|
+
class Viewport
|
|
18
|
+
unless method_defined?(:clacky_handle_event_without_text_selection)
|
|
19
|
+
alias_method :clacky_handle_event_without_text_selection, :handle_event
|
|
20
|
+
|
|
21
|
+
def handle_event(event_data, layout = nil)
|
|
22
|
+
return false if keyboard_event?(event_data) && !@focused
|
|
23
|
+
|
|
24
|
+
case event_data[:name]
|
|
25
|
+
when :mouse_down
|
|
26
|
+
return copy_selection if event_data[:button] == :right
|
|
27
|
+
|
|
28
|
+
start_scrollbar_drag(event_data, layout) || start_selection(event_data, layout)
|
|
29
|
+
when :mouse_drag
|
|
30
|
+
drag_scrollbar(event_data, layout) || drag_selection(event_data, layout)
|
|
31
|
+
when :mouse_up
|
|
32
|
+
stop_scrollbar_drag || clacky_stop_selection_without_copy
|
|
33
|
+
else
|
|
34
|
+
clacky_handle_event_without_text_selection(event_data, layout)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def clacky_stop_selection_without_copy
|
|
39
|
+
return false unless @selecting
|
|
40
|
+
|
|
41
|
+
@selecting = false
|
|
42
|
+
@selected_text = extract_selected_text
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def copy_to_clipboard(text)
|
|
47
|
+
text = text.to_s
|
|
48
|
+
return false if text.empty?
|
|
49
|
+
return true if RubyRich::Terminal.windows? && clacky_try_windows_clipboard(text)
|
|
50
|
+
|
|
51
|
+
clacky_clipboard_commands.each do |command|
|
|
52
|
+
return true if clacky_write_clipboard_command(command, text)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
clacky_copy_to_terminal_clipboard(text)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def copy_selection
|
|
59
|
+
text = @selected_text.to_s
|
|
60
|
+
return false if text.empty?
|
|
61
|
+
|
|
62
|
+
copied = copy_to_clipboard(text)
|
|
63
|
+
clacky_clear_selection if copied
|
|
64
|
+
copied
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def clacky_clear_selection
|
|
68
|
+
@selecting = false
|
|
69
|
+
@selection_start = nil
|
|
70
|
+
@selection_end = nil
|
|
71
|
+
@selected_text = ""
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def apply_selection(line, absolute_line)
|
|
75
|
+
range = normalized_selection
|
|
76
|
+
return line unless range
|
|
77
|
+
|
|
78
|
+
start_pos, end_pos = range
|
|
79
|
+
return line if absolute_line < start_pos[:line] || absolute_line > end_pos[:line]
|
|
80
|
+
|
|
81
|
+
start_col = absolute_line == start_pos[:line] ? start_pos[:col] : 0
|
|
82
|
+
end_col = absolute_line == end_pos[:line] ? end_pos[:col] : display_width(strip_ansi(line).rstrip)
|
|
83
|
+
end_col = [end_col, display_width(strip_ansi(line).rstrip)].min
|
|
84
|
+
clacky_highlight_display_range(line, start_col, end_col)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def clacky_highlight_display_range(line, start_col, end_col)
|
|
88
|
+
return line if end_col <= start_col
|
|
89
|
+
|
|
90
|
+
result = +""
|
|
91
|
+
width = 0
|
|
92
|
+
active = false
|
|
93
|
+
in_escape = false
|
|
94
|
+
escape = +""
|
|
95
|
+
|
|
96
|
+
line.each_char do |char|
|
|
97
|
+
if in_escape
|
|
98
|
+
escape << char
|
|
99
|
+
if char == "m"
|
|
100
|
+
result << escape
|
|
101
|
+
result << AnsiCode.inverse if active
|
|
102
|
+
escape = +""
|
|
103
|
+
in_escape = false
|
|
104
|
+
end
|
|
105
|
+
next
|
|
106
|
+
elsif char.ord == 27
|
|
107
|
+
escape << char
|
|
108
|
+
in_escape = true
|
|
109
|
+
next
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
char_width = Unicode::DisplayWidth.of(char)
|
|
113
|
+
should_highlight = width < end_col && width + char_width > start_col
|
|
114
|
+
if should_highlight && !active
|
|
115
|
+
result << AnsiCode.inverse
|
|
116
|
+
active = true
|
|
117
|
+
elsif !should_highlight && active
|
|
118
|
+
result << AnsiCode.reset
|
|
119
|
+
active = false
|
|
120
|
+
end
|
|
121
|
+
result << char
|
|
122
|
+
width += char_width
|
|
123
|
+
end
|
|
124
|
+
result << AnsiCode.reset if active
|
|
125
|
+
result
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def clacky_clipboard_commands
|
|
129
|
+
commands = []
|
|
130
|
+
commands << ["wl-copy"] if ENV["WAYLAND_DISPLAY"]
|
|
131
|
+
if ENV["DISPLAY"]
|
|
132
|
+
commands << ["xclip", "-selection", "clipboard"]
|
|
133
|
+
commands << ["xsel", "--clipboard", "--input"]
|
|
134
|
+
end
|
|
135
|
+
commands << ["pbcopy"] if RUBY_PLATFORM.match?(/darwin/)
|
|
136
|
+
commands
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def clacky_write_clipboard_command(command, text)
|
|
140
|
+
IO.popen(command, "w") { |io| io.write(text) }
|
|
141
|
+
$?&.success? == true
|
|
142
|
+
rescue IOError, SystemCallError
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def clacky_try_windows_clipboard(text)
|
|
147
|
+
copy_to_windows_clipboard(text)
|
|
148
|
+
true
|
|
149
|
+
rescue IOError, SystemCallError
|
|
150
|
+
false
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def clacky_copy_to_terminal_clipboard(text)
|
|
154
|
+
encoded = Base64.strict_encode64(text.encode(Encoding::UTF_8))
|
|
155
|
+
$stdout.print("\e]52;c;#{encoded}\a")
|
|
156
|
+
$stdout.flush
|
|
157
|
+
true
|
|
158
|
+
rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError, IOError, SystemCallError
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private :clacky_stop_selection_without_copy,
|
|
163
|
+
:copy_to_clipboard,
|
|
164
|
+
:copy_selection,
|
|
165
|
+
:clacky_clear_selection,
|
|
166
|
+
:apply_selection,
|
|
167
|
+
:clacky_highlight_display_range,
|
|
168
|
+
:clacky_clipboard_commands,
|
|
169
|
+
:clacky_write_clipboard_command,
|
|
170
|
+
:clacky_try_windows_clipboard,
|
|
171
|
+
:clacky_copy_to_terminal_clipboard
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
class Transcript
|
|
176
|
+
unless private_method_defined?(:clacky_render_entry_without_plain)
|
|
177
|
+
alias_method :clacky_render_entry_without_plain, :render_entry
|
|
178
|
+
|
|
179
|
+
def render_entry(entry, index)
|
|
180
|
+
if entry.metadata[:plain]
|
|
181
|
+
entry.content.to_s.split("\n", -1)
|
|
182
|
+
else
|
|
183
|
+
clacky_render_entry_without_plain(entry, index)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private :render_entry
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
class Markdown
|
|
192
|
+
class TerminalRenderer
|
|
193
|
+
unless method_defined?(:clacky_fit_table_rows)
|
|
194
|
+
def table(header, body)
|
|
195
|
+
all_rows = @table_state[:all_rows]
|
|
196
|
+
reset_table_state
|
|
197
|
+
return "" if all_rows.empty?
|
|
198
|
+
|
|
199
|
+
header_line_count = [header.to_s.strip.split("\n").size, 1].max
|
|
200
|
+
header_rows = all_rows[0...header_line_count]
|
|
201
|
+
body_rows = all_rows[header_line_count..] || []
|
|
202
|
+
|
|
203
|
+
return "" if header_rows.empty? || body_rows.empty?
|
|
204
|
+
|
|
205
|
+
headers, fitted_body_rows = clacky_fit_table_rows(header_rows.last, body_rows)
|
|
206
|
+
begin
|
|
207
|
+
tbl = RubyRich::Table.new(headers: headers, border_style: @options[:table_border_style] || :simple)
|
|
208
|
+
fitted_body_rows.each do |row|
|
|
209
|
+
padded = row + Array.new([0, headers.length - row.length].max, "")
|
|
210
|
+
tbl.add_row(padded[0...headers.length])
|
|
211
|
+
end
|
|
212
|
+
return "#{tbl.render}\n\n"
|
|
213
|
+
rescue
|
|
214
|
+
# fall through to the original plain fallback shape
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
result = "\n"
|
|
218
|
+
result += "#{header.strip}\n"
|
|
219
|
+
result += "#{"-" * [header.strip.length, 20].min}\n"
|
|
220
|
+
result += "#{body.strip}\n" if body && !body.strip.empty?
|
|
221
|
+
"#{result}\n"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def clacky_fit_table_rows(header_row, body_rows)
|
|
225
|
+
column_count = [header_row.length, *body_rows.map(&:length)].max.to_i
|
|
226
|
+
normalized_header = header_row + Array.new([0, column_count - header_row.length].max, "")
|
|
227
|
+
normalized_body = body_rows.map { |row| row + Array.new([0, column_count - row.length].max, "") }
|
|
228
|
+
natural_widths = clacky_table_natural_widths(normalized_header, normalized_body)
|
|
229
|
+
column_widths = clacky_constrain_table_widths(natural_widths)
|
|
230
|
+
|
|
231
|
+
headers = normalized_header.each_with_index.map { |cell, index| clacky_wrap_table_cell(clacky_table_cell_text(cell), column_widths[index]) }
|
|
232
|
+
rows = normalized_body.map do |row|
|
|
233
|
+
row.each_with_index.map { |cell, index| clacky_wrap_table_cell(clacky_table_cell_text(cell), column_widths[index]) }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
[headers, rows]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def clacky_table_natural_widths(header_row, body_rows)
|
|
240
|
+
rows = [header_row] + body_rows
|
|
241
|
+
rows.transpose.map do |cells|
|
|
242
|
+
cells.map { |cell| clacky_visible_width(clacky_table_cell_text(cell)) }.max.to_i
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def clacky_table_cell_text(cell)
|
|
247
|
+
process_inline(cell).to_s.gsub(/\e\[[0-9;:]*m/, "")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def clacky_constrain_table_widths(natural_widths)
|
|
251
|
+
return natural_widths if natural_widths.empty?
|
|
252
|
+
|
|
253
|
+
border_overhead = (natural_widths.length * 3) + 1
|
|
254
|
+
max_table_width = [[@options[:width].to_i - 1, 20].max, border_overhead + natural_widths.length].max
|
|
255
|
+
available_content_width = [max_table_width - border_overhead, natural_widths.length].max
|
|
256
|
+
widths = natural_widths.map { |width| [width, 1].max }
|
|
257
|
+
return widths if widths.sum <= available_content_width
|
|
258
|
+
|
|
259
|
+
min_width = available_content_width < natural_widths.length * 3 ? 1 : 3
|
|
260
|
+
while widths.sum > available_content_width
|
|
261
|
+
index = widths.each_with_index.select { |width, _| width > min_width }.max_by(&:first)&.last
|
|
262
|
+
break unless index
|
|
263
|
+
|
|
264
|
+
widths[index] -= 1
|
|
265
|
+
end
|
|
266
|
+
widths
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def clacky_wrap_table_cell(text, width)
|
|
270
|
+
width = [width.to_i, 1].max
|
|
271
|
+
text.to_s.split("\n", -1).flat_map do |line|
|
|
272
|
+
clacky_wrap_table_line(line, width)
|
|
273
|
+
end.join("\n")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def clacky_wrap_table_line(line, width)
|
|
277
|
+
return [""] if line.empty?
|
|
278
|
+
|
|
279
|
+
lines = []
|
|
280
|
+
current = +""
|
|
281
|
+
current_width = 0
|
|
282
|
+
in_escape = false
|
|
283
|
+
escape = +""
|
|
284
|
+
|
|
285
|
+
line.each_char do |char|
|
|
286
|
+
if in_escape
|
|
287
|
+
escape << char
|
|
288
|
+
if char == "m"
|
|
289
|
+
current << escape
|
|
290
|
+
escape = +""
|
|
291
|
+
in_escape = false
|
|
292
|
+
end
|
|
293
|
+
next
|
|
294
|
+
elsif char.ord == 27
|
|
295
|
+
escape << char
|
|
296
|
+
in_escape = true
|
|
297
|
+
next
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
char_width = Unicode::DisplayWidth.of(char)
|
|
301
|
+
if current_width.positive? && current_width + char_width > width
|
|
302
|
+
lines << current
|
|
303
|
+
current = +""
|
|
304
|
+
current_width = 0
|
|
305
|
+
end
|
|
306
|
+
current << char
|
|
307
|
+
current_width += char_width
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
lines << current unless current.empty?
|
|
311
|
+
lines.empty? ? [""] : lines
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def clacky_visible_width(text)
|
|
315
|
+
text.to_s.gsub(/\e\[[0-9;:]*m/, "").split("\n").map(&:display_width).max.to_i
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
module Clacky
|
|
323
|
+
class RichTodoSidebar
|
|
324
|
+
attr_accessor :width, :height
|
|
325
|
+
attr_reader :tasks
|
|
326
|
+
|
|
327
|
+
def initialize(tasks: [])
|
|
328
|
+
@tasks = tasks
|
|
329
|
+
@width = 0
|
|
330
|
+
@height = 0
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def update_plan(_text)
|
|
334
|
+
self
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def set_tasks(tasks)
|
|
338
|
+
@tasks = Array(tasks)
|
|
339
|
+
self
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def add_task(label, status: :pending)
|
|
343
|
+
@tasks << { label: label, status: status }
|
|
344
|
+
self
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def render
|
|
348
|
+
panel = RubyRich::Panel.new(render_tasks, title: "Todos", border_style: :blue, title_align: :left)
|
|
349
|
+
panel.width = @width
|
|
350
|
+
panel.height = @height
|
|
351
|
+
panel.render
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def render_tasks
|
|
355
|
+
return muted("No active todos") if @tasks.empty?
|
|
356
|
+
|
|
357
|
+
@tasks.map do |task|
|
|
358
|
+
label = task_label(task)
|
|
359
|
+
status = task_status(task)
|
|
360
|
+
"#{status_marker(status)} #{label}"
|
|
361
|
+
end.join("\n")
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def task_label(task)
|
|
365
|
+
case task
|
|
366
|
+
when Hash
|
|
367
|
+
(task[:label] ||
|
|
368
|
+
task["label"] ||
|
|
369
|
+
task[:title] ||
|
|
370
|
+
task["title"] ||
|
|
371
|
+
task[:content] ||
|
|
372
|
+
task["content"] ||
|
|
373
|
+
task[:task] ||
|
|
374
|
+
task["task"]).to_s
|
|
375
|
+
else
|
|
376
|
+
task.to_s
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def task_status(task)
|
|
381
|
+
case task
|
|
382
|
+
when Hash
|
|
383
|
+
(task[:status] || task["status"] || :pending).to_sym
|
|
384
|
+
else
|
|
385
|
+
:pending
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def status_marker(status)
|
|
390
|
+
case status
|
|
391
|
+
when :done, :completed
|
|
392
|
+
"#{RubyRich::AnsiCode.color(:green, true)}✓#{RubyRich::AnsiCode.reset}"
|
|
393
|
+
when :running, :in_progress, :active, :executing
|
|
394
|
+
"#{RubyRich::AnsiCode.color(:blue, true)}●#{RubyRich::AnsiCode.reset}"
|
|
395
|
+
when :failed, :error
|
|
396
|
+
"#{RubyRich::AnsiCode.color(:red, true)}!#{RubyRich::AnsiCode.reset}"
|
|
397
|
+
else
|
|
398
|
+
"#{RubyRich::AnsiCode.color(:black, true)}○#{RubyRich::AnsiCode.reset}"
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def muted(text)
|
|
403
|
+
"#{RubyRich::AnsiCode.color(:black, true)}#{text}#{RubyRich::AnsiCode.reset}"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
private :render_tasks,
|
|
407
|
+
:task_label,
|
|
408
|
+
:task_status,
|
|
409
|
+
:status_marker,
|
|
410
|
+
:muted
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
class RichAgentShell < RubyRich::AgentShell
|
|
414
|
+
def build_layout
|
|
415
|
+
@sidebar = RichTodoSidebar.new
|
|
416
|
+
@viewport.instance_variable_set(:@scrollbar, false)
|
|
417
|
+
root = RubyRich::Layout.new(name: :root)
|
|
418
|
+
root.split_column(
|
|
419
|
+
RubyRich::Layout.new(name: :header, size: 1),
|
|
420
|
+
RubyRich::Layout.new(name: :body, ratio: 1),
|
|
421
|
+
RubyRich::Layout.new(name: :composer, size: 6),
|
|
422
|
+
RubyRich::Layout.new(name: :status, size: 1)
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
root[:body].split_row(
|
|
426
|
+
RubyRich::Layout.new(name: :transcript, ratio: 1),
|
|
427
|
+
RubyRich::Layout.new(name: :todos, size: 36)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
root[:header].content = RubyRich::AppShell::HeaderView.new(self)
|
|
431
|
+
root[:transcript].content = @viewport
|
|
432
|
+
root[:todos].content = @sidebar
|
|
433
|
+
root[:composer].content = RubyRich::AppShell::FramedView.new(@composer, title: "Composer", theme: @theme) { @composer.focused? }
|
|
434
|
+
root[:status].content = RubyRich::AppShell::StatusView.new(self)
|
|
435
|
+
root
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def attach_components
|
|
439
|
+
@viewport.attach(@layout[:transcript])
|
|
440
|
+
@transcript.attach(@layout[:transcript])
|
|
441
|
+
@composer.focus.attach(@layout[:composer])
|
|
442
|
+
|
|
443
|
+
@focus_manager
|
|
444
|
+
.register(:transcript, @layout[:transcript], RubyRich::AppShell::FocusTarget.new(@transcript, @viewport))
|
|
445
|
+
.register(:composer, @layout[:composer], @composer)
|
|
446
|
+
.attach(@layout)
|
|
447
|
+
@focus_manager.focus(:composer)
|
|
448
|
+
|
|
449
|
+
@layout.key(:ctrl_c, 1_000) do |_event, live|
|
|
450
|
+
live.stop if @stop_on_ctrl_c != false
|
|
451
|
+
false
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def attach_agent_controls
|
|
456
|
+
@composer.instance_variable_set(:@on_interrupt, nil)
|
|
457
|
+
|
|
458
|
+
@layout.key(:ctrl_c, 2_000) do |_event, live|
|
|
459
|
+
handle_interrupt(live, self)
|
|
460
|
+
false
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
@layout.key(:ctrl_m, 2_000) do |_event, _live|
|
|
464
|
+
toggle_mode
|
|
465
|
+
false
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def handle_interrupt(_live = nil, _source = nil)
|
|
470
|
+
input_was_empty = @composer.value.to_s.empty?
|
|
471
|
+
@callbacks[:interrupt]&.call(input_was_empty: input_was_empty)
|
|
472
|
+
false
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
private :build_layout,
|
|
476
|
+
:attach_components,
|
|
477
|
+
:attach_agent_controls,
|
|
478
|
+
:handle_interrupt
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Experimental RubyRich-backed TUI controller.
|
|
482
|
+
#
|
|
483
|
+
# This intentionally implements the same surface as UI2::UIController so the
|
|
484
|
+
# CLI/Agent loop can switch implementations without knowing which TUI is
|
|
485
|
+
# underneath. It is not the default UI yet.
|
|
486
|
+
class RichUIController
|
|
487
|
+
include Clacky::UIInterface
|
|
488
|
+
|
|
489
|
+
STREAMING_MARKDOWN_THRESHOLD = 240
|
|
490
|
+
STREAMING_MARKDOWN_CHUNK_SIZE = 6
|
|
491
|
+
STREAMING_MARKDOWN_DELAY = 0.03
|
|
492
|
+
|
|
493
|
+
COMMANDS = [
|
|
494
|
+
{ label: "/clear", value: "/clear", description: "Clear output and restart session" },
|
|
495
|
+
{ label: "/config", value: "/config", description: "Open configuration" },
|
|
496
|
+
{ label: "/undo", value: "/undo", description: "Restore a previous task state" },
|
|
497
|
+
{ label: "/help", value: "/help", description: "Show commands" },
|
|
498
|
+
{ label: "/exit", value: "/exit", description: "Exit application", aliases: ["/quit"] }
|
|
499
|
+
].freeze
|
|
500
|
+
|
|
501
|
+
attr_reader :layout, :shell, :running
|
|
502
|
+
attr_accessor :config
|
|
503
|
+
|
|
504
|
+
def initialize(config = {})
|
|
505
|
+
@config = {
|
|
506
|
+
working_dir: config[:working_dir],
|
|
507
|
+
mode: config[:mode],
|
|
508
|
+
model: config[:model],
|
|
509
|
+
theme: config[:theme]
|
|
510
|
+
}
|
|
511
|
+
@welcome_banner = Clacky::UI2::Components::WelcomeBanner.new
|
|
512
|
+
@shell = RichAgentShell.new(
|
|
513
|
+
title: "OpenClacky",
|
|
514
|
+
subtitle: config[:working_dir].to_s,
|
|
515
|
+
model: config[:model].to_s,
|
|
516
|
+
commands: COMMANDS
|
|
517
|
+
)
|
|
518
|
+
@layout = LayoutAdapter.new(@shell)
|
|
519
|
+
@input_callback = nil
|
|
520
|
+
@interrupt_callback = nil
|
|
521
|
+
@mode_toggle_callback = nil
|
|
522
|
+
@time_machine_callback = nil
|
|
523
|
+
@tasks_count = 0
|
|
524
|
+
@total_cost = 0.0
|
|
525
|
+
@running = false
|
|
526
|
+
@tool_ids = []
|
|
527
|
+
@todo_items = []
|
|
528
|
+
@explicit_todo_cycle = false
|
|
529
|
+
@tool_activities = []
|
|
530
|
+
@tool_activity_by_id = {}
|
|
531
|
+
@legacy_progress = {}
|
|
532
|
+
@stdout_lines = []
|
|
533
|
+
@callback_threads = []
|
|
534
|
+
@stream_threads = []
|
|
535
|
+
|
|
536
|
+
wire_shell_callbacks
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def initialize_and_show_banner(recent_user_messages: nil)
|
|
540
|
+
@running = true
|
|
541
|
+
@shell.update_status(session_status)
|
|
542
|
+
if recent_user_messages && !recent_user_messages.empty?
|
|
543
|
+
@shell.add_separator("recent session")
|
|
544
|
+
recent_user_messages.each { |message| @shell.add_user_message(message) }
|
|
545
|
+
else
|
|
546
|
+
add_plain_block(render_welcome_banner)
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def start
|
|
551
|
+
initialize_and_show_banner unless @running
|
|
552
|
+
start_input_loop
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def start_input_loop
|
|
556
|
+
@running = true
|
|
557
|
+
@shell.start
|
|
558
|
+
ensure
|
|
559
|
+
@running = false
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def stop(clear_screen: false)
|
|
563
|
+
@running = false
|
|
564
|
+
@shell.stop
|
|
565
|
+
RubyRich::Terminal.clear if clear_screen
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def set_skill_loader(_skill_loader, _agent_profile = nil); end
|
|
569
|
+
def set_agent(_agent, _agent_profile = nil); end
|
|
570
|
+
|
|
571
|
+
def on_input(&block)
|
|
572
|
+
@input_callback = block
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def on_interrupt(&block)
|
|
576
|
+
@interrupt_callback = block
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def on_mode_toggle(&block)
|
|
580
|
+
@mode_toggle_callback = block
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def on_time_machine(&block)
|
|
584
|
+
@time_machine_callback = block
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def append_output(content)
|
|
588
|
+
return if content.nil?
|
|
589
|
+
|
|
590
|
+
@shell.add_markdown(content.to_s)
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def log(message, level: :info)
|
|
594
|
+
case level.to_sym
|
|
595
|
+
when :error then show_error(message)
|
|
596
|
+
when :warning, :warn then show_warning(message)
|
|
597
|
+
when :debug then nil
|
|
598
|
+
else show_info(message)
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def show_assistant_message(content, files:)
|
|
603
|
+
text = filter_thinking_tags(content)
|
|
604
|
+
stream_thread = nil
|
|
605
|
+
stream_thread = add_conversation_markdown(text) unless text.nil? || text.strip.empty?
|
|
606
|
+
if stream_thread.is_a?(Thread)
|
|
607
|
+
add_file_summary_after(stream_thread, files)
|
|
608
|
+
else
|
|
609
|
+
add_file_summary(files)
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def show_tool_call(name, args)
|
|
614
|
+
id = @shell.start_tool_call(name: name.to_s, input: format_args(args), status: :running)
|
|
615
|
+
if id
|
|
616
|
+
@tool_ids << id
|
|
617
|
+
track_tool_activity(id, tool_activity_label(name, args), :running)
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def show_tool_result(result)
|
|
622
|
+
if (id = @tool_ids.pop)
|
|
623
|
+
@shell.finish_tool_call(id, status: :done, output: result.to_s)
|
|
624
|
+
update_tool_activity(id, :done)
|
|
625
|
+
else
|
|
626
|
+
@shell.add_markdown(result.to_s)
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def show_tool_stdout(lines)
|
|
631
|
+
@stdout_lines.concat(Array(lines).map(&:to_s))
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def show_tool_error(error)
|
|
635
|
+
message = error.is_a?(Exception) ? error.message : error.to_s
|
|
636
|
+
if (id = @tool_ids.pop)
|
|
637
|
+
@shell.finish_tool_call(id, status: :error, output: message)
|
|
638
|
+
update_tool_activity(id, :error)
|
|
639
|
+
else
|
|
640
|
+
@shell.add_error_message(message)
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def show_tool_args(formatted_args)
|
|
645
|
+
append_output("Args: #{formatted_args}")
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def show_file_write_preview(path, is_new_file:)
|
|
649
|
+
append_output("#{is_new_file ? "Creating" : "Modifying"} file: #{path || "(unknown)"}")
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def show_file_edit_preview(path)
|
|
653
|
+
append_output("Editing file: #{path || "(unknown)"}")
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def show_file_error(error_message)
|
|
657
|
+
show_error(error_message)
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def show_shell_preview(command)
|
|
661
|
+
append_output("$ #{command}")
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def show_diff(old_content, new_content, max_lines: 50)
|
|
665
|
+
require "diffy"
|
|
666
|
+
diff = Diffy::Diff.new(old_content, new_content, context: 3).to_s(:color)
|
|
667
|
+
lines = diff.lines
|
|
668
|
+
visible = lines.take(max_lines).join
|
|
669
|
+
hidden = lines.length - max_lines
|
|
670
|
+
visible += "\n... (#{hidden} more lines hidden)" if hidden.positive?
|
|
671
|
+
@shell.add_diff(content: visible)
|
|
672
|
+
rescue LoadError
|
|
673
|
+
append_output("Old size: #{old_content.bytesize} bytes\nNew size: #{new_content.bytesize} bytes")
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def show_token_usage(token_data)
|
|
677
|
+
@shell.show_token_usage(
|
|
678
|
+
input: token_data[:prompt_tokens],
|
|
679
|
+
output: token_data[:completion_tokens],
|
|
680
|
+
total: token_data[:total_tokens],
|
|
681
|
+
cost: token_data[:cost]
|
|
682
|
+
)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
|
|
686
|
+
set_idle_status
|
|
687
|
+
return if awaiting_user_feedback || iterations <= 5
|
|
688
|
+
|
|
689
|
+
parts = ["Completed #{iterations} iterations", "cost $#{cost.round(4)}"]
|
|
690
|
+
parts << "#{duration.round(1)}s" if duration
|
|
691
|
+
append_output(parts.join(" · "))
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def show_info(message, prefix_newline: true)
|
|
695
|
+
_ = prefix_newline
|
|
696
|
+
@shell.add_system_message(message.to_s)
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def show_warning(message)
|
|
700
|
+
@shell.add_system_message("Warning: #{message}")
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def show_error(message)
|
|
704
|
+
@shell.add_error_message(message.to_s)
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def show_success(message)
|
|
708
|
+
@shell.add_system_message("OK: #{message}")
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
|
|
712
|
+
_ = prefix_newline
|
|
713
|
+
type = progress_type.to_s
|
|
714
|
+
if phase.to_s == "done"
|
|
715
|
+
@legacy_progress.delete(type)&.finish(final_message: message)
|
|
716
|
+
return
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
handle = @legacy_progress[type]
|
|
720
|
+
if handle
|
|
721
|
+
handle.update(message: message, metadata: metadata)
|
|
722
|
+
else
|
|
723
|
+
@legacy_progress[type] = start_progress(message: message, style: type == "thinking" ? :primary : :quiet)
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def start_progress(message: nil, style: :primary, quiet_on_fast_finish: false)
|
|
728
|
+
_ = quiet_on_fast_finish
|
|
729
|
+
ProgressHandleAdapter.new(@shell.start_progress(message || "Working", style: style))
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def with_progress(message: nil, style: :primary, quiet_on_fast_finish: false)
|
|
733
|
+
handle = start_progress(message: message, style: style, quiet_on_fast_finish: quiet_on_fast_finish)
|
|
734
|
+
begin
|
|
735
|
+
yield handle
|
|
736
|
+
ensure
|
|
737
|
+
handle.finish
|
|
738
|
+
end
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil, session_id: nil)
|
|
742
|
+
_ = cost_source
|
|
743
|
+
_ = latency
|
|
744
|
+
@tasks_count = tasks if tasks
|
|
745
|
+
@total_cost = cost if cost
|
|
746
|
+
@status = status if status
|
|
747
|
+
@shell.update_status(session_status)
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
def update_todos(todos)
|
|
751
|
+
@todo_items = Array(todos).map { |todo| normalize_todo(todo) }
|
|
752
|
+
@explicit_todo_cycle = true
|
|
753
|
+
refresh_sidebar_tasks
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def set_working_status
|
|
757
|
+
update_sessionbar(status: "working")
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def set_idle_status
|
|
761
|
+
update_sessionbar(status: "idle")
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def request_confirmation(message, default: true)
|
|
765
|
+
show_info(message)
|
|
766
|
+
@shell.confirm(
|
|
767
|
+
title: "Confirm",
|
|
768
|
+
message: message,
|
|
769
|
+
choices: [{ key: true, label: "Yes" }, { key: false, label: "No" }],
|
|
770
|
+
default: default
|
|
771
|
+
)
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def clear_input
|
|
775
|
+
@shell.composer.editor.clear
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def set_input_tips(message, type: :info)
|
|
779
|
+
update_sessionbar(status: "#{type}: #{message}")
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def show_help
|
|
783
|
+
@shell.add_markdown(<<~HELP)
|
|
784
|
+
Commands:
|
|
785
|
+
/clear - Clear output and restart session
|
|
786
|
+
/exit - Exit application
|
|
787
|
+
|
|
788
|
+
Input:
|
|
789
|
+
Shift+Enter - New line
|
|
790
|
+
Up/Down - History navigation
|
|
791
|
+
Ctrl+C - Interrupt current task
|
|
792
|
+
HELP
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
def show_config_modal(current_config, test_callback: nil)
|
|
796
|
+
return nil unless @running
|
|
797
|
+
|
|
798
|
+
loop do
|
|
799
|
+
choices = config_menu_choices(current_config)
|
|
800
|
+
result = show_menu_dialog(
|
|
801
|
+
title: "Model Configuration",
|
|
802
|
+
choices: choices,
|
|
803
|
+
selected_index: config_initial_selection(choices)
|
|
804
|
+
)
|
|
805
|
+
return nil if result.nil?
|
|
806
|
+
|
|
807
|
+
case result[:action]
|
|
808
|
+
when :switch
|
|
809
|
+
return result
|
|
810
|
+
when :add
|
|
811
|
+
new_model = show_model_edit_form(nil, test_callback: test_callback)
|
|
812
|
+
if new_model
|
|
813
|
+
anthropic_format = new_model[:provider] == "anthropic"
|
|
814
|
+
current_config.add_model(
|
|
815
|
+
model: new_model[:model],
|
|
816
|
+
api_key: new_model[:api_key],
|
|
817
|
+
base_url: new_model[:base_url],
|
|
818
|
+
anthropic_format: anthropic_format
|
|
819
|
+
)
|
|
820
|
+
new_id = current_config.models.last["id"]
|
|
821
|
+
return { action: :add, model_id: new_id }
|
|
822
|
+
end
|
|
823
|
+
when :edit
|
|
824
|
+
current_model = current_config.current_model
|
|
825
|
+
edited = show_model_edit_form(current_model, test_callback: test_callback)
|
|
826
|
+
if edited
|
|
827
|
+
current_model["api_key"] = edited[:api_key]
|
|
828
|
+
current_model["model"] = edited[:model]
|
|
829
|
+
current_model["base_url"] = edited[:base_url]
|
|
830
|
+
return { action: :edit, model_id: current_model["id"] }
|
|
831
|
+
end
|
|
832
|
+
when :delete
|
|
833
|
+
if current_config.models.length <= 1
|
|
834
|
+
show_warning("Cannot delete the last model.")
|
|
835
|
+
next
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
current_config.remove_model(current_config.current_model_index)
|
|
839
|
+
new_current = current_config.current_model
|
|
840
|
+
return { action: :delete, model_id: new_current && new_current["id"] }
|
|
841
|
+
when :close
|
|
842
|
+
return nil
|
|
843
|
+
end
|
|
844
|
+
end
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def filter_thinking_tags(content)
|
|
848
|
+
return content if content.nil?
|
|
849
|
+
|
|
850
|
+
content.gsub(%r{<think(?:ing)?>[\s\S]*?</think(?:ing)?>}mi, "").gsub(/\n{3,}/, "\n\n").strip
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
def track_tool_activity(id, label, status)
|
|
854
|
+
activity = { id: id, label: label.to_s, status: status }
|
|
855
|
+
@tool_activities << activity
|
|
856
|
+
@tool_activities.shift while @tool_activities.length > 12
|
|
857
|
+
@tool_activity_by_id[id] = activity
|
|
858
|
+
refresh_sidebar_tasks
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
def update_tool_activity(id, status)
|
|
862
|
+
activity = @tool_activity_by_id[id]
|
|
863
|
+
return unless activity
|
|
864
|
+
|
|
865
|
+
activity[:status] = status
|
|
866
|
+
refresh_sidebar_tasks
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
def refresh_sidebar_tasks
|
|
870
|
+
tasks = if @todo_items.empty?
|
|
871
|
+
@explicit_todo_cycle ? [] : @tool_activities
|
|
872
|
+
else
|
|
873
|
+
@todo_items
|
|
874
|
+
end
|
|
875
|
+
@shell.update_tasks(tasks)
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
def reset_task_sidebar_tracking
|
|
879
|
+
@todo_items = []
|
|
880
|
+
@explicit_todo_cycle = false
|
|
881
|
+
@tool_activities = []
|
|
882
|
+
@tool_activity_by_id = {}
|
|
883
|
+
refresh_sidebar_tasks
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
def tool_activity_label(name, args)
|
|
887
|
+
tool_name = name.to_s
|
|
888
|
+
data = normalize_tool_args(args)
|
|
889
|
+
|
|
890
|
+
case tool_name
|
|
891
|
+
when "web_search"
|
|
892
|
+
query = data["query"].to_s
|
|
893
|
+
return tool_name if query.empty?
|
|
894
|
+
|
|
895
|
+
%(web_search("#{escape_tool_label(truncate_tool_label(query))}"))
|
|
896
|
+
when "web_fetch"
|
|
897
|
+
url = data["url"].to_s
|
|
898
|
+
return tool_name if url.empty?
|
|
899
|
+
|
|
900
|
+
"web_fetch(#{truncate_tool_label(tool_url_host(url))})"
|
|
901
|
+
else
|
|
902
|
+
compact = compact_tool_arg(data)
|
|
903
|
+
compact ? "#{tool_name}(#{compact})" : tool_name
|
|
904
|
+
end
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
def normalize_tool_args(args)
|
|
908
|
+
parsed = if args.is_a?(String)
|
|
909
|
+
JSON.parse(args)
|
|
910
|
+
else
|
|
911
|
+
args
|
|
912
|
+
end
|
|
913
|
+
return {} unless parsed.is_a?(Hash)
|
|
914
|
+
|
|
915
|
+
parsed.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value }
|
|
916
|
+
rescue JSON::ParserError
|
|
917
|
+
{}
|
|
918
|
+
end
|
|
919
|
+
|
|
920
|
+
def compact_tool_arg(data)
|
|
921
|
+
key = %w[query url path file command pattern task].find { |candidate| data.key?(candidate) && !data[candidate].to_s.empty? }
|
|
922
|
+
return nil unless key
|
|
923
|
+
|
|
924
|
+
value = key == "url" ? tool_url_host(data[key].to_s) : data[key].to_s
|
|
925
|
+
escaped = escape_tool_label(truncate_tool_label(value))
|
|
926
|
+
value.match?(/\A[\w.-]+\z/) ? escaped : %("#{escaped}")
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
def tool_url_host(url)
|
|
930
|
+
URI.parse(url).host || url
|
|
931
|
+
rescue URI::InvalidURIError
|
|
932
|
+
url
|
|
933
|
+
end
|
|
934
|
+
|
|
935
|
+
def truncate_tool_label(text, limit = 40)
|
|
936
|
+
chars = text.to_s.each_char.to_a
|
|
937
|
+
return text.to_s if chars.length <= limit
|
|
938
|
+
|
|
939
|
+
"#{chars.first(limit - 3).join}..."
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
def escape_tool_label(text)
|
|
943
|
+
text.to_s.gsub("\\", "\\\\\\").gsub('"', '\"')
|
|
944
|
+
end
|
|
945
|
+
|
|
946
|
+
def add_conversation_markdown(text)
|
|
947
|
+
markdown = normalize_markdown_for_terminal(text)
|
|
948
|
+
return @shell.add_markdown(markdown) unless stream_markdown?(markdown)
|
|
949
|
+
|
|
950
|
+
id = @shell.add_markdown("", streaming: true)
|
|
951
|
+
return @shell.add_markdown(markdown) unless id
|
|
952
|
+
|
|
953
|
+
thread = Thread.new do
|
|
954
|
+
markdown.each_char.each_slice(STREAMING_MARKDOWN_CHUNK_SIZE) do |chars|
|
|
955
|
+
@shell.append_to_message(id, chars.join)
|
|
956
|
+
sleep(STREAMING_MARKDOWN_DELAY)
|
|
957
|
+
end
|
|
958
|
+
end
|
|
959
|
+
@stream_threads << thread
|
|
960
|
+
@stream_threads.reject! { |item| !item.alive? }
|
|
961
|
+
thread
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
def stream_markdown?(text)
|
|
965
|
+
text.length >= STREAMING_MARKDOWN_THRESHOLD
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
def add_file_summary_after(stream_thread, files)
|
|
969
|
+
return if Array(files).empty?
|
|
970
|
+
|
|
971
|
+
thread = Thread.new do
|
|
972
|
+
stream_thread.join
|
|
973
|
+
add_file_summary(files)
|
|
974
|
+
end
|
|
975
|
+
@stream_threads << thread
|
|
976
|
+
@stream_threads.reject! { |item| !item.alive? }
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
def add_plain_block(text)
|
|
980
|
+
@shell.transcript.add_block(:markdown, expand_ansi_multiline_spans(text), metadata: { plain: true })
|
|
981
|
+
@shell.viewport.scroll_to_bottom
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
def expand_ansi_multiline_spans(text)
|
|
985
|
+
active = +""
|
|
986
|
+
text.to_s.lines.map do |line|
|
|
987
|
+
body = line.chomp
|
|
988
|
+
prefix = body.start_with?("\e[") || active.empty? ? "" : active
|
|
989
|
+
body.scan(/\e\[[0-9;:]*m/).each do |code|
|
|
990
|
+
active = code == RubyRich::AnsiCode.reset ? +"" : code
|
|
991
|
+
end
|
|
992
|
+
suffix = !active.empty? && !body.end_with?(RubyRich::AnsiCode.reset) ? RubyRich::AnsiCode.reset : ""
|
|
993
|
+
"#{prefix}#{body}#{suffix}"
|
|
994
|
+
end.join("\n")
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
def normalize_markdown_for_terminal(text)
|
|
998
|
+
text.to_s
|
|
999
|
+
.gsub(/\r\n?/, "\n")
|
|
1000
|
+
.gsub(/\A[ \t]*\n+/, "")
|
|
1001
|
+
.gsub(/\n+[ \t]*\z/, "")
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def add_file_summary(files)
|
|
1005
|
+
items = Array(files).filter_map do |file|
|
|
1006
|
+
path = file[:path] || file["path"] || file[:name] || file["name"]
|
|
1007
|
+
next if path.to_s.strip.empty?
|
|
1008
|
+
|
|
1009
|
+
"- `#{path}`"
|
|
1010
|
+
end
|
|
1011
|
+
return if items.empty?
|
|
1012
|
+
|
|
1013
|
+
@shell.add_markdown("**Files**\n\n#{items.join("\n")}")
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
def wire_shell_callbacks
|
|
1017
|
+
@shell.on_submit do |text, attachments|
|
|
1018
|
+
reset_task_sidebar_tracking
|
|
1019
|
+
files = Array(attachments).map { |attachment| attachment.respond_to?(:to_h) ? attachment.to_h : attachment }
|
|
1020
|
+
@shell.add_user_message(text)
|
|
1021
|
+
run_callback_async { @input_callback&.call(text, files, display: text) }
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
@shell.on_interrupt do |input_was_empty:|
|
|
1025
|
+
@interrupt_callback&.call(input_was_empty: input_was_empty)
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
@shell.on_mode_toggle do |mode|
|
|
1029
|
+
@config[:mode] = mode.to_s
|
|
1030
|
+
@mode_toggle_callback&.call(mode.to_s)
|
|
1031
|
+
end
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
def session_status
|
|
1035
|
+
[
|
|
1036
|
+
@status || "idle",
|
|
1037
|
+
@config[:mode],
|
|
1038
|
+
@config[:model],
|
|
1039
|
+
"#{@tasks_count} tasks",
|
|
1040
|
+
"$#{@total_cost.round(4)}"
|
|
1041
|
+
].compact.join(" · ")
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
def run_callback_async(&block)
|
|
1045
|
+
@callback_threads.reject! { |thread| !thread.alive? }
|
|
1046
|
+
@callback_threads << Thread.new do
|
|
1047
|
+
block.call
|
|
1048
|
+
rescue StandardError => e
|
|
1049
|
+
show_error(e.message)
|
|
1050
|
+
end
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
def render_welcome_banner
|
|
1054
|
+
@welcome_banner.render_full(
|
|
1055
|
+
working_dir: @config[:working_dir].to_s,
|
|
1056
|
+
mode: @config[:mode].to_s,
|
|
1057
|
+
width: terminal_width
|
|
1058
|
+
)
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
def terminal_width
|
|
1062
|
+
if defined?(TTY::Screen)
|
|
1063
|
+
TTY::Screen.width
|
|
1064
|
+
else
|
|
1065
|
+
120
|
|
1066
|
+
end
|
|
1067
|
+
rescue StandardError
|
|
1068
|
+
120
|
|
1069
|
+
end
|
|
1070
|
+
|
|
1071
|
+
def config_menu_choices(current_config)
|
|
1072
|
+
choices = current_config.models.each_with_index.map do |model, index|
|
|
1073
|
+
type_badge = case model["type"]
|
|
1074
|
+
when "default" then "[default] "
|
|
1075
|
+
when "lite" then "[lite] "
|
|
1076
|
+
else ""
|
|
1077
|
+
end
|
|
1078
|
+
{
|
|
1079
|
+
label: "#{type_badge}#{model["model"] || "unnamed"} (#{mask_api_key(model["api_key"])})",
|
|
1080
|
+
value: { action: :switch, model_id: model["id"] },
|
|
1081
|
+
current: index == current_config.current_model_index
|
|
1082
|
+
}
|
|
1083
|
+
end
|
|
1084
|
+
|
|
1085
|
+
choices + [
|
|
1086
|
+
{ label: "─" * 50, disabled: true },
|
|
1087
|
+
{ label: "[+] Add New Model", value: { action: :add } },
|
|
1088
|
+
{ label: "[*] Edit Current Model", value: { action: :edit } },
|
|
1089
|
+
(current_config.models.length > 1 ? { label: "[-] Delete Model", value: { action: :delete } } : nil),
|
|
1090
|
+
{ label: "[X] Close", value: { action: :close } }
|
|
1091
|
+
].compact
|
|
1092
|
+
end
|
|
1093
|
+
|
|
1094
|
+
def config_initial_selection(choices)
|
|
1095
|
+
choices.index { |choice| choice[:current] } || choices.index { |choice| !choice[:disabled] } || 0
|
|
1096
|
+
end
|
|
1097
|
+
|
|
1098
|
+
def show_menu_dialog(title:, choices:, selected_index: nil)
|
|
1099
|
+
selected_index ||= config_initial_selection(choices)
|
|
1100
|
+
dialog = ConfigMenuDialog.new(title: title, choices: choices, selected_index: selected_index)
|
|
1101
|
+
|
|
1102
|
+
dialog.key(:up, 1_000) { dialog.move_up; true }
|
|
1103
|
+
dialog.key(:down, 1_000) { dialog.move_down; true }
|
|
1104
|
+
dialog.key(:string, 1_000) do |event, _live|
|
|
1105
|
+
case event[:value]
|
|
1106
|
+
when "k" then dialog.move_up
|
|
1107
|
+
when "j" then dialog.move_down
|
|
1108
|
+
when "q" then dialog.finish(nil)
|
|
1109
|
+
end
|
|
1110
|
+
true
|
|
1111
|
+
end
|
|
1112
|
+
dialog.key(:enter, 1_000) do
|
|
1113
|
+
selected = dialog.selected_choice
|
|
1114
|
+
dialog.finish(selected && !selected[:disabled] ? selected[:value] : nil)
|
|
1115
|
+
end
|
|
1116
|
+
dialog.key(:escape, 1_000) { dialog.finish(nil) }
|
|
1117
|
+
|
|
1118
|
+
show_blocking_dialog(dialog)
|
|
1119
|
+
end
|
|
1120
|
+
|
|
1121
|
+
def show_form_dialog(title:, fields:)
|
|
1122
|
+
dialog = FormDialog.new(title: title, fields: fields)
|
|
1123
|
+
dialog.key(:escape, 1_000) { dialog.finish(nil) }
|
|
1124
|
+
show_blocking_dialog(dialog)
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
def show_blocking_dialog(dialog)
|
|
1128
|
+
@shell.layout.show_dialog(dialog)
|
|
1129
|
+
dialog.wait
|
|
1130
|
+
ensure
|
|
1131
|
+
@shell.layout.hide_dialog if @shell.layout.dialog.equal?(dialog)
|
|
1132
|
+
end
|
|
1133
|
+
|
|
1134
|
+
def show_model_edit_form(model, test_callback: nil)
|
|
1135
|
+
is_new = model.nil?
|
|
1136
|
+
model ||= {}
|
|
1137
|
+
selected_provider = nil
|
|
1138
|
+
|
|
1139
|
+
if is_new
|
|
1140
|
+
selected_provider = show_provider_selection
|
|
1141
|
+
return nil if selected_provider.nil?
|
|
1142
|
+
end
|
|
1143
|
+
|
|
1144
|
+
provider_preset = selected_provider && selected_provider != "custom" ? Clacky::Providers.get(selected_provider) : nil
|
|
1145
|
+
default_model = provider_preset ? provider_preset["default_model"] : model["model"]
|
|
1146
|
+
default_base_url = provider_preset ? provider_preset["base_url"] : model["base_url"]
|
|
1147
|
+
masked_key = mask_api_key(model["api_key"])
|
|
1148
|
+
|
|
1149
|
+
fields = [
|
|
1150
|
+
{
|
|
1151
|
+
name: :api_key,
|
|
1152
|
+
label: "API Key #{is_new ? "" : "(current: #{masked_key})"}:",
|
|
1153
|
+
default: "",
|
|
1154
|
+
mask: true,
|
|
1155
|
+
placeholder: is_new ? "required" : "leave blank to keep current"
|
|
1156
|
+
},
|
|
1157
|
+
{
|
|
1158
|
+
name: :model,
|
|
1159
|
+
label: "Model #{is_new && default_model ? "(default: #{default_model})" : (is_new ? "" : "(current: #{model["model"]})")}:",
|
|
1160
|
+
default: default_model || "",
|
|
1161
|
+
placeholder: "model name"
|
|
1162
|
+
},
|
|
1163
|
+
{
|
|
1164
|
+
name: :base_url,
|
|
1165
|
+
label: "Base URL #{is_new && default_base_url ? "(default: #{default_base_url})" : (is_new ? "" : "(current: #{model["base_url"]})")}:",
|
|
1166
|
+
default: default_base_url || "",
|
|
1167
|
+
placeholder: "https://..."
|
|
1168
|
+
}
|
|
1169
|
+
]
|
|
1170
|
+
|
|
1171
|
+
title = if is_new && selected_provider && selected_provider != "custom"
|
|
1172
|
+
provider_name = Clacky::Providers.get(selected_provider)&.dig("name") || selected_provider
|
|
1173
|
+
"Add #{provider_name} Model"
|
|
1174
|
+
elsif is_new
|
|
1175
|
+
"Add Custom Model"
|
|
1176
|
+
else
|
|
1177
|
+
"Edit Model"
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
loop do
|
|
1181
|
+
result = show_form_dialog(title: title, fields: fields)
|
|
1182
|
+
return nil if result.nil?
|
|
1183
|
+
|
|
1184
|
+
values = merge_model_form_values(
|
|
1185
|
+
result,
|
|
1186
|
+
model: model,
|
|
1187
|
+
default_model: default_model,
|
|
1188
|
+
default_base_url: default_base_url
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
validation = validate_model_form(values, is_new: is_new, existing_model: model, test_callback: test_callback)
|
|
1192
|
+
if validation[:success]
|
|
1193
|
+
return values.merge(provider: selected_provider)
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
show_warning(validation[:error])
|
|
1197
|
+
fields.each { |field| field[:default] = result[field[:name]].to_s }
|
|
1198
|
+
end
|
|
1199
|
+
end
|
|
1200
|
+
|
|
1201
|
+
def show_provider_selection
|
|
1202
|
+
choices = Clacky::Providers.list.map { |id, name| { label: name, value: id } }
|
|
1203
|
+
choices << { label: "─" * 40, disabled: true }
|
|
1204
|
+
choices << { label: "Custom (manual configuration)", value: "custom" }
|
|
1205
|
+
show_menu_dialog(title: "Select Provider", choices: choices, selected_index: 0)
|
|
1206
|
+
end
|
|
1207
|
+
|
|
1208
|
+
def merge_model_form_values(result, model:, default_model:, default_base_url:)
|
|
1209
|
+
{
|
|
1210
|
+
api_key: result[:api_key].to_s.empty? ? model["api_key"] : result[:api_key],
|
|
1211
|
+
model: result[:model].to_s.empty? ? (model["model"] || default_model) : result[:model],
|
|
1212
|
+
base_url: result[:base_url].to_s.empty? ? (model["base_url"] || default_base_url) : result[:base_url]
|
|
1213
|
+
}
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
def validate_model_form(values, is_new:, existing_model:, test_callback:)
|
|
1217
|
+
if is_new
|
|
1218
|
+
return { success: false, error: "API Key is required for new model" } if values[:api_key].to_s.empty?
|
|
1219
|
+
return { success: false, error: "Model name is required" } if values[:model].to_s.empty?
|
|
1220
|
+
return { success: false, error: "Base URL is required" } if values[:base_url].to_s.empty?
|
|
1221
|
+
end
|
|
1222
|
+
|
|
1223
|
+
return { success: true } unless test_callback
|
|
1224
|
+
|
|
1225
|
+
temp_config = Clacky::AgentConfig.new(
|
|
1226
|
+
models: [{
|
|
1227
|
+
"api_key" => values[:api_key],
|
|
1228
|
+
"model" => values[:model],
|
|
1229
|
+
"base_url" => values[:base_url],
|
|
1230
|
+
"anthropic_format" => existing_model["anthropic_format"]
|
|
1231
|
+
}],
|
|
1232
|
+
current_model_index: 0
|
|
1233
|
+
)
|
|
1234
|
+
test_callback.call(temp_config)
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1237
|
+
def format_args(args)
|
|
1238
|
+
data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
|
|
1239
|
+
data.is_a?(Hash) ? JSON.pretty_generate(data) : data.to_s
|
|
1240
|
+
end
|
|
1241
|
+
|
|
1242
|
+
def normalize_todo(todo)
|
|
1243
|
+
case todo
|
|
1244
|
+
when Hash
|
|
1245
|
+
title = todo[:content] || todo["content"] || todo[:title] || todo["title"] || todo[:task] || todo["task"]
|
|
1246
|
+
status = todo[:status] || todo["status"] || :pending
|
|
1247
|
+
{ label: title.to_s, title: title.to_s, status: status.to_sym }
|
|
1248
|
+
else
|
|
1249
|
+
{ label: todo.to_s, title: todo.to_s, status: :pending }
|
|
1250
|
+
end
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
def mask_api_key(api_key)
|
|
1254
|
+
key = api_key.to_s
|
|
1255
|
+
return "not set" if key.empty?
|
|
1256
|
+
|
|
1257
|
+
"#{key[0..5]}...#{key[-4..]}"
|
|
1258
|
+
end
|
|
1259
|
+
|
|
1260
|
+
private :track_tool_activity,
|
|
1261
|
+
:update_tool_activity,
|
|
1262
|
+
:refresh_sidebar_tasks,
|
|
1263
|
+
:reset_task_sidebar_tracking,
|
|
1264
|
+
:tool_activity_label,
|
|
1265
|
+
:normalize_tool_args,
|
|
1266
|
+
:compact_tool_arg,
|
|
1267
|
+
:tool_url_host,
|
|
1268
|
+
:truncate_tool_label,
|
|
1269
|
+
:escape_tool_label,
|
|
1270
|
+
:add_conversation_markdown,
|
|
1271
|
+
:stream_markdown?,
|
|
1272
|
+
:add_file_summary_after,
|
|
1273
|
+
:add_plain_block,
|
|
1274
|
+
:expand_ansi_multiline_spans,
|
|
1275
|
+
:normalize_markdown_for_terminal,
|
|
1276
|
+
:add_file_summary,
|
|
1277
|
+
:wire_shell_callbacks,
|
|
1278
|
+
:session_status,
|
|
1279
|
+
:run_callback_async,
|
|
1280
|
+
:render_welcome_banner,
|
|
1281
|
+
:terminal_width,
|
|
1282
|
+
:config_menu_choices,
|
|
1283
|
+
:config_initial_selection,
|
|
1284
|
+
:show_menu_dialog,
|
|
1285
|
+
:show_form_dialog,
|
|
1286
|
+
:show_blocking_dialog,
|
|
1287
|
+
:show_model_edit_form,
|
|
1288
|
+
:show_provider_selection,
|
|
1289
|
+
:merge_model_form_values,
|
|
1290
|
+
:validate_model_form,
|
|
1291
|
+
:format_args,
|
|
1292
|
+
:normalize_todo,
|
|
1293
|
+
:mask_api_key
|
|
1294
|
+
|
|
1295
|
+
class LayoutAdapter
|
|
1296
|
+
def initialize(shell)
|
|
1297
|
+
@shell = shell
|
|
1298
|
+
end
|
|
1299
|
+
|
|
1300
|
+
def clear_output
|
|
1301
|
+
@shell.transcript.store.entries.clear
|
|
1302
|
+
@shell.viewport.scroll_to_bottom
|
|
1303
|
+
end
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
class ProgressHandleAdapter
|
|
1307
|
+
def initialize(handle)
|
|
1308
|
+
@handle = handle
|
|
1309
|
+
end
|
|
1310
|
+
|
|
1311
|
+
def update(message: nil, metadata: nil)
|
|
1312
|
+
_ = metadata
|
|
1313
|
+
@handle.update(message.to_s) if message
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
def finish(final_message: nil)
|
|
1317
|
+
final_message ? @handle.finish(final_message.to_s) : @handle.finish
|
|
1318
|
+
end
|
|
1319
|
+
|
|
1320
|
+
def cancel
|
|
1321
|
+
@handle.cancel
|
|
1322
|
+
end
|
|
1323
|
+
end
|
|
1324
|
+
|
|
1325
|
+
class ConfigMenuDialog
|
|
1326
|
+
attr_accessor :width, :height
|
|
1327
|
+
|
|
1328
|
+
def initialize(choices:, selected_index: 0, title: "Model Configuration", width: 86)
|
|
1329
|
+
@choices = choices
|
|
1330
|
+
@selected_index = selected_index
|
|
1331
|
+
@width = width
|
|
1332
|
+
@height = [choices.length + 7, 12].max
|
|
1333
|
+
@event_listeners = {}
|
|
1334
|
+
@mutex = Mutex.new
|
|
1335
|
+
@condition = ConditionVariable.new
|
|
1336
|
+
@finished = false
|
|
1337
|
+
@result = nil
|
|
1338
|
+
@panel = RubyRich::Panel.new("", title: title, border_style: :cyan, title_align: :center)
|
|
1339
|
+
@layout = RubyRich::Layout.new(name: :config_dialog, width: @width, height: @height)
|
|
1340
|
+
@layout.update_content(@panel)
|
|
1341
|
+
@layout.calculate_dimensions(@width, @height)
|
|
1342
|
+
end
|
|
1343
|
+
|
|
1344
|
+
def selected_choice
|
|
1345
|
+
@choices[@selected_index]
|
|
1346
|
+
end
|
|
1347
|
+
|
|
1348
|
+
def move_up
|
|
1349
|
+
move(-1)
|
|
1350
|
+
end
|
|
1351
|
+
|
|
1352
|
+
def move_down
|
|
1353
|
+
move(1)
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1356
|
+
def finish(value)
|
|
1357
|
+
@mutex.synchronize do
|
|
1358
|
+
@result = value
|
|
1359
|
+
@finished = true
|
|
1360
|
+
@condition.signal
|
|
1361
|
+
end
|
|
1362
|
+
true
|
|
1363
|
+
end
|
|
1364
|
+
|
|
1365
|
+
def wait
|
|
1366
|
+
@mutex.synchronize { @condition.wait(@mutex) until @finished }
|
|
1367
|
+
@result
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1370
|
+
def key(event_name, priority = 0, &block)
|
|
1371
|
+
@event_listeners[event_name] ||= []
|
|
1372
|
+
@event_listeners[event_name] << { priority: priority, block: block }
|
|
1373
|
+
@event_listeners[event_name].sort_by! { |listener| -listener[:priority] }
|
|
1374
|
+
end
|
|
1375
|
+
|
|
1376
|
+
def notify_listeners(event_data)
|
|
1377
|
+
Array(@event_listeners[event_data[:name]]).each { |listener| listener[:block].call(event_data, nil) }
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1380
|
+
def render_to_buffer
|
|
1381
|
+
@panel.content = render_content
|
|
1382
|
+
@layout.calculate_dimensions(@width, @height)
|
|
1383
|
+
@layout.render_to_buffer
|
|
1384
|
+
end
|
|
1385
|
+
|
|
1386
|
+
def move(delta)
|
|
1387
|
+
return if @choices.empty?
|
|
1388
|
+
|
|
1389
|
+
index = @selected_index
|
|
1390
|
+
loop do
|
|
1391
|
+
index = (index + delta) % @choices.length
|
|
1392
|
+
break unless @choices[index][:disabled]
|
|
1393
|
+
break if index == @selected_index
|
|
1394
|
+
end
|
|
1395
|
+
@selected_index = index
|
|
1396
|
+
end
|
|
1397
|
+
|
|
1398
|
+
def render_content
|
|
1399
|
+
lines = [""]
|
|
1400
|
+
@choices.each_with_index do |choice, index|
|
|
1401
|
+
lines << choice_line(choice, selected: index == @selected_index)
|
|
1402
|
+
end
|
|
1403
|
+
lines << ""
|
|
1404
|
+
lines << "#{muted("↑↓/jk: Navigate")} • #{muted("Enter: Select")} • #{muted("Esc/q: Cancel")}"
|
|
1405
|
+
lines.join("\n")
|
|
1406
|
+
end
|
|
1407
|
+
|
|
1408
|
+
def choice_line(choice, selected:)
|
|
1409
|
+
return " #{muted(choice[:label])}" if choice[:disabled]
|
|
1410
|
+
|
|
1411
|
+
prefix = selected ? "#{RubyRich::AnsiCode.color(:cyan, true)}➜#{RubyRich::AnsiCode.reset} " : " "
|
|
1412
|
+
label = selected ? RubyRich::AnsiCode.color(:white, true) + choice[:label] + RubyRich::AnsiCode.reset : choice[:label]
|
|
1413
|
+
"#{prefix}#{label}"
|
|
1414
|
+
end
|
|
1415
|
+
|
|
1416
|
+
def muted(text)
|
|
1417
|
+
"#{RubyRich::AnsiCode.color(:black, true)}#{text}#{RubyRich::AnsiCode.reset}"
|
|
1418
|
+
end
|
|
1419
|
+
|
|
1420
|
+
private :move,
|
|
1421
|
+
:render_content,
|
|
1422
|
+
:choice_line,
|
|
1423
|
+
:muted
|
|
1424
|
+
end
|
|
1425
|
+
|
|
1426
|
+
class FormDialog
|
|
1427
|
+
attr_accessor :width, :height
|
|
1428
|
+
|
|
1429
|
+
def initialize(title:, fields:, width: 92)
|
|
1430
|
+
@title = title
|
|
1431
|
+
@fields = fields
|
|
1432
|
+
@field_index = 0
|
|
1433
|
+
@editors = fields.map do |field|
|
|
1434
|
+
RubyRich::LineEditor.new.tap { |editor| editor.value = field[:default].to_s }
|
|
1435
|
+
end
|
|
1436
|
+
@width = width
|
|
1437
|
+
@height = [fields.length * 3 + 8, 16].max
|
|
1438
|
+
@event_listeners = {}
|
|
1439
|
+
@mutex = Mutex.new
|
|
1440
|
+
@condition = ConditionVariable.new
|
|
1441
|
+
@finished = false
|
|
1442
|
+
@result = nil
|
|
1443
|
+
@panel = RubyRich::Panel.new("", title: title, border_style: :cyan, title_align: :center)
|
|
1444
|
+
@layout = RubyRich::Layout.new(name: :form_dialog, width: @width, height: @height)
|
|
1445
|
+
@layout.update_content(@panel)
|
|
1446
|
+
@layout.calculate_dimensions(@width, @height)
|
|
1447
|
+
wire_default_keys
|
|
1448
|
+
end
|
|
1449
|
+
|
|
1450
|
+
def finish(value)
|
|
1451
|
+
@mutex.synchronize do
|
|
1452
|
+
@result = value
|
|
1453
|
+
@finished = true
|
|
1454
|
+
@condition.signal
|
|
1455
|
+
end
|
|
1456
|
+
true
|
|
1457
|
+
end
|
|
1458
|
+
|
|
1459
|
+
def wait
|
|
1460
|
+
@mutex.synchronize { @condition.wait(@mutex) until @finished }
|
|
1461
|
+
@result
|
|
1462
|
+
end
|
|
1463
|
+
|
|
1464
|
+
def key(event_name, priority = 0, &block)
|
|
1465
|
+
@event_listeners[event_name] ||= []
|
|
1466
|
+
@event_listeners[event_name] << { priority: priority, block: block }
|
|
1467
|
+
@event_listeners[event_name].sort_by! { |listener| -listener[:priority] }
|
|
1468
|
+
end
|
|
1469
|
+
|
|
1470
|
+
def notify_listeners(event_data)
|
|
1471
|
+
listeners = Array(@event_listeners[event_data[:name]])
|
|
1472
|
+
listeners.each { |listener| listener[:block].call(event_data, nil) }
|
|
1473
|
+
end
|
|
1474
|
+
|
|
1475
|
+
def render_to_buffer
|
|
1476
|
+
@panel.content = render_content
|
|
1477
|
+
@layout.calculate_dimensions(@width, @height)
|
|
1478
|
+
@layout.render_to_buffer
|
|
1479
|
+
end
|
|
1480
|
+
|
|
1481
|
+
def wire_default_keys
|
|
1482
|
+
key(:string, 100) { |event, _live| current_editor.insert(event[:value]); true }
|
|
1483
|
+
key(:paste, 100) { |event, _live| current_editor.insert(event[:value]); true }
|
|
1484
|
+
key(:backspace, 100) { current_editor.backspace; true }
|
|
1485
|
+
key(:delete, 100) { current_editor.delete; true }
|
|
1486
|
+
key(:left, 100) { current_editor.move_left; true }
|
|
1487
|
+
key(:right, 100) { current_editor.move_right; true }
|
|
1488
|
+
key(:ctrl_a, 100) { current_editor.buffer_start; true }
|
|
1489
|
+
key(:ctrl_e, 100) { current_editor.buffer_end; true }
|
|
1490
|
+
key(:up, 100) { move_field(-1); true }
|
|
1491
|
+
key(:down, 100) { move_field(1); true }
|
|
1492
|
+
key(:tab, 100) { move_field(1); true }
|
|
1493
|
+
key(:shift_tab, 100) { move_field(-1); true }
|
|
1494
|
+
key(:enter, 100) { finish(values); true }
|
|
1495
|
+
end
|
|
1496
|
+
|
|
1497
|
+
def current_editor
|
|
1498
|
+
@editors[@field_index]
|
|
1499
|
+
end
|
|
1500
|
+
|
|
1501
|
+
def move_field(delta)
|
|
1502
|
+
@field_index = (@field_index + delta) % @fields.length
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
def values
|
|
1506
|
+
@fields.each_with_index.to_h { |field, index| [field[:name].to_sym, @editors[index].value] }
|
|
1507
|
+
end
|
|
1508
|
+
|
|
1509
|
+
def render_content
|
|
1510
|
+
lines = [""]
|
|
1511
|
+
@fields.each_with_index do |field, index|
|
|
1512
|
+
focused = index == @field_index
|
|
1513
|
+
marker = focused ? "#{RubyRich::AnsiCode.color(:cyan, true)}➜#{RubyRich::AnsiCode.reset}" : " "
|
|
1514
|
+
label = focused ? "#{RubyRich::AnsiCode.color(:white, true)}#{field[:label]}#{RubyRich::AnsiCode.reset}" : field[:label]
|
|
1515
|
+
lines << "#{marker} #{label}"
|
|
1516
|
+
lines << " #{render_field_value(field, @editors[index], focused: focused)}"
|
|
1517
|
+
lines << ""
|
|
1518
|
+
end
|
|
1519
|
+
lines << "#{muted("Tab/↑↓: Field")} • #{muted("Enter: Save")} • #{muted("Esc: Cancel")}"
|
|
1520
|
+
lines.join("\n")
|
|
1521
|
+
end
|
|
1522
|
+
|
|
1523
|
+
def render_field_value(field, editor, focused:)
|
|
1524
|
+
raw = editor.value
|
|
1525
|
+
text = if field[:mask] && !raw.empty?
|
|
1526
|
+
"*" * raw.length
|
|
1527
|
+
elsif raw.empty?
|
|
1528
|
+
field[:placeholder].to_s
|
|
1529
|
+
else
|
|
1530
|
+
raw
|
|
1531
|
+
end
|
|
1532
|
+
color = raw.empty? ? :black : (focused ? :cyan : :white)
|
|
1533
|
+
"#{RubyRich::AnsiCode.color(color, true)}#{text}#{RubyRich::AnsiCode.reset}"
|
|
1534
|
+
end
|
|
1535
|
+
|
|
1536
|
+
def muted(text)
|
|
1537
|
+
"#{RubyRich::AnsiCode.color(:black, true)}#{text}#{RubyRich::AnsiCode.reset}"
|
|
1538
|
+
end
|
|
1539
|
+
|
|
1540
|
+
private :wire_default_keys,
|
|
1541
|
+
:current_editor,
|
|
1542
|
+
:move_field,
|
|
1543
|
+
:values,
|
|
1544
|
+
:render_content,
|
|
1545
|
+
:render_field_value,
|
|
1546
|
+
:muted
|
|
1547
|
+
end
|
|
1548
|
+
end
|
|
1549
|
+
end
|