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
@@ -0,0 +1,868 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "base64"
6
+ require "set"
7
+ require "ruby_rich"
8
+ require_relative "../ui_interface"
9
+ require_relative "../providers"
10
+ require_relative "../ui2/components/welcome_banner"
11
+ require_relative "shell/rich_agent_shell"
12
+ require_relative "components/sidebar"
13
+ require_relative "components/thinking_live_view"
14
+ require_relative "components/status_view"
15
+ require_relative "layout_adapter"
16
+ require_relative "progress_handle_adapter"
17
+ require_relative "components/dialogs/config_menu_dialog"
18
+ require_relative "components/dialogs/form_dialog"
19
+ require_relative "entry_tracker"
20
+ require_relative "components/dialogs/approval_dialog"
21
+
22
+ module Clacky
23
+ class RichUIController
24
+ include Clacky::UIInterface
25
+ include Clacky::RichUI::ViewRenderer
26
+
27
+ STREAMING_MARKDOWN_THRESHOLD = 240
28
+ STREAMING_MARKDOWN_CHUNK_SIZE = 6
29
+ STREAMING_MARKDOWN_DELAY = 0.03
30
+
31
+ COMMANDS = [
32
+ { label: "/clear", value: "/clear", description: "Clear output and restart session" },
33
+ { label: "/config", value: "/config", description: "Open configuration" },
34
+ { label: "/undo", value: "/undo", description: "Restore a previous task state" },
35
+ { label: "/help", value: "/help", description: "Show commands" },
36
+ { label: "/exit", value: "/exit", description: "Exit application", aliases: ["/quit"] }
37
+ ].freeze
38
+
39
+ attr_reader :layout, :shell, :running
40
+ attr_reader :status, :tasks_count, :total_cost, :turn_active, :ctrl_c_warning, :work_label, :latest_latency
41
+ attr_accessor :config, :available_models
42
+
43
+ def initialize(config = {})
44
+ @config = {
45
+ working_dir: config[:working_dir],
46
+ mode: config[:mode],
47
+ model: config[:model],
48
+ theme: config[:theme]
49
+ }
50
+ @welcome_banner = Clacky::UI2::Components::WelcomeBanner.new
51
+ @available_models = config[:model_names] || [config[:model] || "unknown"]
52
+ @shell = RichAgentShell.new(
53
+ title: "OpenClacky",
54
+ subtitle: config[:working_dir].to_s,
55
+ model: config[:model].to_s,
56
+ theme: RubyRich::Theme.agent_dark,
57
+ commands: COMMANDS
58
+ )
59
+ @shell.clacky_controller = self
60
+ @layout = RichUI::LayoutAdapter.new(@shell)
61
+ @input_callback = nil
62
+ @interrupt_callback = nil
63
+ @work_label = nil
64
+ @ctrl_c_warning = nil
65
+ @latest_latency = nil
66
+ @always_allow_fingerprints = Set.new
67
+ @mode_toggle_callback = nil
68
+ @model_switch_callback = nil
69
+ @time_machine_callback = nil
70
+ @tasks_count = 0
71
+ @total_cost = 0.0
72
+ @running = false
73
+ @turn_active = false
74
+ @tracker = RichUI::EntryTracker.new
75
+ @todo_items = []
76
+ @explicit_todo_cycle = false
77
+ @tool_activities = []
78
+ @tool_activity_by_id = {}
79
+ @legacy_progress = {}
80
+ @stdout_lines = []
81
+ @callback_threads = []
82
+ @stream_threads = []
83
+
84
+ wire_shell_callbacks
85
+ end
86
+
87
+ def initialize_and_show_banner(recent_user_messages: nil)
88
+ @running = true
89
+ @shell.update_status(session_status)
90
+ if recent_user_messages && !recent_user_messages.empty?
91
+ @shell.add_separator("recent session")
92
+ recent_user_messages.each { |message| @shell.add_user_message(message) }
93
+ else
94
+ add_plain_block(render_welcome_banner)
95
+ end
96
+ end
97
+
98
+ def start
99
+ initialize_and_show_banner unless @running
100
+ start_input_loop
101
+ end
102
+
103
+ def start_input_loop
104
+ @running = true
105
+ begin
106
+ @shell.start
107
+ rescue AgentInterrupted
108
+ # Ctrl+C (SIGINT via intr:true) raised AgentInterrupted.
109
+ # Check viewport selection FIRST: when text is selected
110
+ # (program-level drag-select), copy it to the clipboard and
111
+ # clear the highlight, then retry the shell WITHOUT calling
112
+ # the interrupt callback. Otherwise route through the
113
+ # normal interrupt callback (interrupt task / double-tap
114
+ # warning / exit).
115
+ vp = @shell.viewport
116
+ selecting = vp.instance_variable_get(:@selecting)
117
+ has_selection = selecting || vp.selected_text.to_s != ""
118
+
119
+ if has_selection
120
+ vp.send(:stop_selection) if selecting
121
+ vp.instance_variable_set(:@selection_start, nil)
122
+ vp.instance_variable_set(:@selection_end, nil)
123
+ vp.instance_variable_set(:@selected_text, "")
124
+ retry
125
+ end
126
+
127
+ # No selection — route through the interrupt callback.
128
+ # The double-tap exit logic lives in the callback (cli.rb).
129
+ input_was_empty = @shell.composer.value.to_s.empty?
130
+ @interrupt_callback&.call(input_was_empty: input_was_empty)
131
+ retry
132
+ end
133
+ ensure
134
+ @running = false
135
+ end
136
+
137
+ # Clears the screen on exit by default — the Rich UI repaints fullscreen
138
+ # and leaves no useful scrollback to preserve.
139
+ def stop(clear_screen: true)
140
+ @running = false
141
+ @shell.stop
142
+ RubyRich::Terminal.clear if clear_screen
143
+ end
144
+
145
+ # Max description length for slash-menu display. Skill descriptions can be
146
+ # hundreds of chars; RubyRich Composer renders each command as a single line
147
+ # and long lines wrap or clip unpredictably. Truncating at registration is
148
+ # simpler and more reliable than patching the gem's render_command.
149
+ SKILL_DESC_MAX = 50
150
+
151
+ def set_skill_loader(skill_loader, agent_profile = nil)
152
+ return unless skill_loader
153
+
154
+ skills = skill_loader.user_invocable_skills
155
+ skills = skills.select { |s| s.allowed_for_agent?(agent_profile.name) } if agent_profile
156
+
157
+ skills.each do |skill|
158
+ desc = skill.description.to_s
159
+ desc = desc.length > SKILL_DESC_MAX ? "#{desc[0, SKILL_DESC_MAX - 1]}…" : desc
160
+ @shell.composer.register_command(
161
+ name: skill.slash_command,
162
+ description: desc
163
+ # No handler — text falls through to submit callback → CLI → agent
164
+ )
165
+ end
166
+ end
167
+
168
+ def set_agent(_agent, _agent_profile = nil); end
169
+
170
+ def on_input(&block)
171
+ @input_callback = block
172
+ end
173
+
174
+ def on_interrupt(&block)
175
+ @interrupt_callback = block
176
+ end
177
+
178
+ def on_mode_toggle(&block)
179
+ @mode_toggle_callback = block
180
+ end
181
+
182
+ def on_model_switch(&block)
183
+ @model_switch_callback = block
184
+ end
185
+
186
+ def on_time_machine(&block)
187
+ @time_machine_callback = block
188
+ end
189
+
190
+ def append_output(content)
191
+ return if content.nil?
192
+
193
+ @shell.add_markdown(content.to_s)
194
+ end
195
+
196
+ def log(message, level: :info)
197
+ case level.to_sym
198
+ when :error then show_error(message)
199
+ when :warning, :warn then show_warning(message)
200
+ when :debug then nil
201
+ else show_info(message)
202
+ end
203
+ end
204
+
205
+ def show_assistant_message(content, files:)
206
+ thinking_text, clean_text = extract_thinking_and_content(content)
207
+ unless thinking_text.to_s.strip.empty?
208
+ # Show live thinking with spinner + timer in fixed area
209
+ @shell.thinking_live.start_thinking
210
+ stream_thinking_live(thinking_text.strip)
211
+ elapsed = @shell.thinking_live.start_time
212
+ elapsed = elapsed ? (Time.now - elapsed).round(1) : 0.0
213
+ @shell.thinking_live.finish_thinking
214
+ # Also add collapsed thinking block for reference (Ctrl+O to expand)
215
+ @shell.add_thinking(thinking_text.strip, status: "#{elapsed}s", collapsed: true)
216
+ # Hide the live area so transcript expands back to full height
217
+ @shell.thinking_live.idle!
218
+ end
219
+ text = clean_text
220
+ stream_thread = nil
221
+ stream_thread = add_conversation_markdown(text) unless text.nil? || text.strip.empty?
222
+ if stream_thread.is_a?(Thread)
223
+ add_file_summary_after(stream_thread, files)
224
+ else
225
+ add_file_summary(files)
226
+ end
227
+ end
228
+
229
+ # Stream thinking text into the live area character by character.
230
+ # After streaming completes, the finished state shows for ~1 second.
231
+ def stream_thinking_live(text, chunk_size: 3, delay: 0.008)
232
+ text.each_char.each_slice(chunk_size) do |chars|
233
+ @shell.thinking_live.append_text(chars.join)
234
+ sleep(delay)
235
+ end
236
+ # Brief pause to show "Thinking done" before next content renders
237
+ sleep(0.6)
238
+ end
239
+
240
+ def show_tool_call(name, args)
241
+ id = @shell.start_tool_call(name: name.to_s, input: format_args(args), status: :running)
242
+ if id
243
+ @tracker.register_tool(id)
244
+ track_tool_activity(id, tool_activity_label(name, args), :running)
245
+ @work_label = "#{name}…"
246
+ end
247
+ end
248
+
249
+ def show_tool_result(result)
250
+ if (id = @tracker.pop_tool_id)
251
+ @shell.finish_tool_call(id, status: :done, output: format_tool_output(result.to_s, :done))
252
+ update_tool_activity(id, :done)
253
+ else
254
+ @shell.add_markdown(result.to_s)
255
+ end
256
+ end
257
+
258
+ def show_tool_stdout(lines)
259
+ @stdout_lines.concat(Array(lines).map(&:to_s))
260
+ end
261
+
262
+ def show_tool_error(error)
263
+ message = error.is_a?(Exception) ? error.message : error.to_s
264
+ if (id = @tracker.pop_tool_id)
265
+ @shell.finish_tool_call(id, status: :error, output: format_tool_output(message, :error))
266
+ update_tool_activity(id, :error)
267
+ else
268
+ @shell.add_error_message(message)
269
+ end
270
+ end
271
+
272
+ def show_tool_args(formatted_args)
273
+ append_output("Args: #{formatted_args}")
274
+ end
275
+
276
+ def show_file_write_preview(path, is_new_file:)
277
+ append_output("#{is_new_file ? "Creating" : "Modifying"} file: #{path || "(unknown)"}")
278
+ end
279
+
280
+ def show_file_edit_preview(path)
281
+ append_output("Editing file: #{path || "(unknown)"}")
282
+ end
283
+
284
+ def show_file_error(error_message)
285
+ show_error(error_message)
286
+ end
287
+
288
+ def show_shell_preview(command)
289
+ append_output("$ #{command}")
290
+ end
291
+
292
+ def show_diff(old_content, new_content, max_lines: 50)
293
+ require "diffy"
294
+ diff = Diffy::Diff.new(old_content, new_content, context: 3).to_s
295
+ stats = parse_diff_stats(diff)
296
+ header = "─── Diff#{stats}#{" " unless stats.empty?}───"
297
+ lines = diff.lines
298
+ visible = lines.take(max_lines).join
299
+ hidden = lines.length - max_lines
300
+ trailer = hidden.positive? ? "\n... (#{hidden} more lines hidden)" : ""
301
+ @shell.add_diff(content: "#{header}\n#{visible}#{trailer}")
302
+ rescue LoadError
303
+ append_output("Old size: #{old_content.bytesize} bytes\nNew size: #{new_content.bytesize} bytes")
304
+ end
305
+
306
+ def show_token_usage(token_data)
307
+ @shell.show_token_usage(
308
+ input: token_data[:prompt_tokens],
309
+ output: token_data[:completion_tokens],
310
+ total: token_data[:total_tokens],
311
+ cost: token_data[:cost]
312
+ )
313
+ @shell.sidebar.update_context(token_data) if @shell.sidebar
314
+ end
315
+
316
+ def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
317
+ set_idle_status
318
+ return if awaiting_user_feedback || iterations <= 5
319
+
320
+ parts = ["Completed #{iterations} iterations", "cost $#{cost.round(4)}"]
321
+ parts << "#{duration.round(1)}s" if duration
322
+ append_output(parts.join(" · "))
323
+ end
324
+
325
+ def show_info(message, prefix_newline: true)
326
+ _ = prefix_newline
327
+ @shell.add_system_message(message.to_s)
328
+ end
329
+
330
+ def show_warning(message)
331
+ @shell.add_system_message("Warning: #{message}")
332
+ end
333
+
334
+ def show_error(message)
335
+ @shell.add_error_message(message.to_s)
336
+ end
337
+
338
+ def show_success(message)
339
+ @shell.add_system_message("OK: #{message}")
340
+ end
341
+
342
+ def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
343
+ _ = prefix_newline
344
+ type = progress_type.to_s
345
+ if phase.to_s == "done"
346
+ @legacy_progress.delete(type)&.finish(final_message: message)
347
+ return
348
+ end
349
+
350
+ handle = @legacy_progress[type]
351
+ if handle
352
+ handle.update(message: message, metadata: metadata)
353
+ else
354
+ @legacy_progress[type] = start_progress(message: message, style: type == "thinking" ? :primary : :quiet)
355
+ end
356
+ end
357
+
358
+ def start_progress(message: nil, style: :primary, quiet_on_fast_finish: false)
359
+ _ = quiet_on_fast_finish
360
+ RichUI::ProgressHandleAdapter.new(@shell.start_progress(message || "Working", style: style))
361
+ end
362
+
363
+ def with_progress(message: nil, style: :primary, quiet_on_fast_finish: false)
364
+ handle = start_progress(message: message, style: style, quiet_on_fast_finish: quiet_on_fast_finish)
365
+ begin
366
+ yield handle
367
+ ensure
368
+ handle.finish
369
+ end
370
+ end
371
+
372
+ def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil, session_id: nil)
373
+ _ = cost_source
374
+ @latest_latency = nil
375
+ if latency.is_a?(Hash)
376
+ ms = latency[:ttft_ms] || latency[:duration_ms]
377
+ @latest_latency = ms ? "#{(ms / 1000.0).round(1)}s" : nil
378
+ end
379
+ @tasks_count = tasks if tasks
380
+ @total_cost = cost if cost
381
+ @status = status if status
382
+ @shell.update_status(session_status)
383
+ end
384
+
385
+ def update_todos(todos)
386
+ @todo_items = Array(todos).map { |todo| normalize_todo(todo) }
387
+ @explicit_todo_cycle = true
388
+ refresh_sidebar_tasks
389
+ end
390
+
391
+ def set_working_status
392
+ @turn_active = true
393
+ @work_label ||= "working…"
394
+ update_sessionbar(status: "working")
395
+ end
396
+
397
+ def set_idle_status
398
+ @turn_active = false
399
+ @work_label = nil
400
+ update_sessionbar(status: "idle")
401
+ end
402
+
403
+ def request_confirmation(message, default: true)
404
+ tool_name, params = ViewRenderer.parse_tool_info(message)
405
+ risk = ViewRenderer.tool_risk_level(tool_name)
406
+ category = ViewRenderer.tool_category(tool_name)
407
+
408
+ fingerprint = ViewRenderer.build_fingerprint(tool_name, params)
409
+ return true if @always_allow_fingerprints.include?(fingerprint)
410
+
411
+ show_info(message)
412
+ dialog = RichUI::ApprovalDialog.new(
413
+ tool_name: tool_name || "unknown",
414
+ message: message,
415
+ params: params,
416
+ risk: risk,
417
+ category: category
418
+ )
419
+ result = show_blocking_dialog(dialog)
420
+
421
+ case result
422
+ when :approve
423
+ true
424
+ when :always_allow
425
+ @always_allow_fingerprints.add(fingerprint)
426
+ true
427
+ when :deny
428
+ false
429
+ else
430
+ default
431
+ end
432
+ end
433
+
434
+ def show_model_switch_dialog
435
+ models = @available_models || [@config[:model] || "unknown"]
436
+ choices = models.each_with_index.map do |name, i|
437
+ current = name == @config[:model]
438
+ { label: "#{current ? "● " : " "}#{name}", value: name }
439
+ end
440
+
441
+ selected = show_menu_dialog(
442
+ title: "Switch Model",
443
+ choices: choices,
444
+ selected_index: models.index(@config[:model]) || 0
445
+ )
446
+ return nil if selected.nil?
447
+
448
+ persist_choice = show_menu_dialog(
449
+ title: "Apply Scope",
450
+ choices: [
451
+ { label: "This session only", value: false },
452
+ { label: "Save permanently", value: true }
453
+ ],
454
+ selected_index: 0
455
+ )
456
+ return nil if persist_choice.nil?
457
+
458
+ { model: selected, persist: persist_choice }
459
+ end
460
+
461
+ def clear_input
462
+ @shell.composer.editor.clear
463
+ end
464
+
465
+ def set_input_tips(message, type: :info)
466
+ update_sessionbar(status: "#{type}: #{message}")
467
+ end
468
+
469
+ def show_help
470
+ @shell.add_markdown(<<~HELP)
471
+ Commands:
472
+ /clear - Clear output and restart session
473
+ /exit - Exit application
474
+
475
+ Input:
476
+ Shift+Enter - New line
477
+ Up/Down - History navigation
478
+ Ctrl+C - Interrupt current task
479
+ HELP
480
+ end
481
+
482
+ def show_config_modal(current_config, test_callback: nil)
483
+ return nil unless @running
484
+
485
+ loop do
486
+ choices = config_menu_choices(current_config)
487
+ result = show_menu_dialog(
488
+ title: "Model Configuration",
489
+ choices: choices,
490
+ selected_index: config_initial_selection(choices)
491
+ )
492
+ return nil if result.nil?
493
+
494
+ case result[:action]
495
+ when :switch
496
+ return result
497
+ when :add
498
+ new_model = show_model_edit_form(nil, test_callback: test_callback)
499
+ if new_model
500
+ anthropic_format = new_model[:provider] == "anthropic"
501
+ current_config.add_model(
502
+ model: new_model[:model],
503
+ api_key: new_model[:api_key],
504
+ base_url: new_model[:base_url],
505
+ anthropic_format: anthropic_format
506
+ )
507
+ new_id = current_config.models.last["id"]
508
+ return { action: :add, model_id: new_id }
509
+ end
510
+ when :edit
511
+ current_model = current_config.current_model
512
+ edited = show_model_edit_form(current_model, test_callback: test_callback)
513
+ if edited
514
+ current_model["api_key"] = edited[:api_key]
515
+ current_model["model"] = edited[:model]
516
+ current_model["base_url"] = edited[:base_url]
517
+ return { action: :edit, model_id: current_model["id"] }
518
+ end
519
+ when :delete
520
+ if current_config.models.length <= 1
521
+ show_warning("Cannot delete the last model.")
522
+ next
523
+ end
524
+
525
+ current_config.remove_model(current_config.current_model_index)
526
+ new_current = current_config.current_model
527
+ return { action: :delete, model_id: new_current && new_current["id"] }
528
+ when :close
529
+ return nil
530
+ end
531
+ end
532
+ end
533
+
534
+ def track_tool_activity(id, label, status)
535
+ activity = { id: id, label: label.to_s, status: status }
536
+ @tool_activities << activity
537
+ @tool_activities.shift while @tool_activities.length > 12
538
+ @tool_activity_by_id[id] = activity
539
+ refresh_sidebar_tasks
540
+ end
541
+
542
+ def update_tool_activity(id, status)
543
+ activity = @tool_activity_by_id[id]
544
+ return unless activity
545
+
546
+ activity[:status] = status
547
+ refresh_sidebar_tasks
548
+ end
549
+
550
+ def refresh_sidebar_tasks
551
+ @shell.update_tasks(@todo_items)
552
+ @shell.sidebar.update_work_activities(@tool_activities)
553
+ @shell.sidebar.update_work_stats(@tasks_count, @total_cost)
554
+ end
555
+
556
+ def reset_task_sidebar_tracking
557
+ @todo_items = []
558
+ @explicit_todo_cycle = false
559
+ @tool_activities = []
560
+ @tool_activity_by_id = {}
561
+ refresh_sidebar_tasks
562
+ end
563
+
564
+ def add_conversation_markdown(text)
565
+ markdown = normalize_markdown_for_terminal(text)
566
+ return @shell.add_markdown(markdown) unless stream_markdown?(markdown)
567
+
568
+ id = @shell.add_markdown("", streaming: true)
569
+ return @shell.add_markdown(markdown) unless id
570
+
571
+ thread = Thread.new do
572
+ Thread.current.report_on_exception = false
573
+ begin
574
+ markdown.each_char.each_slice(STREAMING_MARKDOWN_CHUNK_SIZE) do |chars|
575
+ @shell.append_to_message(id, chars.join)
576
+ sleep(STREAMING_MARKDOWN_DELAY)
577
+ end
578
+ rescue => e
579
+ Clacky::Logger.warn("[stream_markdown] chunk append failed: #{e.class}: #{e.message}")
580
+ # Fallback: replace the partial stream with the full markdown
581
+ begin
582
+ @shell.replace_message(id, markdown)
583
+ rescue
584
+ nil
585
+ end
586
+ end
587
+ end
588
+ @stream_threads << thread
589
+ @stream_threads.reject! { |item| !item.alive? }
590
+ thread
591
+ end
592
+
593
+ def stream_markdown?(text)
594
+ text.length >= STREAMING_MARKDOWN_THRESHOLD
595
+ end
596
+
597
+ def add_file_summary_after(stream_thread, files)
598
+ return if Array(files).empty?
599
+
600
+ thread = Thread.new do
601
+ Thread.current.report_on_exception = false
602
+ begin
603
+ stream_thread.join
604
+ add_file_summary(files)
605
+ rescue => e
606
+ Clacky::Logger.warn("[file_summary] thread failed: #{e.class}: #{e.message}")
607
+ end
608
+ end
609
+ @stream_threads << thread
610
+ @stream_threads.reject! { |item| !item.alive? }
611
+ end
612
+
613
+ def add_plain_block(text)
614
+ @shell.transcript.add_block(:markdown, expand_ansi_multiline_spans(text), metadata: { plain: true })
615
+ @shell.viewport.scroll_to_bottom
616
+ end
617
+
618
+ def add_file_summary(files)
619
+ items = Array(files).filter_map do |file|
620
+ path = file[:path] || file["path"] || file[:name] || file["name"]
621
+ next if path.to_s.strip.empty?
622
+
623
+ "- `#{path}`"
624
+ end
625
+ return if items.empty?
626
+
627
+ @shell.add_markdown("**Files**\n\n#{items.join("\n")}")
628
+ end
629
+
630
+ def wire_shell_callbacks
631
+ @shell.on_submit do |text, attachments|
632
+ reset_task_sidebar_tracking
633
+ @ctrl_c_warning = nil
634
+ files = Array(attachments).map { |attachment| attachment.respond_to?(:to_h) ? attachment.to_h : attachment }
635
+ @shell.add_user_message(text)
636
+ run_callback_async { @input_callback&.call(text, files, display: text) }
637
+ end
638
+
639
+ @shell.on_interrupt do |input_was_empty:|
640
+ @interrupt_callback&.call(input_was_empty: input_was_empty)
641
+ end
642
+
643
+ @shell.on_mode_toggle do |mode|
644
+ @config[:mode] = mode.to_s
645
+ @mode_toggle_callback&.call(mode.to_s)
646
+ end
647
+
648
+ @shell.on_esc do
649
+ handle_esc
650
+ end
651
+
652
+ @shell.callbacks[:clear_ctrlc] = -> { @ctrl_c_warning = nil }
653
+
654
+ @shell.callbacks[:model_switch] = -> {
655
+ Thread.new do
656
+ result = show_model_switch_dialog
657
+ if result
658
+ @config[:model] = result[:model]
659
+ @latest_latency = nil
660
+ @shell.update_status(session_status)
661
+ @model_switch_callback&.call(result[:model], result[:persist])
662
+ end
663
+ rescue => e
664
+ $stderr.puts "[model_switch] #{e.class}: #{e.message}"
665
+ end
666
+ }
667
+ end
668
+
669
+ # Esc cancellation stack (tui_design.md §2.8).
670
+ # Called from Composer's @on_escape callback (before native escape).
671
+ # Returns true when handled (skip native), false to fall through.
672
+ def handle_esc
673
+ # Layer 1: Close any open dialog or slash menu
674
+ if @shell.layout.dialog
675
+ dialog = @shell.layout.dialog
676
+ dialog.finish(nil) if dialog.respond_to?(:finish)
677
+ @shell.layout.hide_dialog
678
+ return true
679
+ end
680
+ if @shell.composer.menu_open?
681
+ @shell.composer.send(:close_menu)
682
+ return true
683
+ end
684
+
685
+ # Layer 2: Interrupt running turn
686
+ if @turn_active
687
+ @interrupt_callback&.call(input_was_empty: false)
688
+ return true
689
+ end
690
+
691
+ # Layer 3: Discard queued draft (future; return true when done)
692
+
693
+ # Layer 4+5: Fall through to Composer's native escape —
694
+ # editor with text → clear, empty editor → focus/no-op
695
+ false
696
+ end
697
+
698
+ def session_status
699
+ [
700
+ @status || "idle",
701
+ @config[:mode],
702
+ @config[:model],
703
+ "#{@tasks_count} tasks",
704
+ "$#{@total_cost.round(4)}"
705
+ ].compact.join(" · ")
706
+ end
707
+
708
+ def run_callback_async(&block)
709
+ @callback_threads.reject! { |thread| !thread.alive? }
710
+ @callback_threads << Thread.new do
711
+ block.call
712
+ rescue StandardError => e
713
+ show_error(e.message)
714
+ end
715
+ end
716
+
717
+ def render_welcome_banner
718
+ @welcome_banner.render_full(
719
+ working_dir: @config[:working_dir].to_s,
720
+ mode: @config[:mode].to_s,
721
+ width: terminal_width
722
+ )
723
+ end
724
+
725
+ def terminal_width
726
+ if defined?(TTY::Screen)
727
+ TTY::Screen.width
728
+ else
729
+ 120
730
+ end
731
+ rescue StandardError
732
+ 120
733
+ end
734
+
735
+ def show_menu_dialog(title:, choices:, selected_index: nil)
736
+ selected_index ||= config_initial_selection(choices)
737
+ dialog = RichUI::ConfigMenuDialog.new(title: title, choices: choices, selected_index: selected_index)
738
+
739
+ dialog.key(:up, 1_000) { dialog.move_up; true }
740
+ dialog.key(:down, 1_000) { dialog.move_down; true }
741
+ dialog.key(:string, 1_000) do |event, _live|
742
+ case event[:value]
743
+ when "k" then dialog.move_up
744
+ when "j" then dialog.move_down
745
+ when "q" then dialog.finish(nil)
746
+ end
747
+ true
748
+ end
749
+ dialog.key(:enter, 1_000) do
750
+ selected = dialog.selected_choice
751
+ dialog.finish(selected && !selected[:disabled] ? selected[:value] : nil)
752
+ end
753
+ dialog.key(:escape, 1_000) { dialog.finish(nil) }
754
+
755
+ show_blocking_dialog(dialog)
756
+ end
757
+
758
+ def show_form_dialog(title:, fields:)
759
+ dialog = RichUI::FormDialog.new(title: title, fields: fields)
760
+ dialog.key(:escape, 1_000) { dialog.finish(nil) }
761
+ show_blocking_dialog(dialog)
762
+ end
763
+
764
+ def show_blocking_dialog(dialog)
765
+ @shell.layout.show_dialog(dialog)
766
+ dialog.wait
767
+ ensure
768
+ @shell.layout.hide_dialog if @shell.layout.dialog.equal?(dialog)
769
+ end
770
+
771
+ def show_model_edit_form(model, test_callback: nil)
772
+ is_new = model.nil?
773
+ model ||= {}
774
+ selected_provider = nil
775
+
776
+ if is_new
777
+ selected_provider = show_provider_selection
778
+ return nil if selected_provider.nil?
779
+ end
780
+
781
+ provider_preset = selected_provider && selected_provider != "custom" ? Clacky::Providers.get(selected_provider) : nil
782
+ default_model = provider_preset ? provider_preset["default_model"] : model["model"]
783
+ default_base_url = provider_preset ? provider_preset["base_url"] : model["base_url"]
784
+ masked_key = mask_api_key(model["api_key"])
785
+
786
+ fields = [
787
+ {
788
+ name: :api_key,
789
+ label: "API Key #{is_new ? "" : "(current: #{masked_key})"}:",
790
+ default: "",
791
+ mask: true,
792
+ placeholder: is_new ? "required" : "leave blank to keep current"
793
+ },
794
+ {
795
+ name: :model,
796
+ label: "Model #{is_new && default_model ? "(default: #{default_model})" : (is_new ? "" : "(current: #{model["model"]})")}:",
797
+ default: default_model || "",
798
+ placeholder: "model name"
799
+ },
800
+ {
801
+ name: :base_url,
802
+ label: "Base URL #{is_new && default_base_url ? "(default: #{default_base_url})" : (is_new ? "" : "(current: #{model["base_url"]})")}:",
803
+ default: default_base_url || "",
804
+ placeholder: "https://..."
805
+ }
806
+ ]
807
+
808
+ title = if is_new && selected_provider && selected_provider != "custom"
809
+ provider_name = Clacky::Providers.get(selected_provider)&.dig("name") || selected_provider
810
+ "Add #{provider_name} Model"
811
+ elsif is_new
812
+ "Add Custom Model"
813
+ else
814
+ "Edit Model"
815
+ end
816
+
817
+ loop do
818
+ result = show_form_dialog(title: title, fields: fields)
819
+ return nil if result.nil?
820
+
821
+ values = merge_model_form_values(
822
+ result,
823
+ model: model,
824
+ default_model: default_model,
825
+ default_base_url: default_base_url
826
+ )
827
+
828
+ validation = validate_model_form(values, is_new: is_new, existing_model: model, test_callback: test_callback)
829
+ if validation[:success]
830
+ return values.merge(provider: selected_provider)
831
+ end
832
+
833
+ show_warning(validation[:error])
834
+ fields.each { |field| field[:default] = result[field[:name]].to_s }
835
+ end
836
+ end
837
+
838
+ def show_provider_selection
839
+ choices = Clacky::Providers.list.map { |id, name| { label: name, value: id } }
840
+ choices << { label: "─" * 40, disabled: true }
841
+ choices << { label: "Custom (manual configuration)", value: "custom" }
842
+ show_menu_dialog(title: "Select Provider", choices: choices, selected_index: 0)
843
+ end
844
+
845
+ private :track_tool_activity,
846
+ :update_tool_activity,
847
+ :refresh_sidebar_tasks,
848
+ :reset_task_sidebar_tracking,
849
+ :add_conversation_markdown,
850
+ :stream_markdown?,
851
+ :add_file_summary_after,
852
+ :add_plain_block,
853
+ :stream_thinking_live,
854
+ :add_file_summary,
855
+ :wire_shell_callbacks,
856
+ :session_status,
857
+ :run_callback_async,
858
+ :render_welcome_banner,
859
+ :terminal_width,
860
+ :show_menu_dialog,
861
+ :show_form_dialog,
862
+ :show_blocking_dialog,
863
+ :show_model_edit_form,
864
+ :show_provider_selection,
865
+ :show_model_switch_dialog
866
+
867
+ end
868
+ end