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.
@@ -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