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

#show_feedback_request, #stream_thinking_progress

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



587
588
589
590
591
# File 'lib/clacky/rich_ui_controller.rb', line 587

def append_output(content)
  return if content.nil?

  @shell.add_markdown(content.to_s)
end

#clear_inputObject



774
775
776
# File 'lib/clacky/rich_ui_controller.rb', line 774

def clear_input
  @shell.composer.editor.clear
end

#filter_thinking_tags(content) ⇒ Object



847
848
849
850
851
# File 'lib/clacky/rich_ui_controller.rb', line 847

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



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

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



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

def on_input(&block)
  @input_callback = block
end

#on_interrupt(&block) ⇒ Object



575
576
577
# File 'lib/clacky/rich_ui_controller.rb', line 575

def on_interrupt(&block)
  @interrupt_callback = block
end

#on_mode_toggle(&block) ⇒ Object



579
580
581
# File 'lib/clacky/rich_ui_controller.rb', line 579

def on_mode_toggle(&block)
  @mode_toggle_callback = block
end

#on_time_machine(&block) ⇒ Object



583
584
585
# File 'lib/clacky/rich_ui_controller.rb', line 583

def on_time_machine(&block)
  @time_machine_callback = block
end

#request_confirmation(message, default: true) ⇒ Object



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

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



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

def set_agent(_agent, _agent_profile = nil); end

#set_idle_statusObject



760
761
762
# File 'lib/clacky/rich_ui_controller.rb', line 760

def set_idle_status
  update_sessionbar(status: "idle")
end

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



778
779
780
# File 'lib/clacky/rich_ui_controller.rb', line 778

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

#set_skill_loader(_skill_loader, _agent_profile = nil) ⇒ Object



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

def set_skill_loader(_skill_loader, _agent_profile = nil); end

#set_working_statusObject



756
757
758
# File 'lib/clacky/rich_ui_controller.rb', line 756

def set_working_status
  update_sessionbar(status: "working")
end

#show_assistant_message(content, files:) ⇒ Object



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

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



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

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



795
796
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
# File 'lib/clacky/rich_ui_controller.rb', line 795

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



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

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



703
704
705
# File 'lib/clacky/rich_ui_controller.rb', line 703

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

#show_file_edit_preview(path) ⇒ Object



652
653
654
# File 'lib/clacky/rich_ui_controller.rb', line 652

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

#show_file_error(error_message) ⇒ Object



656
657
658
# File 'lib/clacky/rich_ui_controller.rb', line 656

def show_file_error(error_message)
  show_error(error_message)
end

#show_file_write_preview(path, is_new_file:) ⇒ Object



648
649
650
# File 'lib/clacky/rich_ui_controller.rb', line 648

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

#show_helpObject



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

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



694
695
696
697
# File 'lib/clacky/rich_ui_controller.rb', line 694

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



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

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



660
661
662
# File 'lib/clacky/rich_ui_controller.rb', line 660

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

#show_success(message) ⇒ Object



707
708
709
# File 'lib/clacky/rich_ui_controller.rb', line 707

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

#show_token_usage(token_data) ⇒ Object



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

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



644
645
646
# File 'lib/clacky/rich_ui_controller.rb', line 644

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

#show_tool_call(name, args) ⇒ Object



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

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



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

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



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

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



630
631
632
# File 'lib/clacky/rich_ui_controller.rb', line 630

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

#show_warning(message) ⇒ Object



699
700
701
# File 'lib/clacky/rich_ui_controller.rb', line 699

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



727
728
729
730
# File 'lib/clacky/rich_ui_controller.rb', line 727

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: false) ⇒ Object



562
563
564
565
566
# File 'lib/clacky/rich_ui_controller.rb', line 562

def stop(clear_screen: false)
  @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



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

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



750
751
752
753
754
# File 'lib/clacky/rich_ui_controller.rb', line 750

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



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

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