Class: Clacky::RichUIController

Inherits:
Object
  • Object
show all
Includes:
UIInterface
Defined in:
lib/clacky/rich_ui_controller.rb

Overview

Experimental RubyRich-backed TUI controller.

This intentionally implements the same surface as UI2::UIController so the CLI/Agent loop can switch implementations without knowing which TUI is underneath. It is not the default UI yet.

Defined Under Namespace

Classes: ConfigMenuDialog, FormDialog, LayoutAdapter, ProgressHandleAdapter

Constant Summary collapse

STREAMING_MARKDOWN_THRESHOLD =
240
STREAMING_MARKDOWN_CHUNK_SIZE =
6
STREAMING_MARKDOWN_DELAY =
0.03
COMMANDS =
[
  { label: "/clear", value: "/clear", description: "Clear output and restart session" },
  { label: "/config", value: "/config", description: "Open configuration" },
  { label: "/undo", value: "/undo", description: "Restore a previous task state" },
  { label: "/help", value: "/help", description: "Show commands" },
  { label: "/exit", value: "/exit", description: "Exit application", aliases: ["/quit"] }
].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from UIInterface

#phase_end, #phase_start, #show_feedback_request, #stream_thinking_progress, #with_phase

Constructor Details

#initialize(config = {}) ⇒ RichUIController

Returns a new instance of RichUIController.



504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
# File 'lib/clacky/rich_ui_controller.rb', line 504

def initialize(config = {})
  @config = {
    working_dir: config[:working_dir],
    mode: config[:mode],
    model: config[:model],
    theme: config[:theme]
  }
  @welcome_banner = Clacky::UI2::Components::WelcomeBanner.new
  @shell = RichAgentShell.new(
    title: "OpenClacky",
    subtitle: config[:working_dir].to_s,
    model: config[:model].to_s,
    commands: COMMANDS
  )
  @layout = LayoutAdapter.new(@shell)
  @input_callback = nil
  @interrupt_callback = nil
  @mode_toggle_callback = nil
  @time_machine_callback = nil
  @tasks_count = 0
  @total_cost = 0.0
  @running = false
  @tool_ids = []
  @todo_items = []
  @explicit_todo_cycle = false
  @tool_activities = []
  @tool_activity_by_id = {}
  @legacy_progress = {}
  @stdout_lines = []
  @callback_threads = []
  @stream_threads = []

  wire_shell_callbacks
end

Instance Attribute Details

#configObject

Returns the value of attribute config.



502
503
504
# File 'lib/clacky/rich_ui_controller.rb', line 502

def config
  @config
end

#layoutObject (readonly)

Returns the value of attribute layout.



501
502
503
# File 'lib/clacky/rich_ui_controller.rb', line 501

def layout
  @layout
end

#runningObject (readonly)

Returns the value of attribute running.



501
502
503
# File 'lib/clacky/rich_ui_controller.rb', line 501

def running
  @running
end

#shellObject (readonly)

Returns the value of attribute shell.



501
502
503
# File 'lib/clacky/rich_ui_controller.rb', line 501

def shell
  @shell
end

Instance Method Details

#append_output(content) ⇒ Object



589
590
591
592
593
# File 'lib/clacky/rich_ui_controller.rb', line 589

def append_output(content)
  return if content.nil?

  @shell.add_markdown(content.to_s)
end

#clear_inputObject



776
777
778
# File 'lib/clacky/rich_ui_controller.rb', line 776

def clear_input
  @shell.composer.editor.clear
end

#filter_thinking_tags(content) ⇒ Object



849
850
851
852
853
# File 'lib/clacky/rich_ui_controller.rb', line 849

def filter_thinking_tags(content)
  return content if content.nil?

  content.gsub(%r{<think(?:ing)?>[\s\S]*?</think(?:ing)?>}mi, "").gsub(/\n{3,}/, "\n\n").strip
end

#initialize_and_show_banner(recent_user_messages: nil) ⇒ Object



539
540
541
542
543
544
545
546
547
548
# File 'lib/clacky/rich_ui_controller.rb', line 539

def initialize_and_show_banner(recent_user_messages: nil)
  @running = true
  @shell.update_status(session_status)
  if recent_user_messages && !recent_user_messages.empty?
    @shell.add_separator("recent session")
    recent_user_messages.each { |message| @shell.add_user_message(message) }
  else
    add_plain_block(render_welcome_banner)
  end
end

#log(message, level: :info) ⇒ Object



595
596
597
598
599
600
601
602
# File 'lib/clacky/rich_ui_controller.rb', line 595

def log(message, level: :info)
  case level.to_sym
  when :error then show_error(message)
  when :warning, :warn then show_warning(message)
  when :debug then nil
  else show_info(message)
  end
end

#on_input(&block) ⇒ Object



573
574
575
# File 'lib/clacky/rich_ui_controller.rb', line 573

def on_input(&block)
  @input_callback = block
end

#on_interrupt(&block) ⇒ Object



577
578
579
# File 'lib/clacky/rich_ui_controller.rb', line 577

def on_interrupt(&block)
  @interrupt_callback = block
end

#on_mode_toggle(&block) ⇒ Object



581
582
583
# File 'lib/clacky/rich_ui_controller.rb', line 581

def on_mode_toggle(&block)
  @mode_toggle_callback = block
end

#on_time_machine(&block) ⇒ Object



585
586
587
# File 'lib/clacky/rich_ui_controller.rb', line 585

def on_time_machine(&block)
  @time_machine_callback = block
end

#request_confirmation(message, default: true) ⇒ Object



766
767
768
769
770
771
772
773
774
# File 'lib/clacky/rich_ui_controller.rb', line 766

def request_confirmation(message, default: true)
  show_info(message)
  @shell.confirm(
    title: "Confirm",
    message: message,
    choices: [{ key: true, label: "Yes" }, { key: false, label: "No" }],
    default: default
  )
end

#set_agent(_agent, _agent_profile = nil) ⇒ Object



571
# File 'lib/clacky/rich_ui_controller.rb', line 571

def set_agent(_agent, _agent_profile = nil); end

#set_idle_statusObject



762
763
764
# File 'lib/clacky/rich_ui_controller.rb', line 762

def set_idle_status
  update_sessionbar(status: "idle")
end

#set_input_tips(message, type: :info) ⇒ Object



780
781
782
# File 'lib/clacky/rich_ui_controller.rb', line 780

def set_input_tips(message, type: :info)
  update_sessionbar(status: "#{type}: #{message}")
end

#set_skill_loader(_skill_loader, _agent_profile = nil) ⇒ Object



570
# File 'lib/clacky/rich_ui_controller.rb', line 570

def set_skill_loader(_skill_loader, _agent_profile = nil); end

#set_working_statusObject



758
759
760
# File 'lib/clacky/rich_ui_controller.rb', line 758

def set_working_status
  update_sessionbar(status: "working")
end

#show_assistant_message(content, files:) ⇒ Object



604
605
606
607
608
609
610
611
612
613
# File 'lib/clacky/rich_ui_controller.rb', line 604

def show_assistant_message(content, files:)
  text = filter_thinking_tags(content)
  stream_thread = nil
  stream_thread = add_conversation_markdown(text) unless text.nil? || text.strip.empty?
  if stream_thread.is_a?(Thread)
    add_file_summary_after(stream_thread, files)
  else
    add_file_summary(files)
  end
end

#show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil) ⇒ Object



687
688
689
690
691
692
693
694
# File 'lib/clacky/rich_ui_controller.rb', line 687

def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
  set_idle_status
  return if awaiting_user_feedback || iterations <= 5

  parts = ["Completed #{iterations} iterations", "cost $#{cost.round(4)}"]
  parts << "#{duration.round(1)}s" if duration
  append_output(parts.join(" · "))
end

#show_config_modal(current_config, test_callback: nil) ⇒ Object



797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
# File 'lib/clacky/rich_ui_controller.rb', line 797

def show_config_modal(current_config, test_callback: nil)
  return nil unless @running

  loop do
    choices = config_menu_choices(current_config)
    result = show_menu_dialog(
      title: "Model Configuration",
      choices: choices,
      selected_index: config_initial_selection(choices)
    )
    return nil if result.nil?

    case result[:action]
    when :switch
      return result
    when :add
      new_model = show_model_edit_form(nil, test_callback: test_callback)
      if new_model
        anthropic_format = new_model[:provider] == "anthropic"
        current_config.add_model(
          model: new_model[:model],
          api_key: new_model[:api_key],
          base_url: new_model[:base_url],
          anthropic_format: anthropic_format
        )
        new_id = current_config.models.last["id"]
        return { action: :add, model_id: new_id }
      end
    when :edit
      current_model = current_config.current_model
      edited = show_model_edit_form(current_model, test_callback: test_callback)
      if edited
        current_model["api_key"] = edited[:api_key]
        current_model["model"] = edited[:model]
        current_model["base_url"] = edited[:base_url]
        return { action: :edit, model_id: current_model["id"] }
      end
    when :delete
      if current_config.models.length <= 1
        show_warning("Cannot delete the last model.")
        next
      end

      current_config.remove_model(current_config.current_model_index)
      new_current = current_config.current_model
      return { action: :delete, model_id: new_current && new_current["id"] }
    when :close
      return nil
    end
  end
end

#show_diff(old_content, new_content, max_lines: 50) ⇒ Object



666
667
668
669
670
671
672
673
674
675
676
# File 'lib/clacky/rich_ui_controller.rb', line 666

def show_diff(old_content, new_content, max_lines: 50)
  require "diffy"
  diff = Diffy::Diff.new(old_content, new_content, context: 3).to_s(:color)
  lines = diff.lines
  visible = lines.take(max_lines).join
  hidden = lines.length - max_lines
  visible += "\n... (#{hidden} more lines hidden)" if hidden.positive?
  @shell.add_diff(content: visible)
rescue LoadError
  append_output("Old size: #{old_content.bytesize} bytes\nNew size: #{new_content.bytesize} bytes")
end

#show_error(message) ⇒ Object



705
706
707
# File 'lib/clacky/rich_ui_controller.rb', line 705

def show_error(message)
  @shell.add_error_message(message.to_s)
end

#show_file_edit_preview(path) ⇒ Object



654
655
656
# File 'lib/clacky/rich_ui_controller.rb', line 654

def show_file_edit_preview(path)
  append_output("Editing file: #{path || "(unknown)"}")
end

#show_file_error(error_message) ⇒ Object



658
659
660
# File 'lib/clacky/rich_ui_controller.rb', line 658

def show_file_error(error_message)
  show_error(error_message)
end

#show_file_write_preview(path, is_new_file:) ⇒ Object



650
651
652
# File 'lib/clacky/rich_ui_controller.rb', line 650

def show_file_write_preview(path, is_new_file:)
  append_output("#{is_new_file ? "Creating" : "Modifying"} file: #{path || "(unknown)"}")
end

#show_helpObject



784
785
786
787
788
789
790
791
792
793
794
795
# File 'lib/clacky/rich_ui_controller.rb', line 784

def show_help
  @shell.add_markdown(<<~HELP)
    Commands:
      /clear - Clear output and restart session
      /exit - Exit application

    Input:
      Shift+Enter - New line
      Up/Down - History navigation
      Ctrl+C - Interrupt current task
  HELP
end

#show_info(message, prefix_newline: true) ⇒ Object



696
697
698
699
# File 'lib/clacky/rich_ui_controller.rb', line 696

def show_info(message, prefix_newline: true)
  _ = prefix_newline
  @shell.add_system_message(message.to_s)
end

#show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {}) ⇒ Object



713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
# File 'lib/clacky/rich_ui_controller.rb', line 713

def show_progress(message = nil, prefix_newline: true, progress_type: "thinking", phase: "active", metadata: {})
  _ = prefix_newline
  type = progress_type.to_s
  if phase.to_s == "done"
    @legacy_progress.delete(type)&.finish(final_message: message)
    return
  end

  handle = @legacy_progress[type]
  if handle
    handle.update(message: message, metadata: )
  else
    @legacy_progress[type] = start_progress(message: message, style: type == "thinking" ? :primary : :quiet)
  end
end

#show_shell_preview(command) ⇒ Object



662
663
664
# File 'lib/clacky/rich_ui_controller.rb', line 662

def show_shell_preview(command)
  append_output("$ #{command}")
end

#show_success(message) ⇒ Object



709
710
711
# File 'lib/clacky/rich_ui_controller.rb', line 709

def show_success(message)
  @shell.add_system_message("OK: #{message}")
end

#show_token_usage(token_data) ⇒ Object



678
679
680
681
682
683
684
685
# File 'lib/clacky/rich_ui_controller.rb', line 678

def show_token_usage(token_data)
  @shell.show_token_usage(
    input: token_data[:prompt_tokens],
    output: token_data[:completion_tokens],
    total: token_data[:total_tokens],
    cost: token_data[:cost]
  )
end

#show_tool_args(formatted_args) ⇒ Object



646
647
648
# File 'lib/clacky/rich_ui_controller.rb', line 646

def show_tool_args(formatted_args)
  append_output("Args: #{formatted_args}")
end

#show_tool_call(name, args) ⇒ Object



615
616
617
618
619
620
621
# File 'lib/clacky/rich_ui_controller.rb', line 615

def show_tool_call(name, args)
  id = @shell.start_tool_call(name: name.to_s, input: format_args(args), status: :running)
  if id
    @tool_ids << id
    track_tool_activity(id, tool_activity_label(name, args), :running)
  end
end

#show_tool_error(error) ⇒ Object



636
637
638
639
640
641
642
643
644
# File 'lib/clacky/rich_ui_controller.rb', line 636

def show_tool_error(error)
  message = error.is_a?(Exception) ? error.message : error.to_s
  if (id = @tool_ids.pop)
    @shell.finish_tool_call(id, status: :error, output: message)
    update_tool_activity(id, :error)
  else
    @shell.add_error_message(message)
  end
end

#show_tool_result(result) ⇒ Object



623
624
625
626
627
628
629
630
# File 'lib/clacky/rich_ui_controller.rb', line 623

def show_tool_result(result)
  if (id = @tool_ids.pop)
    @shell.finish_tool_call(id, status: :done, output: result.to_s)
    update_tool_activity(id, :done)
  else
    @shell.add_markdown(result.to_s)
  end
end

#show_tool_stdout(lines) ⇒ Object



632
633
634
# File 'lib/clacky/rich_ui_controller.rb', line 632

def show_tool_stdout(lines)
  @stdout_lines.concat(Array(lines).map(&:to_s))
end

#show_warning(message) ⇒ Object



701
702
703
# File 'lib/clacky/rich_ui_controller.rb', line 701

def show_warning(message)
  @shell.add_system_message("Warning: #{message}")
end

#startObject



550
551
552
553
# File 'lib/clacky/rich_ui_controller.rb', line 550

def start
  initialize_and_show_banner unless @running
  start_input_loop
end

#start_input_loopObject



555
556
557
558
559
560
# File 'lib/clacky/rich_ui_controller.rb', line 555

def start_input_loop
  @running = true
  @shell.start
ensure
  @running = false
end

#start_progress(message: nil, style: :primary, quiet_on_fast_finish: false) ⇒ Object



729
730
731
732
# File 'lib/clacky/rich_ui_controller.rb', line 729

def start_progress(message: nil, style: :primary, quiet_on_fast_finish: false)
  _ = quiet_on_fast_finish
  ProgressHandleAdapter.new(@shell.start_progress(message || "Working", style: style))
end

#stop(clear_screen: true) ⇒ Object

Clears the screen on exit by default — the Rich UI repaints fullscreen and leaves no useful scrollback to preserve.



564
565
566
567
568
# File 'lib/clacky/rich_ui_controller.rb', line 564

def stop(clear_screen: true)
  @running = false
  @shell.stop
  RubyRich::Terminal.clear if clear_screen
end

#update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil, session_id: nil) ⇒ Object



743
744
745
746
747
748
749
750
# File 'lib/clacky/rich_ui_controller.rb', line 743

def update_sessionbar(tasks: nil, cost: nil, cost_source: nil, status: nil, latency: nil, session_id: nil)
  _ = cost_source
  _ = latency
  @tasks_count = tasks if tasks
  @total_cost = cost if cost
  @status = status if status
  @shell.update_status(session_status)
end

#update_todos(todos) ⇒ Object



752
753
754
755
756
# File 'lib/clacky/rich_ui_controller.rb', line 752

def update_todos(todos)
  @todo_items = Array(todos).map { |todo| normalize_todo(todo) }
  @explicit_todo_cycle = true
  refresh_sidebar_tasks
end

#with_progress(message: nil, style: :primary, quiet_on_fast_finish: false) ⇒ Object



734
735
736
737
738
739
740
741
# File 'lib/clacky/rich_ui_controller.rb', line 734

def with_progress(message: nil, style: :primary, quiet_on_fast_finish: false)
  handle = start_progress(message: message, style: style, quiet_on_fast_finish: quiet_on_fast_finish)
  begin
    yield handle
  ensure
    handle.finish
  end
end