openclacky 0.5.6 → 0.6.1

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/docs/ui2-architecture.md +124 -0
  4. data/lib/clacky/agent.rb +376 -346
  5. data/lib/clacky/agent_config.rb +1 -7
  6. data/lib/clacky/cli.rb +167 -398
  7. data/lib/clacky/client.rb +68 -36
  8. data/lib/clacky/gitignore_parser.rb +26 -12
  9. data/lib/clacky/model_pricing.rb +6 -2
  10. data/lib/clacky/session_manager.rb +6 -2
  11. data/lib/clacky/tools/glob.rb +66 -10
  12. data/lib/clacky/tools/grep.rb +6 -122
  13. data/lib/clacky/tools/run_project.rb +10 -5
  14. data/lib/clacky/tools/safe_shell.rb +149 -20
  15. data/lib/clacky/tools/shell.rb +3 -51
  16. data/lib/clacky/tools/todo_manager.rb +50 -3
  17. data/lib/clacky/tools/trash_manager.rb +1 -1
  18. data/lib/clacky/tools/web_fetch.rb +4 -4
  19. data/lib/clacky/tools/web_search.rb +40 -28
  20. data/lib/clacky/ui2/README.md +214 -0
  21. data/lib/clacky/ui2/components/base_component.rb +163 -0
  22. data/lib/clacky/ui2/components/common_component.rb +98 -0
  23. data/lib/clacky/ui2/components/inline_input.rb +187 -0
  24. data/lib/clacky/ui2/components/input_area.rb +1124 -0
  25. data/lib/clacky/ui2/components/message_component.rb +80 -0
  26. data/lib/clacky/ui2/components/output_area.rb +112 -0
  27. data/lib/clacky/ui2/components/todo_area.rb +130 -0
  28. data/lib/clacky/ui2/components/tool_component.rb +106 -0
  29. data/lib/clacky/ui2/components/welcome_banner.rb +103 -0
  30. data/lib/clacky/ui2/layout_manager.rb +437 -0
  31. data/lib/clacky/ui2/line_editor.rb +201 -0
  32. data/lib/clacky/ui2/markdown_renderer.rb +80 -0
  33. data/lib/clacky/ui2/screen_buffer.rb +257 -0
  34. data/lib/clacky/ui2/theme_manager.rb +68 -0
  35. data/lib/clacky/ui2/themes/base_theme.rb +85 -0
  36. data/lib/clacky/ui2/themes/hacker_theme.rb +58 -0
  37. data/lib/clacky/ui2/themes/minimal_theme.rb +52 -0
  38. data/lib/clacky/ui2/ui_controller.rb +778 -0
  39. data/lib/clacky/ui2/view_renderer.rb +177 -0
  40. data/lib/clacky/ui2.rb +37 -0
  41. data/lib/clacky/utils/file_ignore_helper.rb +126 -0
  42. data/lib/clacky/version.rb +1 -1
  43. data/lib/clacky.rb +1 -6
  44. metadata +53 -6
  45. data/lib/clacky/ui/banner.rb +0 -155
  46. data/lib/clacky/ui/enhanced_prompt.rb +0 -786
  47. data/lib/clacky/ui/formatter.rb +0 -209
  48. data/lib/clacky/ui/statusbar.rb +0 -96
@@ -0,0 +1,778 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "layout_manager"
4
+ require_relative "view_renderer"
5
+ require_relative "components/output_area"
6
+ require_relative "components/input_area"
7
+ require_relative "components/todo_area"
8
+ require_relative "components/welcome_banner"
9
+ require_relative "components/inline_input"
10
+ require_relative "../thinking_verbs"
11
+
12
+ module Clacky
13
+ module UI2
14
+ # UIController is the MVC controller layer that coordinates UI state and user interactions
15
+ class UIController
16
+ attr_reader :layout, :renderer, :running, :inline_input, :input_area
17
+ attr_accessor :config
18
+
19
+ def initialize(config = {})
20
+ @renderer = ViewRenderer.new
21
+
22
+ # Set theme if specified
23
+ ThemeManager.set_theme(config[:theme]) if config[:theme]
24
+
25
+ # Store configuration
26
+ @config = {
27
+ working_dir: config[:working_dir],
28
+ mode: config[:mode],
29
+ model: config[:model],
30
+ theme: config[:theme]
31
+ }
32
+
33
+ # Initialize layout components
34
+ @output_area = Components::OutputArea.new(height: 20) # Will be recalculated
35
+ @input_area = Components::InputArea.new
36
+ @todo_area = Components::TodoArea.new
37
+ @welcome_banner = Components::WelcomeBanner.new
38
+ @inline_input = nil # Created when needed
39
+ @layout = LayoutManager.new(
40
+ output_area: @output_area,
41
+ input_area: @input_area,
42
+ todo_area: @todo_area
43
+ )
44
+
45
+ @running = false
46
+ @input_callback = nil
47
+ @interrupt_callback = nil
48
+ @tasks_count = 0
49
+ @total_cost = 0.0
50
+ @progress_thread = nil
51
+ @progress_start_time = nil
52
+ @progress_message = nil
53
+ end
54
+
55
+ # Start the UI controller
56
+ def start
57
+ initialize_and_show_banner
58
+ start_input_loop
59
+ end
60
+
61
+ # Initialize screen and show banner (separate from input loop)
62
+ # @param recent_user_messages [Array<String>, nil] Recent user messages when loading session
63
+ def initialize_and_show_banner(recent_user_messages: nil)
64
+ @running = true
65
+
66
+ # Set session bar data before initializing screen
67
+ @input_area.update_sessionbar(
68
+ working_dir: @config[:working_dir],
69
+ mode: @config[:mode],
70
+ model: @config[:model],
71
+ tasks: @tasks_count,
72
+ cost: @total_cost
73
+ )
74
+
75
+ @layout.initialize_screen
76
+
77
+ # Display welcome banner or session history
78
+ if recent_user_messages && !recent_user_messages.empty?
79
+ display_session_history(recent_user_messages)
80
+ else
81
+ display_welcome_banner
82
+ end
83
+ end
84
+
85
+ # Start input loop (separate from initialization)
86
+ def start_input_loop
87
+ @running = true
88
+ input_loop
89
+ end
90
+
91
+ # Update session bar with current stats
92
+ # @param tasks [Integer] Number of completed tasks (optional)
93
+ # @param cost [Float] Total cost (optional)
94
+ # @param status [String] Workspace status ('idle' or 'working') (optional)
95
+ def update_sessionbar(tasks: nil, cost: nil, status: nil)
96
+ @tasks_count = tasks if tasks
97
+ @total_cost = cost if cost
98
+ @input_area.update_sessionbar(
99
+ working_dir: @config[:working_dir],
100
+ mode: @config[:mode],
101
+ model: @config[:model],
102
+ tasks: @tasks_count,
103
+ cost: @total_cost,
104
+ status: status
105
+ )
106
+ @layout.render_input
107
+ end
108
+
109
+ # Toggle permission mode between confirm_safes and auto_approve
110
+ def toggle_mode
111
+ current_mode = @config[:mode]
112
+ new_mode = case current_mode.to_s
113
+ when /confirm_safes/
114
+ "auto_approve"
115
+ when /auto_approve/
116
+ "confirm_safes"
117
+ else
118
+ "auto_approve" # Default to auto_approve if unknown mode
119
+ end
120
+
121
+ @config[:mode] = new_mode
122
+
123
+ # Notify CLI to update agent_config
124
+ @mode_toggle_callback&.call(new_mode)
125
+
126
+ update_sessionbar
127
+ end
128
+
129
+ # Stop the UI controller
130
+ def stop
131
+ @running = false
132
+ @layout.cleanup_screen
133
+ end
134
+
135
+ # Set callback for user input
136
+ # @param block [Proc] Callback to execute with user input
137
+ def on_input(&block)
138
+ @input_callback = block
139
+ end
140
+
141
+ # Set callback for interrupt (Ctrl+C)
142
+ # @param block [Proc] Callback to execute on interrupt
143
+ def on_interrupt(&block)
144
+ @interrupt_callback = block
145
+ end
146
+
147
+ # Set callback for mode toggle (Shift+Tab)
148
+ # @param block [Proc] Callback to execute on mode toggle
149
+ def on_mode_toggle(&block)
150
+ @mode_toggle_callback = block
151
+ end
152
+
153
+ # Append output to the output area
154
+ # @param content [String] Content to append
155
+ def append_output(content)
156
+ @layout.append_output(content)
157
+ end
158
+
159
+ # Log message to output area (use instead of puts)
160
+ # @param message [String] Message to log
161
+ # @param level [Symbol] Log level (:debug, :info, :warning, :error)
162
+ def log(message, level: :info)
163
+ theme = ThemeManager.current_theme
164
+
165
+ output = case level
166
+ when :debug
167
+ # Gray dimmed text for debug messages
168
+ theme.format_text(" [DEBUG] #{message}", :thinking)
169
+ when :info
170
+ # Info symbol with normal text
171
+ "#{theme.format_symbol(:info)} #{message}"
172
+ when :warning
173
+ # Warning rendering
174
+ @renderer.render_warning(message)
175
+ when :error
176
+ # Error rendering
177
+ @renderer.render_error(message)
178
+ else
179
+ # Default to info
180
+ "#{theme.format_symbol(:info)} #{message}"
181
+ end
182
+
183
+ append_output(output)
184
+ end
185
+
186
+ # Update the last line in output area (for progress indicator)
187
+ # @param content [String] Content to update
188
+ def update_progress_line(content)
189
+ @layout.update_last_line(content)
190
+ end
191
+
192
+ # Clear the progress line (remove last line)
193
+ def clear_progress_line
194
+ @layout.remove_last_line
195
+ end
196
+
197
+ # Update todos display
198
+ # @param todos [Array<Hash>] Array of todo items
199
+ def update_todos(todos)
200
+ @layout.update_todos(todos)
201
+ end
202
+
203
+ # Display token usage statistics
204
+ # @param token_data [Hash] Token usage data containing:
205
+ # - delta_tokens: token delta from previous iteration
206
+ # - prompt_tokens: input tokens
207
+ # - completion_tokens: output tokens
208
+ # - total_tokens: total tokens
209
+ # - cache_write: cache write tokens
210
+ # - cache_read: cache read tokens
211
+ # - cost: cost for this iteration
212
+ def show_token_usage(token_data)
213
+ theme = ThemeManager.current_theme
214
+
215
+ token_info = []
216
+
217
+ # Delta tokens with color coding
218
+ delta_tokens = token_data[:delta_tokens]
219
+ delta_str = "+#{delta_tokens}"
220
+ colored_delta = if delta_tokens > 10000
221
+ theme.format_text(delta_str, :error)
222
+ elsif delta_tokens > 5000
223
+ theme.format_text(delta_str, :warning)
224
+ else
225
+ theme.format_text(delta_str, :success)
226
+ end
227
+ token_info << colored_delta
228
+
229
+ # Cache status indicator (using theme)
230
+ cache_write = token_data[:cache_write]
231
+ cache_read = token_data[:cache_read]
232
+ cache_used = cache_read > 0 || cache_write > 0
233
+ if cache_used
234
+ token_info << theme.format_symbol(:cached)
235
+ end
236
+
237
+ # Input tokens (with cache breakdown if available)
238
+ prompt_tokens = token_data[:prompt_tokens]
239
+ if cache_write > 0 || cache_read > 0
240
+ input_detail = "#{prompt_tokens} (cache: #{cache_read} read, #{cache_write} write)"
241
+ token_info << "Input: #{input_detail}"
242
+ else
243
+ token_info << "Input: #{prompt_tokens}"
244
+ end
245
+
246
+ # Output tokens
247
+ token_info << "Output: #{token_data[:completion_tokens]}"
248
+
249
+ # Total
250
+ token_info << "Total: #{token_data[:total_tokens]}"
251
+
252
+ # Cost for this iteration
253
+ if token_data[:cost]
254
+ token_info << "Cost: $#{token_data[:cost].round(6)}"
255
+ end
256
+
257
+ # Display through output system
258
+ token_display = theme.format_text(" [Tokens] #{token_info.join(' | ')}", :thinking)
259
+ append_output(token_display)
260
+ end
261
+
262
+ # Show tool call arguments
263
+ # @param formatted_args [String] Formatted arguments string
264
+ def show_tool_args(formatted_args)
265
+ theme = ThemeManager.current_theme
266
+ append_output("\n#{theme.format_text("Args: #{formatted_args}", :thinking)}")
267
+ end
268
+
269
+ # Show file operation preview (Write tool)
270
+ # @param path [String] File path
271
+ # @param is_new_file [Boolean] Whether this is a new file
272
+ def show_file_write_preview(path, is_new_file:)
273
+ theme = ThemeManager.current_theme
274
+ file_label = theme.format_symbol(:file)
275
+ status = is_new_file ? theme.format_text("Creating new file", :success) : theme.format_text("Modifying existing file", :warning)
276
+ append_output("\n#{file_label} #{path || '(unknown)'}")
277
+ append_output(status)
278
+ end
279
+
280
+ # Show file operation preview (Edit tool)
281
+ # @param path [String] File path
282
+ def show_file_edit_preview(path)
283
+ theme = ThemeManager.current_theme
284
+ file_label = theme.format_symbol(:file)
285
+ append_output("\n#{file_label} #{path || '(unknown)'}")
286
+ end
287
+
288
+ # Show file operation error
289
+ # @param error_message [String] Error message
290
+ def show_file_error(error_message)
291
+ theme = ThemeManager.current_theme
292
+ append_output(" #{theme.format_text("Warning:", :error)} #{error_message}")
293
+ end
294
+
295
+ # Show shell command preview
296
+ # @param command [String] Shell command
297
+ def show_shell_preview(command)
298
+ theme = ThemeManager.current_theme
299
+ cmd_label = theme.format_symbol(:command)
300
+ append_output("\n#{cmd_label} #{command}")
301
+ end
302
+
303
+ # === Semantic UI Methods (for Agent to call directly) ===
304
+
305
+ # Show user message
306
+ # @param content [String] Message content
307
+ # @param images [Array] Image paths (optional)
308
+ def show_user_message(content, images: [])
309
+ output = @renderer.render_user_message(content)
310
+ append_output(output)
311
+ end
312
+
313
+ # Show assistant message
314
+ # @param content [String] Message content
315
+ def show_assistant_message(content)
316
+ output = @renderer.render_assistant_message(content)
317
+ append_output(output)
318
+ end
319
+
320
+ # Show tool call
321
+ # @param name [String] Tool name
322
+ # @param args [String, Hash] Tool arguments (JSON string or Hash)
323
+ def show_tool_call(name, args)
324
+ formatted_call = format_tool_call(name, args)
325
+ output = @renderer.render_tool_call(tool_name: name, formatted_call: formatted_call)
326
+ append_output(output)
327
+ end
328
+
329
+ # Show tool result
330
+ # @param result [String] Formatted tool result
331
+ def show_tool_result(result)
332
+ output = @renderer.render_tool_result(result: result)
333
+ append_output(output)
334
+ end
335
+
336
+ # Show tool error
337
+ # @param error [String, Exception] Error message or exception
338
+ def show_tool_error(error)
339
+ error_msg = error.is_a?(Exception) ? error.message : error.to_s
340
+ output = @renderer.render_tool_error(error: error_msg)
341
+ append_output(output)
342
+ end
343
+
344
+ # Show completion status (only for tasks with more than 5 iterations)
345
+ # @param iterations [Integer] Number of iterations
346
+ # @param cost [Float] Cost of this run
347
+ # @param duration [Float] Duration in seconds
348
+ # @param cache_stats [Hash] Cache statistics
349
+ def show_complete(iterations:, cost:, duration: nil, cache_stats: nil)
350
+ # Update status back to 'idle' when task is complete
351
+ update_sessionbar(status: 'idle')
352
+
353
+ # Clear user tip when agent stops working
354
+ @input_area.clear_user_tip
355
+ @layout.render_input
356
+
357
+ # Only show completion message for complex tasks (>5 iterations)
358
+ return if iterations <= 5
359
+
360
+ cache_tokens = cache_stats&.dig(:cache_read_input_tokens)
361
+ cache_requests = cache_stats&.dig(:total_requests)
362
+ cache_hits = cache_stats&.dig(:cache_hit_requests)
363
+
364
+ output = @renderer.render_task_complete(
365
+ iterations: iterations,
366
+ cost: cost,
367
+ duration: duration,
368
+ cache_tokens: cache_tokens,
369
+ cache_requests: cache_requests,
370
+ cache_hits: cache_hits
371
+ )
372
+ append_output(output)
373
+ end
374
+
375
+ # Show progress indicator with dynamic elapsed time
376
+ # @param message [String] Progress message (optional, will use random thinking verb if nil)
377
+ # @param prefix_newline [Boolean] Whether to add a blank line before progress (default: true)
378
+ def show_progress(message = nil, prefix_newline: true)
379
+ # Stop any existing progress thread
380
+ stop_progress_thread
381
+
382
+ # Update status to 'working'
383
+ update_sessionbar(status: 'working')
384
+
385
+ @progress_message = message || Clacky::THINKING_VERBS.sample
386
+ @progress_start_time = Time.now
387
+
388
+ # Show initial progress (yellow, active)
389
+ append_output("") if prefix_newline
390
+ output = @renderer.render_working("#{@progress_message}… (ctrl+c to interrupt)")
391
+ append_output(output)
392
+
393
+ # Start background thread to update elapsed time
394
+ @progress_thread = Thread.new do
395
+ while @progress_start_time
396
+ sleep 0.5
397
+ next unless @progress_start_time
398
+
399
+ elapsed = (Time.now - @progress_start_time).to_i
400
+ update_progress_line(@renderer.render_working("#{@progress_message}… (ctrl+c to interrupt · #{elapsed}s)"))
401
+ end
402
+ rescue => e
403
+ # Silently handle thread errors
404
+ end
405
+ end
406
+
407
+ # Clear progress indicator
408
+ def clear_progress
409
+ # Calculate elapsed time before stopping
410
+ elapsed_time = @progress_start_time ? (Time.now - @progress_start_time).to_i : 0
411
+
412
+ # Stop the progress thread
413
+ stop_progress_thread
414
+
415
+ # Update the final progress line to gray (stopped state)
416
+ if @progress_message && elapsed_time > 0
417
+ final_output = @renderer.render_progress("#{@progress_message}… (#{elapsed_time}s)")
418
+ update_progress_line(final_output)
419
+ else
420
+ clear_progress_line
421
+ end
422
+ end
423
+
424
+ # Stop the progress update thread
425
+ def stop_progress_thread
426
+ @progress_start_time = nil
427
+ if @progress_thread&.alive?
428
+ @progress_thread.kill
429
+ @progress_thread = nil
430
+ end
431
+ end
432
+
433
+ # Show info message
434
+ # @param message [String] Info message
435
+ # @param prefix_newline [Boolean] Whether to add newline before message (default: true)
436
+ def show_info(message, prefix_newline: true)
437
+ output = @renderer.render_system_message(message, prefix_newline: prefix_newline)
438
+ append_output(output)
439
+ end
440
+
441
+ # Show warning message
442
+ # @param message [String] Warning message
443
+ def show_warning(message)
444
+ output = @renderer.render_warning(message)
445
+ append_output(output)
446
+ end
447
+
448
+ # Show error message
449
+ # @param message [String] Error message
450
+ def show_error(message)
451
+ output = @renderer.render_error(message)
452
+ append_output(output)
453
+ end
454
+
455
+ # Set workspace status to idle (called when agent stops working)
456
+ def set_idle_status
457
+ update_sessionbar(status: 'idle')
458
+ # Clear user tip when agent stops working
459
+ @input_area.clear_user_tip
460
+ @layout.render_input
461
+ end
462
+
463
+ # Set workspace status to working (called when agent starts working)
464
+ def set_working_status
465
+ update_sessionbar(status: 'working')
466
+ # Show a random user tip with 40% probability when agent starts working
467
+ @input_area.show_user_tip(probability: 0.4)
468
+ @layout.render_input
469
+ end
470
+
471
+ # Show help text
472
+ def show_help
473
+ theme = ThemeManager.current_theme
474
+
475
+ # Separator line
476
+ separator = theme.format_text("─" * 60, :info)
477
+
478
+ lines = [
479
+ separator,
480
+ "",
481
+ theme.format_text("Commands:", :info),
482
+ " #{theme.format_text("/clear", :success)} - Clear output and restart session",
483
+ " #{theme.format_text("/exit", :success)} - Exit application",
484
+ "",
485
+ theme.format_text("Input:", :info),
486
+ " #{theme.format_text("Shift+Enter", :success)} - New line",
487
+ " #{theme.format_text("Up/Down", :success)} - History navigation",
488
+ " #{theme.format_text("Ctrl+V", :success)} - Paste image (Ctrl+D to delete, max 3)",
489
+ " #{theme.format_text("Ctrl+C", :success)} - Clear input (press 2x to exit)",
490
+ "",
491
+ theme.format_text("Other:", :info),
492
+ " Supports Emacs-style shortcuts (Ctrl+A, Ctrl+E, etc.)",
493
+ "",
494
+ separator
495
+ ]
496
+
497
+ lines.each { |line| append_output(line) }
498
+ end
499
+
500
+ # Request confirmation from user (blocking)
501
+ # @param message [String] Confirmation prompt
502
+ # @param default [Boolean] Default value if user presses Enter
503
+ # @return [Boolean, String, nil] true/false for yes/no, String for feedback, nil for cancelled
504
+ def request_confirmation(message, default: true)
505
+ # Show question in output with theme styling
506
+ theme = ThemeManager.current_theme
507
+ question_symbol = theme.format_symbol(:info)
508
+ append_output("\n#{question_symbol} #{message}")
509
+
510
+ # Pause InputArea
511
+ @input_area.pause
512
+ @layout.recalculate_layout
513
+
514
+ # Create InlineInput with styled prompt
515
+ inline_input = Components::InlineInput.new(
516
+ prompt: " Press Enter to approve, 'n' to reject, or provide feedback: ",
517
+ default: nil
518
+ )
519
+ @inline_input = inline_input
520
+
521
+ # Add inline input line to output (use layout to track position)
522
+ @layout.append_output(inline_input.render)
523
+ @layout.position_inline_input_cursor(inline_input)
524
+
525
+ # Collect input (blocks until user presses Enter)
526
+ result_text = inline_input.collect
527
+
528
+ # Clean up - remove the inline input line (use layout to track position)
529
+ @layout.remove_last_line
530
+
531
+ # Append the final response to output
532
+ if result_text.nil?
533
+ append_output(theme.format_text(" [Cancelled]", :error))
534
+ else
535
+ display_text = result_text.empty? ? (default ? "y" : "n") : result_text
536
+ append_output(theme.format_text(" #{display_text}", :success))
537
+ end
538
+
539
+ # Deactivate and clean up
540
+ @inline_input = nil
541
+ @input_area.resume
542
+ @layout.recalculate_layout
543
+ @layout.render_all
544
+
545
+ # Parse result
546
+ return nil if result_text.nil? # Cancelled
547
+
548
+ response = result_text.strip.downcase
549
+ case response
550
+ when "y", "yes" then true
551
+ when "n", "no" then false
552
+ when "" then default
553
+ else
554
+ result_text # Return feedback text
555
+ end
556
+ end
557
+
558
+ # Show diff between old and new content
559
+ # @param old_content [String] Old content
560
+ # @param new_content [String] New content
561
+ # @param max_lines [Integer] Maximum lines to show
562
+ def show_diff(old_content, new_content, max_lines: 50)
563
+ require 'diffy'
564
+
565
+ diff = Diffy::Diff.new(old_content, new_content, context: 3)
566
+ all_lines = diff.to_s(:color).lines
567
+ display_lines = all_lines.first(max_lines)
568
+
569
+ display_lines.each { |line| append_output(line.chomp) }
570
+ if all_lines.size > max_lines
571
+ append_output("\n... (#{all_lines.size - max_lines} more lines, diff truncated)")
572
+ end
573
+ rescue LoadError
574
+ # Fallback if diffy is not available
575
+ append_output(" Old size: #{old_content.bytesize} bytes")
576
+ append_output(" New size: #{new_content.bytesize} bytes")
577
+ end
578
+
579
+ private
580
+
581
+ # Format tool call for display
582
+ # @param name [String] Tool name
583
+ # @param args [String, Hash] Tool arguments
584
+ # @return [String] Formatted call string
585
+ def format_tool_call(name, args)
586
+ args_hash = args.is_a?(String) ? JSON.parse(args, symbolize_names: true) : args
587
+
588
+ # Try to get tool instance for custom formatting
589
+ tool = get_tool_instance(name)
590
+ if tool
591
+ begin
592
+ return tool.format_call(args_hash)
593
+ rescue StandardError
594
+ # Fallback
595
+ end
596
+ end
597
+
598
+ # Simple fallback
599
+ "#{name}(...)"
600
+ rescue JSON::ParserError
601
+ "#{name}(...)"
602
+ end
603
+
604
+ # Get tool instance by name
605
+ # @param tool_name [String] Tool name
606
+ # @return [Object, nil] Tool instance or nil
607
+ def get_tool_instance(tool_name)
608
+ # Convert tool_name to class name (e.g., "file_reader" -> "FileReader")
609
+ class_name = tool_name.split('_').map(&:capitalize).join
610
+
611
+ # Try to find the class in Clacky::Tools namespace
612
+ if Clacky::Tools.const_defined?(class_name)
613
+ tool_class = Clacky::Tools.const_get(class_name)
614
+ tool_class.new
615
+ else
616
+ nil
617
+ end
618
+ rescue NameError
619
+ nil
620
+ end
621
+
622
+ # Display welcome banner with logo and agent info
623
+ def display_welcome_banner
624
+ content = @welcome_banner.render_full(
625
+ working_dir: @config[:working_dir],
626
+ mode: @config[:mode]
627
+ )
628
+ append_output(content)
629
+ end
630
+
631
+ # Display recent user messages when loading session
632
+ # @param user_messages [Array<String>] Array of recent user message texts
633
+ def display_session_history(user_messages)
634
+ theme = ThemeManager.current_theme
635
+
636
+ # Show logo banner only
637
+ append_output(@welcome_banner.render_logo)
638
+
639
+ # Show simple header
640
+ append_output(theme.format_text("Recent conversation:", :info))
641
+
642
+ # Display each user message with numbering
643
+ user_messages.each_with_index do |msg, index|
644
+ # Truncate long messages
645
+ display_msg = if msg.length > 140
646
+ "#{msg[0..137]}..."
647
+ else
648
+ msg
649
+ end
650
+
651
+ # Show with number and indentation
652
+ append_output(" #{index + 1}. #{display_msg}")
653
+ end
654
+
655
+ # Bottom spacing and continuation prompt
656
+ append_output("")
657
+ append_output(theme.format_text("Session restored. Feel free to continue with your next task.", :success))
658
+ end
659
+
660
+ # Main input loop
661
+ def input_loop
662
+ @layout.screen.enable_raw_mode
663
+
664
+ while @running
665
+ key = @layout.screen.read_key(timeout: 0.1)
666
+ next unless key
667
+
668
+ handle_key(key)
669
+ end
670
+ rescue => e
671
+ stop
672
+ raise e
673
+ ensure
674
+ @layout.screen.disable_raw_mode
675
+ end
676
+
677
+ # Handle keyboard input - delegate to InputArea or InlineInput
678
+ # @param key [Symbol, String, Hash] Key input or rapid input hash
679
+ def handle_key(key)
680
+ # If InlineInput is active, delegate to it
681
+ if @inline_input&.active?
682
+ handle_inline_input_key(key)
683
+ return
684
+ end
685
+
686
+ result = @input_area.handle_key(key)
687
+
688
+ # Handle height change first
689
+ if result[:height_changed]
690
+ @layout.recalculate_layout
691
+ end
692
+
693
+ # Handle actions
694
+ case result[:action]
695
+ when :submit
696
+ handle_submit(result[:data])
697
+ when :exit
698
+ stop
699
+ exit(0)
700
+ when :interrupt
701
+ # Stop progress indicator
702
+ stop_progress_thread
703
+
704
+ # Check if input area has content
705
+ input_was_empty = @input_area.empty?
706
+
707
+ # Notify CLI to handle interrupt (stop agent or exit)
708
+ @interrupt_callback&.call(input_was_empty: input_was_empty)
709
+ when :clear_output
710
+ # Clear the screen
711
+ @layout.clear_output
712
+ # Notify the callback to reset session/agent
713
+ @input_callback&.call("/clear", [])
714
+ when :scroll_up
715
+ @layout.scroll_output_up
716
+ when :scroll_down
717
+ @layout.scroll_output_down
718
+ when :help
719
+ show_help
720
+ @input_area.clear
721
+ when :toggle_mode
722
+ toggle_mode
723
+ end
724
+
725
+ # Always re-render input area after key handling
726
+ @layout.render_input
727
+ end
728
+
729
+ # Handle key input for InlineInput
730
+ def handle_inline_input_key(key)
731
+ result = @inline_input.handle_key(key)
732
+
733
+ case result[:action]
734
+ when :update
735
+ # Update the last line of output with current input (use layout to track position)
736
+ @layout.update_last_line(@inline_input.render)
737
+ # Position cursor for inline input
738
+ @layout.position_inline_input_cursor(@inline_input)
739
+ when :submit, :cancel
740
+ # InlineInput is done, will be cleaned up by request_confirmation
741
+ nil
742
+ when :toggle_mode
743
+ # Update mode and session bar info, but don't render yet
744
+ current_mode = @config[:mode]
745
+ new_mode = case current_mode.to_s
746
+ when /confirm_safes/
747
+ "auto_approve"
748
+ when /auto_approve/
749
+ "confirm_safes"
750
+ else
751
+ "auto_approve"
752
+ end
753
+
754
+ @config[:mode] = new_mode
755
+ @mode_toggle_callback&.call(new_mode)
756
+
757
+ # Update session bar data (will be rendered by request_confirmation's render_all)
758
+ @input_area.update_sessionbar(
759
+ working_dir: @config[:working_dir],
760
+ mode: @config[:mode],
761
+ model: @config[:model],
762
+ tasks: @tasks_count,
763
+ cost: @total_cost
764
+ )
765
+ end
766
+ end
767
+
768
+ # Handle submit action
769
+ def handle_submit(data)
770
+ # Call callback first (allows interrupting previous agent before showing new input)
771
+ @input_callback&.call(data[:text], data[:images])
772
+
773
+ # Append the input content to output area after callback completes
774
+ @layout.append_output(data[:display]) unless data[:display].empty?
775
+ end
776
+ end
777
+ end
778
+ end