openclacky 1.3.3 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/docs/rich_ui_guide.md +277 -0
  4. data/docs/rich_ui_refactor_plan.md +396 -0
  5. data/lib/clacky/agent/llm_caller.rb +10 -4
  6. data/lib/clacky/agent/session_serializer.rb +3 -2
  7. data/lib/clacky/agent.rb +3 -2
  8. data/lib/clacky/agent_config.rb +2 -14
  9. data/lib/clacky/api_extension.rb +262 -0
  10. data/lib/clacky/api_extension_loader.rb +156 -0
  11. data/lib/clacky/cli.rb +93 -3
  12. data/lib/clacky/client.rb +38 -13
  13. data/lib/clacky/default_agents/_panels/git/panel.js +1 -1
  14. data/lib/clacky/default_agents/_panels/time_machine/panel.js +1 -1
  15. data/lib/clacky/default_skills/media-gen/SKILL.md +9 -6
  16. data/lib/clacky/idle_compression_timer.rb +3 -1
  17. data/lib/clacky/locales/en.rb +26 -0
  18. data/lib/clacky/locales/i18n.rb +26 -0
  19. data/lib/clacky/locales/zh.rb +26 -0
  20. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  21. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  22. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  23. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  24. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  25. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  26. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  27. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  28. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  29. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  30. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  31. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  32. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  33. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  34. data/lib/clacky/rich_ui.rb +57 -0
  35. data/lib/clacky/rich_ui_controller.rb +3 -1549
  36. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  37. data/lib/clacky/server/http_server.rb +150 -103
  38. data/lib/clacky/server/session_registry.rb +1 -1
  39. data/lib/clacky/shell_hook_loader.rb +1 -1
  40. data/lib/clacky/tools/edit.rb +14 -2
  41. data/lib/clacky/ui2/ui_controller.rb +7 -0
  42. data/lib/clacky/version.rb +1 -1
  43. data/lib/clacky/web/app.css +56 -59
  44. data/lib/clacky/web/app.js +65 -7
  45. data/lib/clacky/web/components/onboard.js +18 -2
  46. data/lib/clacky/web/core/aside.js +8 -3
  47. data/lib/clacky/web/core/ext.js +1 -1
  48. data/lib/clacky/web/features/skills/store.js +30 -2
  49. data/lib/clacky/web/features/skills/view.js +32 -1
  50. data/lib/clacky/web/features/workspace/view.js +1 -1
  51. data/lib/clacky/web/i18n.js +32 -20
  52. data/lib/clacky/web/index.html +9 -17
  53. data/lib/clacky/web/sessions.js +286 -28
  54. data/lib/clacky/web/settings.js +109 -111
  55. data/lib/clacky/web/ws-dispatcher.js +7 -3
  56. data/lib/clacky.rb +17 -2
  57. metadata +38 -2
  58. data/lib/clacky/media/output_dir.rb +0 -43
@@ -1,1551 +1,5 @@
1
1
  # frozen_string_literal: true
2
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
- # Clears the screen on exit by default — the Rich UI repaints fullscreen
563
- # and leaves no useful scrollback to preserve.
564
- def stop(clear_screen: true)
565
- @running = false
566
- @shell.stop
567
- RubyRich::Terminal.clear if clear_screen
568
- end
569
-
570
- def set_skill_loader(_skill_loader, _agent_profile = nil); end
571
- def set_agent(_agent, _agent_profile = nil); end
572
-
573
- def on_input(&block)
574
- @input_callback = block
575
- end
576
-
577
- def on_interrupt(&block)
578
- @interrupt_callback = block
579
- end
580
-
581
- def on_mode_toggle(&block)
582
- @mode_toggle_callback = block
583
- end
584
-
585
- def on_time_machine(&block)
586
- @time_machine_callback = block
587
- end
588
-
589
- def append_output(content)
590
- return if content.nil?
591
-
592
- @shell.add_markdown(content.to_s)
593
- end
594
-
595
- def log(message, level: :info)
596
- case level.to_sym
597
- when :error then show_error(message)
598
- when :warning, :warn then show_warning(message)
599
- when :debug then nil
600
- else show_info(message)
601
- end
602
- end
603
-
604
- def show_assistant_message(content, files:)
605
- text = filter_thinking_tags(content)
606
- stream_thread = nil
607
- stream_thread = add_conversation_markdown(text) unless text.nil? || text.strip.empty?
608
- if stream_thread.is_a?(Thread)
609
- add_file_summary_after(stream_thread, files)
610
- else
611
- add_file_summary(files)
612
- end
613
- end
614
-
615
- def show_tool_call(name, args)
616
- id = @shell.start_tool_call(name: name.to_s, input: format_args(args), status: :running)
617
- if id
618
- @tool_ids << id
619
- track_tool_activity(id, tool_activity_label(name, args), :running)
620
- end
621
- end
622
-
623
- def show_tool_result(result)
624
- if (id = @tool_ids.pop)
625
- @shell.finish_tool_call(id, status: :done, output: result.to_s)
626
- update_tool_activity(id, :done)
627
- else
628
- @shell.add_markdown(result.to_s)
629
- end
630
- end
631
-
632
- def show_tool_stdout(lines)
633
- @stdout_lines.concat(Array(lines).map(&:to_s))
634
- end
635
-
636
- def show_tool_error(error)
637
- message = error.is_a?(Exception) ? error.message : error.to_s
638
- if (id = @tool_ids.pop)
639
- @shell.finish_tool_call(id, status: :error, output: message)
640
- update_tool_activity(id, :error)
641
- else
642
- @shell.add_error_message(message)
643
- end
644
- end
645
-
646
- def show_tool_args(formatted_args)
647
- append_output("Args: #{formatted_args}")
648
- end
649
-
650
- def show_file_write_preview(path, is_new_file:)
651
- append_output("#{is_new_file ? "Creating" : "Modifying"} file: #{path || "(unknown)"}")
652
- end
653
-
654
- def show_file_edit_preview(path)
655
- append_output("Editing file: #{path || "(unknown)"}")
656
- end
657
-
658
- def show_file_error(error_message)
659
- show_error(error_message)
660
- end
661
-
662
- def show_shell_preview(command)
663
- append_output("$ #{command}")
664
- end
665
-
666
- def show_diff(old_content, new_content, max_lines: 50)
667
- require "diffy"
668
- diff = Diffy::Diff.new(old_content, new_content, context: 3).to_s(:color)
669
- lines = diff.lines
670
- visible = lines.take(max_lines).join
671
- hidden = lines.length - max_lines
672
- visible += "\n... (#{hidden} more lines hidden)" if hidden.positive?
673
- @shell.add_diff(content: visible)
674
- rescue LoadError
675
- append_output("Old size: #{old_content.bytesize} bytes\nNew size: #{new_content.bytesize} bytes")
676
- end
677
-
678
- def show_token_usage(token_data)
679
- @shell.show_token_usage(
680
- input: token_data[:prompt_tokens],
681
- output: token_data[:completion_tokens],
682
- total: token_data[:total_tokens],
683
- cost: token_data[:cost]
684
- )
685
- end
686
-
687
- def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
688
- set_idle_status
689
- return if awaiting_user_feedback || iterations <= 5
690
-
691
- parts = ["Completed #{iterations} iterations", "cost $#{cost.round(4)}"]
692
- parts << "#{duration.round(1)}s" if duration
693
- append_output(parts.join(" · "))
694
- end
695
-
696
- def show_info(message, prefix_newline: true)
697
- _ = prefix_newline
698
- @shell.add_system_message(message.to_s)
699
- end
700
-
701
- def show_warning(message)
702
- @shell.add_system_message("Warning: #{message}")
703
- end
704
-
705
- def show_error(message)
706
- @shell.add_error_message(message.to_s)
707
- end
708
-
709
- def show_success(message)
710
- @shell.add_system_message("OK: #{message}")
711
- end
712
-
713
- def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
714
- _ = prefix_newline
715
- type = progress_type.to_s
716
- if phase.to_s == "done"
717
- @legacy_progress.delete(type)&.finish(final_message: message)
718
- return
719
- end
720
-
721
- handle = @legacy_progress[type]
722
- if handle
723
- handle.update(message: message, metadata: metadata)
724
- else
725
- @legacy_progress[type] = start_progress(message: message, style: type == "thinking" ? :primary : :quiet)
726
- end
727
- end
728
-
729
- def start_progress(message: nil, style: :primary, quiet_on_fast_finish: false)
730
- _ = quiet_on_fast_finish
731
- ProgressHandleAdapter.new(@shell.start_progress(message || "Working", style: style))
732
- end
733
-
734
- def with_progress(message: nil, style: :primary, quiet_on_fast_finish: false)
735
- handle = start_progress(message: message, style: style, quiet_on_fast_finish: quiet_on_fast_finish)
736
- begin
737
- yield handle
738
- ensure
739
- handle.finish
740
- end
741
- end
742
-
743
- def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil, session_id: nil)
744
- _ = cost_source
745
- _ = latency
746
- @tasks_count = tasks if tasks
747
- @total_cost = cost if cost
748
- @status = status if status
749
- @shell.update_status(session_status)
750
- end
751
-
752
- def update_todos(todos)
753
- @todo_items = Array(todos).map { |todo| normalize_todo(todo) }
754
- @explicit_todo_cycle = true
755
- refresh_sidebar_tasks
756
- end
757
-
758
- def set_working_status
759
- update_sessionbar(status: "working")
760
- end
761
-
762
- def set_idle_status
763
- update_sessionbar(status: "idle")
764
- end
765
-
766
- def request_confirmation(message, default: true)
767
- show_info(message)
768
- @shell.confirm(
769
- title: "Confirm",
770
- message: message,
771
- choices: [{ key: true, label: "Yes" }, { key: false, label: "No" }],
772
- default: default
773
- )
774
- end
775
-
776
- def clear_input
777
- @shell.composer.editor.clear
778
- end
779
-
780
- def set_input_tips(message, type: :info)
781
- update_sessionbar(status: "#{type}: #{message}")
782
- end
783
-
784
- def show_help
785
- @shell.add_markdown(<<~HELP)
786
- Commands:
787
- /clear - Clear output and restart session
788
- /exit - Exit application
789
-
790
- Input:
791
- Shift+Enter - New line
792
- Up/Down - History navigation
793
- Ctrl+C - Interrupt current task
794
- HELP
795
- end
796
-
797
- def show_config_modal(current_config, test_callback: nil)
798
- return nil unless @running
799
-
800
- loop do
801
- choices = config_menu_choices(current_config)
802
- result = show_menu_dialog(
803
- title: "Model Configuration",
804
- choices: choices,
805
- selected_index: config_initial_selection(choices)
806
- )
807
- return nil if result.nil?
808
-
809
- case result[:action]
810
- when :switch
811
- return result
812
- when :add
813
- new_model = show_model_edit_form(nil, test_callback: test_callback)
814
- if new_model
815
- anthropic_format = new_model[:provider] == "anthropic"
816
- current_config.add_model(
817
- model: new_model[:model],
818
- api_key: new_model[:api_key],
819
- base_url: new_model[:base_url],
820
- anthropic_format: anthropic_format
821
- )
822
- new_id = current_config.models.last["id"]
823
- return { action: :add, model_id: new_id }
824
- end
825
- when :edit
826
- current_model = current_config.current_model
827
- edited = show_model_edit_form(current_model, test_callback: test_callback)
828
- if edited
829
- current_model["api_key"] = edited[:api_key]
830
- current_model["model"] = edited[:model]
831
- current_model["base_url"] = edited[:base_url]
832
- return { action: :edit, model_id: current_model["id"] }
833
- end
834
- when :delete
835
- if current_config.models.length <= 1
836
- show_warning("Cannot delete the last model.")
837
- next
838
- end
839
-
840
- current_config.remove_model(current_config.current_model_index)
841
- new_current = current_config.current_model
842
- return { action: :delete, model_id: new_current && new_current["id"] }
843
- when :close
844
- return nil
845
- end
846
- end
847
- end
848
-
849
- def filter_thinking_tags(content)
850
- return content if content.nil?
851
-
852
- content.gsub(%r{<think(?:ing)?>[\s\S]*?</think(?:ing)?>}mi, "").gsub(/\n{3,}/, "\n\n").strip
853
- end
854
-
855
- def track_tool_activity(id, label, status)
856
- activity = { id: id, label: label.to_s, status: status }
857
- @tool_activities << activity
858
- @tool_activities.shift while @tool_activities.length > 12
859
- @tool_activity_by_id[id] = activity
860
- refresh_sidebar_tasks
861
- end
862
-
863
- def update_tool_activity(id, status)
864
- activity = @tool_activity_by_id[id]
865
- return unless activity
866
-
867
- activity[:status] = status
868
- refresh_sidebar_tasks
869
- end
870
-
871
- def refresh_sidebar_tasks
872
- tasks = if @todo_items.empty?
873
- @explicit_todo_cycle ? [] : @tool_activities
874
- else
875
- @todo_items
876
- end
877
- @shell.update_tasks(tasks)
878
- end
879
-
880
- def reset_task_sidebar_tracking
881
- @todo_items = []
882
- @explicit_todo_cycle = false
883
- @tool_activities = []
884
- @tool_activity_by_id = {}
885
- refresh_sidebar_tasks
886
- end
887
-
888
- def tool_activity_label(name, args)
889
- tool_name = name.to_s
890
- data = normalize_tool_args(args)
891
-
892
- case tool_name
893
- when "web_search"
894
- query = data["query"].to_s
895
- return tool_name if query.empty?
896
-
897
- %(web_search("#{escape_tool_label(truncate_tool_label(query))}"))
898
- when "web_fetch"
899
- url = data["url"].to_s
900
- return tool_name if url.empty?
901
-
902
- "web_fetch(#{truncate_tool_label(tool_url_host(url))})"
903
- else
904
- compact = compact_tool_arg(data)
905
- compact ? "#{tool_name}(#{compact})" : tool_name
906
- end
907
- end
908
-
909
- def normalize_tool_args(args)
910
- parsed = if args.is_a?(String)
911
- JSON.parse(args)
912
- else
913
- args
914
- end
915
- return {} unless parsed.is_a?(Hash)
916
-
917
- parsed.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value }
918
- rescue JSON::ParserError
919
- {}
920
- end
921
-
922
- def compact_tool_arg(data)
923
- key = %w[query url path file command pattern task].find { |candidate| data.key?(candidate) && !data[candidate].to_s.empty? }
924
- return nil unless key
925
-
926
- value = key == "url" ? tool_url_host(data[key].to_s) : data[key].to_s
927
- escaped = escape_tool_label(truncate_tool_label(value))
928
- value.match?(/\A[\w.-]+\z/) ? escaped : %("#{escaped}")
929
- end
930
-
931
- def tool_url_host(url)
932
- URI.parse(url).host || url
933
- rescue URI::InvalidURIError
934
- url
935
- end
936
-
937
- def truncate_tool_label(text, limit = 40)
938
- chars = text.to_s.each_char.to_a
939
- return text.to_s if chars.length <= limit
940
-
941
- "#{chars.first(limit - 3).join}..."
942
- end
943
-
944
- def escape_tool_label(text)
945
- text.to_s.gsub("\\", "\\\\\\").gsub('"', '\"')
946
- end
947
-
948
- def add_conversation_markdown(text)
949
- markdown = normalize_markdown_for_terminal(text)
950
- return @shell.add_markdown(markdown) unless stream_markdown?(markdown)
951
-
952
- id = @shell.add_markdown("", streaming: true)
953
- return @shell.add_markdown(markdown) unless id
954
-
955
- thread = Thread.new do
956
- markdown.each_char.each_slice(STREAMING_MARKDOWN_CHUNK_SIZE) do |chars|
957
- @shell.append_to_message(id, chars.join)
958
- sleep(STREAMING_MARKDOWN_DELAY)
959
- end
960
- end
961
- @stream_threads << thread
962
- @stream_threads.reject! { |item| !item.alive? }
963
- thread
964
- end
965
-
966
- def stream_markdown?(text)
967
- text.length >= STREAMING_MARKDOWN_THRESHOLD
968
- end
969
-
970
- def add_file_summary_after(stream_thread, files)
971
- return if Array(files).empty?
972
-
973
- thread = Thread.new do
974
- stream_thread.join
975
- add_file_summary(files)
976
- end
977
- @stream_threads << thread
978
- @stream_threads.reject! { |item| !item.alive? }
979
- end
980
-
981
- def add_plain_block(text)
982
- @shell.transcript.add_block(:markdown, expand_ansi_multiline_spans(text), metadata: { plain: true })
983
- @shell.viewport.scroll_to_bottom
984
- end
985
-
986
- def expand_ansi_multiline_spans(text)
987
- active = +""
988
- text.to_s.lines.map do |line|
989
- body = line.chomp
990
- prefix = body.start_with?("\e[") || active.empty? ? "" : active
991
- body.scan(/\e\[[0-9;:]*m/).each do |code|
992
- active = code == RubyRich::AnsiCode.reset ? +"" : code
993
- end
994
- suffix = !active.empty? && !body.end_with?(RubyRich::AnsiCode.reset) ? RubyRich::AnsiCode.reset : ""
995
- "#{prefix}#{body}#{suffix}"
996
- end.join("\n")
997
- end
998
-
999
- def normalize_markdown_for_terminal(text)
1000
- text.to_s
1001
- .gsub(/\r\n?/, "\n")
1002
- .gsub(/\A[ \t]*\n+/, "")
1003
- .gsub(/\n+[ \t]*\z/, "")
1004
- end
1005
-
1006
- def add_file_summary(files)
1007
- items = Array(files).filter_map do |file|
1008
- path = file[:path] || file["path"] || file[:name] || file["name"]
1009
- next if path.to_s.strip.empty?
1010
-
1011
- "- `#{path}`"
1012
- end
1013
- return if items.empty?
1014
-
1015
- @shell.add_markdown("**Files**\n\n#{items.join("\n")}")
1016
- end
1017
-
1018
- def wire_shell_callbacks
1019
- @shell.on_submit do |text, attachments|
1020
- reset_task_sidebar_tracking
1021
- files = Array(attachments).map { |attachment| attachment.respond_to?(:to_h) ? attachment.to_h : attachment }
1022
- @shell.add_user_message(text)
1023
- run_callback_async { @input_callback&.call(text, files, display: text) }
1024
- end
1025
-
1026
- @shell.on_interrupt do |input_was_empty:|
1027
- @interrupt_callback&.call(input_was_empty: input_was_empty)
1028
- end
1029
-
1030
- @shell.on_mode_toggle do |mode|
1031
- @config[:mode] = mode.to_s
1032
- @mode_toggle_callback&.call(mode.to_s)
1033
- end
1034
- end
1035
-
1036
- def session_status
1037
- [
1038
- @status || "idle",
1039
- @config[:mode],
1040
- @config[:model],
1041
- "#{@tasks_count} tasks",
1042
- "$#{@total_cost.round(4)}"
1043
- ].compact.join(" · ")
1044
- end
1045
-
1046
- def run_callback_async(&block)
1047
- @callback_threads.reject! { |thread| !thread.alive? }
1048
- @callback_threads << Thread.new do
1049
- block.call
1050
- rescue StandardError => e
1051
- show_error(e.message)
1052
- end
1053
- end
1054
-
1055
- def render_welcome_banner
1056
- @welcome_banner.render_full(
1057
- working_dir: @config[:working_dir].to_s,
1058
- mode: @config[:mode].to_s,
1059
- width: terminal_width
1060
- )
1061
- end
1062
-
1063
- def terminal_width
1064
- if defined?(TTY::Screen)
1065
- TTY::Screen.width
1066
- else
1067
- 120
1068
- end
1069
- rescue StandardError
1070
- 120
1071
- end
1072
-
1073
- def config_menu_choices(current_config)
1074
- choices = current_config.models.each_with_index.map do |model, index|
1075
- type_badge = case model["type"]
1076
- when "default" then "[default] "
1077
- when "lite" then "[lite] "
1078
- else ""
1079
- end
1080
- {
1081
- label: "#{type_badge}#{model["model"] || "unnamed"} (#{mask_api_key(model["api_key"])})",
1082
- value: { action: :switch, model_id: model["id"] },
1083
- current: index == current_config.current_model_index
1084
- }
1085
- end
1086
-
1087
- choices + [
1088
- { label: "─" * 50, disabled: true },
1089
- { label: "[+] Add New Model", value: { action: :add } },
1090
- { label: "[*] Edit Current Model", value: { action: :edit } },
1091
- (current_config.models.length > 1 ? { label: "[-] Delete Model", value: { action: :delete } } : nil),
1092
- { label: "[X] Close", value: { action: :close } }
1093
- ].compact
1094
- end
1095
-
1096
- def config_initial_selection(choices)
1097
- choices.index { |choice| choice[:current] } || choices.index { |choice| !choice[:disabled] } || 0
1098
- end
1099
-
1100
- def show_menu_dialog(title:, choices:, selected_index: nil)
1101
- selected_index ||= config_initial_selection(choices)
1102
- dialog = ConfigMenuDialog.new(title: title, choices: choices, selected_index: selected_index)
1103
-
1104
- dialog.key(:up, 1_000) { dialog.move_up; true }
1105
- dialog.key(:down, 1_000) { dialog.move_down; true }
1106
- dialog.key(:string, 1_000) do |event, _live|
1107
- case event[:value]
1108
- when "k" then dialog.move_up
1109
- when "j" then dialog.move_down
1110
- when "q" then dialog.finish(nil)
1111
- end
1112
- true
1113
- end
1114
- dialog.key(:enter, 1_000) do
1115
- selected = dialog.selected_choice
1116
- dialog.finish(selected && !selected[:disabled] ? selected[:value] : nil)
1117
- end
1118
- dialog.key(:escape, 1_000) { dialog.finish(nil) }
1119
-
1120
- show_blocking_dialog(dialog)
1121
- end
1122
-
1123
- def show_form_dialog(title:, fields:)
1124
- dialog = FormDialog.new(title: title, fields: fields)
1125
- dialog.key(:escape, 1_000) { dialog.finish(nil) }
1126
- show_blocking_dialog(dialog)
1127
- end
1128
-
1129
- def show_blocking_dialog(dialog)
1130
- @shell.layout.show_dialog(dialog)
1131
- dialog.wait
1132
- ensure
1133
- @shell.layout.hide_dialog if @shell.layout.dialog.equal?(dialog)
1134
- end
1135
-
1136
- def show_model_edit_form(model, test_callback: nil)
1137
- is_new = model.nil?
1138
- model ||= {}
1139
- selected_provider = nil
1140
-
1141
- if is_new
1142
- selected_provider = show_provider_selection
1143
- return nil if selected_provider.nil?
1144
- end
1145
-
1146
- provider_preset = selected_provider && selected_provider != "custom" ? Clacky::Providers.get(selected_provider) : nil
1147
- default_model = provider_preset ? provider_preset["default_model"] : model["model"]
1148
- default_base_url = provider_preset ? provider_preset["base_url"] : model["base_url"]
1149
- masked_key = mask_api_key(model["api_key"])
1150
-
1151
- fields = [
1152
- {
1153
- name: :api_key,
1154
- label: "API Key #{is_new ? "" : "(current: #{masked_key})"}:",
1155
- default: "",
1156
- mask: true,
1157
- placeholder: is_new ? "required" : "leave blank to keep current"
1158
- },
1159
- {
1160
- name: :model,
1161
- label: "Model #{is_new && default_model ? "(default: #{default_model})" : (is_new ? "" : "(current: #{model["model"]})")}:",
1162
- default: default_model || "",
1163
- placeholder: "model name"
1164
- },
1165
- {
1166
- name: :base_url,
1167
- label: "Base URL #{is_new && default_base_url ? "(default: #{default_base_url})" : (is_new ? "" : "(current: #{model["base_url"]})")}:",
1168
- default: default_base_url || "",
1169
- placeholder: "https://..."
1170
- }
1171
- ]
1172
-
1173
- title = if is_new && selected_provider && selected_provider != "custom"
1174
- provider_name = Clacky::Providers.get(selected_provider)&.dig("name") || selected_provider
1175
- "Add #{provider_name} Model"
1176
- elsif is_new
1177
- "Add Custom Model"
1178
- else
1179
- "Edit Model"
1180
- end
1181
-
1182
- loop do
1183
- result = show_form_dialog(title: title, fields: fields)
1184
- return nil if result.nil?
1185
-
1186
- values = merge_model_form_values(
1187
- result,
1188
- model: model,
1189
- default_model: default_model,
1190
- default_base_url: default_base_url
1191
- )
1192
-
1193
- validation = validate_model_form(values, is_new: is_new, existing_model: model, test_callback: test_callback)
1194
- if validation[:success]
1195
- return values.merge(provider: selected_provider)
1196
- end
1197
-
1198
- show_warning(validation[:error])
1199
- fields.each { |field| field[:default] = result[field[:name]].to_s }
1200
- end
1201
- end
1202
-
1203
- def show_provider_selection
1204
- choices = Clacky::Providers.list.map { |id, name| { label: name, value: id } }
1205
- choices << { label: "─" * 40, disabled: true }
1206
- choices << { label: "Custom (manual configuration)", value: "custom" }
1207
- show_menu_dialog(title: "Select Provider", choices: choices, selected_index: 0)
1208
- end
1209
-
1210
- def merge_model_form_values(result, model:, default_model:, default_base_url:)
1211
- {
1212
- api_key: result[:api_key].to_s.empty? ? model["api_key"] : result[:api_key],
1213
- model: result[:model].to_s.empty? ? (model["model"] || default_model) : result[:model],
1214
- base_url: result[:base_url].to_s.empty? ? (model["base_url"] || default_base_url) : result[:base_url]
1215
- }
1216
- end
1217
-
1218
- def validate_model_form(values, is_new:, existing_model:, test_callback:)
1219
- if is_new
1220
- return { success: false, error: "API Key is required for new model" } if values[:api_key].to_s.empty?
1221
- return { success: false, error: "Model name is required" } if values[:model].to_s.empty?
1222
- return { success: false, error: "Base URL is required" } if values[:base_url].to_s.empty?
1223
- end
1224
-
1225
- return { success: true } unless test_callback
1226
-
1227
- temp_config = Clacky::AgentConfig.new(
1228
- models: [{
1229
- "api_key" => values[:api_key],
1230
- "model" => values[:model],
1231
- "base_url" => values[:base_url],
1232
- "anthropic_format" => existing_model["anthropic_format"]
1233
- }],
1234
- current_model_index: 0
1235
- )
1236
- test_callback.call(temp_config)
1237
- end
1238
-
1239
- def format_args(args)
1240
- data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
1241
- data.is_a?(Hash) ? JSON.pretty_generate(data) : data.to_s
1242
- end
1243
-
1244
- def normalize_todo(todo)
1245
- case todo
1246
- when Hash
1247
- title = todo[:content] || todo["content"] || todo[:title] || todo["title"] || todo[:task] || todo["task"]
1248
- status = todo[:status] || todo["status"] || :pending
1249
- { label: title.to_s, title: title.to_s, status: status.to_sym }
1250
- else
1251
- { label: todo.to_s, title: todo.to_s, status: :pending }
1252
- end
1253
- end
1254
-
1255
- def mask_api_key(api_key)
1256
- key = api_key.to_s
1257
- return "not set" if key.empty?
1258
-
1259
- "#{key[0..5]}...#{key[-4..]}"
1260
- end
1261
-
1262
- private :track_tool_activity,
1263
- :update_tool_activity,
1264
- :refresh_sidebar_tasks,
1265
- :reset_task_sidebar_tracking,
1266
- :tool_activity_label,
1267
- :normalize_tool_args,
1268
- :compact_tool_arg,
1269
- :tool_url_host,
1270
- :truncate_tool_label,
1271
- :escape_tool_label,
1272
- :add_conversation_markdown,
1273
- :stream_markdown?,
1274
- :add_file_summary_after,
1275
- :add_plain_block,
1276
- :expand_ansi_multiline_spans,
1277
- :normalize_markdown_for_terminal,
1278
- :add_file_summary,
1279
- :wire_shell_callbacks,
1280
- :session_status,
1281
- :run_callback_async,
1282
- :render_welcome_banner,
1283
- :terminal_width,
1284
- :config_menu_choices,
1285
- :config_initial_selection,
1286
- :show_menu_dialog,
1287
- :show_form_dialog,
1288
- :show_blocking_dialog,
1289
- :show_model_edit_form,
1290
- :show_provider_selection,
1291
- :merge_model_form_values,
1292
- :validate_model_form,
1293
- :format_args,
1294
- :normalize_todo,
1295
- :mask_api_key
1296
-
1297
- class LayoutAdapter
1298
- def initialize(shell)
1299
- @shell = shell
1300
- end
1301
-
1302
- def clear_output
1303
- @shell.transcript.store.entries.clear
1304
- @shell.viewport.scroll_to_bottom
1305
- end
1306
- end
1307
-
1308
- class ProgressHandleAdapter
1309
- def initialize(handle)
1310
- @handle = handle
1311
- end
1312
-
1313
- def update(message: nil, metadata: nil)
1314
- _ = metadata
1315
- @handle.update(message.to_s) if message
1316
- end
1317
-
1318
- def finish(final_message: nil)
1319
- final_message ? @handle.finish(final_message.to_s) : @handle.finish
1320
- end
1321
-
1322
- def cancel
1323
- @handle.cancel
1324
- end
1325
- end
1326
-
1327
- class ConfigMenuDialog
1328
- attr_accessor :width, :height
1329
-
1330
- def initialize(choices:, selected_index: 0, title: "Model Configuration", width: 86)
1331
- @choices = choices
1332
- @selected_index = selected_index
1333
- @width = width
1334
- @height = [choices.length + 7, 12].max
1335
- @event_listeners = {}
1336
- @mutex = Mutex.new
1337
- @condition = ConditionVariable.new
1338
- @finished = false
1339
- @result = nil
1340
- @panel = RubyRich::Panel.new("", title: title, border_style: :cyan, title_align: :center)
1341
- @layout = RubyRich::Layout.new(name: :config_dialog, width: @width, height: @height)
1342
- @layout.update_content(@panel)
1343
- @layout.calculate_dimensions(@width, @height)
1344
- end
1345
-
1346
- def selected_choice
1347
- @choices[@selected_index]
1348
- end
1349
-
1350
- def move_up
1351
- move(-1)
1352
- end
1353
-
1354
- def move_down
1355
- move(1)
1356
- end
1357
-
1358
- def finish(value)
1359
- @mutex.synchronize do
1360
- @result = value
1361
- @finished = true
1362
- @condition.signal
1363
- end
1364
- true
1365
- end
1366
-
1367
- def wait
1368
- @mutex.synchronize { @condition.wait(@mutex) until @finished }
1369
- @result
1370
- end
1371
-
1372
- def key(event_name, priority = 0, &block)
1373
- @event_listeners[event_name] ||= []
1374
- @event_listeners[event_name] << { priority: priority, block: block }
1375
- @event_listeners[event_name].sort_by! { |listener| -listener[:priority] }
1376
- end
1377
-
1378
- def notify_listeners(event_data)
1379
- Array(@event_listeners[event_data[:name]]).each { |listener| listener[:block].call(event_data, nil) }
1380
- end
1381
-
1382
- def render_to_buffer
1383
- @panel.content = render_content
1384
- @layout.calculate_dimensions(@width, @height)
1385
- @layout.render_to_buffer
1386
- end
1387
-
1388
- def move(delta)
1389
- return if @choices.empty?
1390
-
1391
- index = @selected_index
1392
- loop do
1393
- index = (index + delta) % @choices.length
1394
- break unless @choices[index][:disabled]
1395
- break if index == @selected_index
1396
- end
1397
- @selected_index = index
1398
- end
1399
-
1400
- def render_content
1401
- lines = [""]
1402
- @choices.each_with_index do |choice, index|
1403
- lines << choice_line(choice, selected: index == @selected_index)
1404
- end
1405
- lines << ""
1406
- lines << "#{muted("↑↓/jk: Navigate")} • #{muted("Enter: Select")} • #{muted("Esc/q: Cancel")}"
1407
- lines.join("\n")
1408
- end
1409
-
1410
- def choice_line(choice, selected:)
1411
- return " #{muted(choice[:label])}" if choice[:disabled]
1412
-
1413
- prefix = selected ? "#{RubyRich::AnsiCode.color(:cyan, true)}➜#{RubyRich::AnsiCode.reset} " : " "
1414
- label = selected ? RubyRich::AnsiCode.color(:white, true) + choice[:label] + RubyRich::AnsiCode.reset : choice[:label]
1415
- "#{prefix}#{label}"
1416
- end
1417
-
1418
- def muted(text)
1419
- "#{RubyRich::AnsiCode.color(:black, true)}#{text}#{RubyRich::AnsiCode.reset}"
1420
- end
1421
-
1422
- private :move,
1423
- :render_content,
1424
- :choice_line,
1425
- :muted
1426
- end
1427
-
1428
- class FormDialog
1429
- attr_accessor :width, :height
1430
-
1431
- def initialize(title:, fields:, width: 92)
1432
- @title = title
1433
- @fields = fields
1434
- @field_index = 0
1435
- @editors = fields.map do |field|
1436
- RubyRich::LineEditor.new.tap { |editor| editor.value = field[:default].to_s }
1437
- end
1438
- @width = width
1439
- @height = [fields.length * 3 + 8, 16].max
1440
- @event_listeners = {}
1441
- @mutex = Mutex.new
1442
- @condition = ConditionVariable.new
1443
- @finished = false
1444
- @result = nil
1445
- @panel = RubyRich::Panel.new("", title: title, border_style: :cyan, title_align: :center)
1446
- @layout = RubyRich::Layout.new(name: :form_dialog, width: @width, height: @height)
1447
- @layout.update_content(@panel)
1448
- @layout.calculate_dimensions(@width, @height)
1449
- wire_default_keys
1450
- end
1451
-
1452
- def finish(value)
1453
- @mutex.synchronize do
1454
- @result = value
1455
- @finished = true
1456
- @condition.signal
1457
- end
1458
- true
1459
- end
1460
-
1461
- def wait
1462
- @mutex.synchronize { @condition.wait(@mutex) until @finished }
1463
- @result
1464
- end
1465
-
1466
- def key(event_name, priority = 0, &block)
1467
- @event_listeners[event_name] ||= []
1468
- @event_listeners[event_name] << { priority: priority, block: block }
1469
- @event_listeners[event_name].sort_by! { |listener| -listener[:priority] }
1470
- end
1471
-
1472
- def notify_listeners(event_data)
1473
- listeners = Array(@event_listeners[event_data[:name]])
1474
- listeners.each { |listener| listener[:block].call(event_data, nil) }
1475
- end
1476
-
1477
- def render_to_buffer
1478
- @panel.content = render_content
1479
- @layout.calculate_dimensions(@width, @height)
1480
- @layout.render_to_buffer
1481
- end
1482
-
1483
- def wire_default_keys
1484
- key(:string, 100) { |event, _live| current_editor.insert(event[:value]); true }
1485
- key(:paste, 100) { |event, _live| current_editor.insert(event[:value]); true }
1486
- key(:backspace, 100) { current_editor.backspace; true }
1487
- key(:delete, 100) { current_editor.delete; true }
1488
- key(:left, 100) { current_editor.move_left; true }
1489
- key(:right, 100) { current_editor.move_right; true }
1490
- key(:ctrl_a, 100) { current_editor.buffer_start; true }
1491
- key(:ctrl_e, 100) { current_editor.buffer_end; true }
1492
- key(:up, 100) { move_field(-1); true }
1493
- key(:down, 100) { move_field(1); true }
1494
- key(:tab, 100) { move_field(1); true }
1495
- key(:shift_tab, 100) { move_field(-1); true }
1496
- key(:enter, 100) { finish(values); true }
1497
- end
1498
-
1499
- def current_editor
1500
- @editors[@field_index]
1501
- end
1502
-
1503
- def move_field(delta)
1504
- @field_index = (@field_index + delta) % @fields.length
1505
- end
1506
-
1507
- def values
1508
- @fields.each_with_index.to_h { |field, index| [field[:name].to_sym, @editors[index].value] }
1509
- end
1510
-
1511
- def render_content
1512
- lines = [""]
1513
- @fields.each_with_index do |field, index|
1514
- focused = index == @field_index
1515
- marker = focused ? "#{RubyRich::AnsiCode.color(:cyan, true)}➜#{RubyRich::AnsiCode.reset}" : " "
1516
- label = focused ? "#{RubyRich::AnsiCode.color(:white, true)}#{field[:label]}#{RubyRich::AnsiCode.reset}" : field[:label]
1517
- lines << "#{marker} #{label}"
1518
- lines << " #{render_field_value(field, @editors[index], focused: focused)}"
1519
- lines << ""
1520
- end
1521
- lines << "#{muted("Tab/↑↓: Field")} • #{muted("Enter: Save")} • #{muted("Esc: Cancel")}"
1522
- lines.join("\n")
1523
- end
1524
-
1525
- def render_field_value(field, editor, focused:)
1526
- raw = editor.value
1527
- text = if field[:mask] && !raw.empty?
1528
- "*" * raw.length
1529
- elsif raw.empty?
1530
- field[:placeholder].to_s
1531
- else
1532
- raw
1533
- end
1534
- color = raw.empty? ? :black : (focused ? :cyan : :white)
1535
- "#{RubyRich::AnsiCode.color(color, true)}#{text}#{RubyRich::AnsiCode.reset}"
1536
- end
1537
-
1538
- def muted(text)
1539
- "#{RubyRich::AnsiCode.color(:black, true)}#{text}#{RubyRich::AnsiCode.reset}"
1540
- end
1541
-
1542
- private :wire_default_keys,
1543
- :current_editor,
1544
- :move_field,
1545
- :values,
1546
- :render_content,
1547
- :render_field_value,
1548
- :muted
1549
- end
1550
- end
1551
- end
3
+ # Compatibility shim: all RichUI code has moved to lib/clacky/rich_ui/.
4
+ # Please require "clacky/rich_ui" instead.
5
+ require_relative "rich_ui"