openclacky 0.5.6 → 0.6.0

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