openclacky 0.7.0 → 0.7.2

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/commit/SKILL.md +29 -4
  3. data/.clackyrules +3 -1
  4. data/CHANGELOG.md +103 -2
  5. data/README.md +70 -161
  6. data/bin/clarky +11 -0
  7. data/docs/HOW-TO-USE-CN.md +96 -0
  8. data/docs/HOW-TO-USE.md +94 -0
  9. data/docs/config.example.yml +27 -0
  10. data/docs/deploy_subagent_design.md +540 -0
  11. data/docs/time_machine_design.md +247 -0
  12. data/docs/why-openclacky.md +0 -1
  13. data/lib/clacky/agent/cost_tracker.rb +180 -0
  14. data/lib/clacky/agent/llm_caller.rb +54 -0
  15. data/lib/clacky/{message_compressor.rb → agent/message_compressor.rb} +12 -36
  16. data/lib/clacky/agent/message_compressor_helper.rb +534 -0
  17. data/lib/clacky/agent/session_serializer.rb +152 -0
  18. data/lib/clacky/agent/skill_manager.rb +138 -0
  19. data/lib/clacky/agent/system_prompt_builder.rb +96 -0
  20. data/lib/clacky/agent/time_machine.rb +199 -0
  21. data/lib/clacky/agent/tool_executor.rb +434 -0
  22. data/lib/clacky/{tool_registry.rb → agent/tool_registry.rb} +1 -1
  23. data/lib/clacky/agent.rb +260 -1370
  24. data/lib/clacky/agent_config.rb +447 -10
  25. data/lib/clacky/cli.rb +275 -98
  26. data/lib/clacky/client.rb +12 -2
  27. data/lib/clacky/default_skills/code-explorer/SKILL.md +34 -0
  28. data/lib/clacky/default_skills/deploy/SKILL.md +13 -0
  29. data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +383 -0
  30. data/lib/clacky/default_skills/deploy/tools/check_health.rb +116 -0
  31. data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +174 -0
  32. data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +67 -0
  33. data/lib/clacky/default_skills/deploy/tools/list_services.rb +80 -0
  34. data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +67 -0
  35. data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +138 -0
  36. data/lib/clacky/default_skills/new/SKILL.md +2 -2
  37. data/lib/clacky/json_ui_controller.rb +195 -0
  38. data/lib/clacky/providers.rb +107 -0
  39. data/lib/clacky/skill.rb +48 -7
  40. data/lib/clacky/skill_loader.rb +7 -0
  41. data/lib/clacky/tools/edit.rb +105 -48
  42. data/lib/clacky/tools/file_reader.rb +44 -73
  43. data/lib/clacky/tools/invoke_skill.rb +89 -0
  44. data/lib/clacky/tools/list_tasks.rb +54 -0
  45. data/lib/clacky/tools/redo_task.rb +41 -0
  46. data/lib/clacky/tools/safe_shell.rb +1 -1
  47. data/lib/clacky/tools/shell.rb +74 -62
  48. data/lib/clacky/tools/trash_manager.rb +1 -1
  49. data/lib/clacky/tools/undo_task.rb +32 -0
  50. data/lib/clacky/tools/web_fetch.rb +2 -1
  51. data/lib/clacky/ui2/components/command_suggestions.rb +13 -3
  52. data/lib/clacky/ui2/components/inline_input.rb +23 -2
  53. data/lib/clacky/ui2/components/input_area.rb +65 -21
  54. data/lib/clacky/ui2/components/modal_component.rb +199 -62
  55. data/lib/clacky/ui2/layout_manager.rb +75 -25
  56. data/lib/clacky/ui2/line_editor.rb +23 -2
  57. data/lib/clacky/ui2/markdown_renderer.rb +31 -10
  58. data/lib/clacky/ui2/screen_buffer.rb +2 -0
  59. data/lib/clacky/ui2/ui_controller.rb +316 -37
  60. data/lib/clacky/ui2.rb +2 -0
  61. data/lib/clacky/ui_interface.rb +50 -0
  62. data/lib/clacky/utils/arguments_parser.rb +31 -3
  63. data/lib/clacky/utils/file_processor.rb +13 -18
  64. data/lib/clacky/version.rb +1 -1
  65. data/lib/clacky.rb +19 -9
  66. data/scripts/install.sh +274 -97
  67. data/scripts/uninstall.sh +12 -12
  68. metadata +40 -13
  69. data/.clacky/skills/test-skill/SKILL.md +0 -15
  70. data/lib/clacky/compression/base.rb +0 -231
  71. data/lib/clacky/compression/standard.rb +0 -339
  72. data/lib/clacky/config.rb +0 -117
  73. /data/lib/clacky/{hook_manager.rb → agent/hook_manager.rb} +0 -0
  74. /data/lib/clacky/{progress_indicator.rb → ui2/progress_indicator.rb} +0 -0
  75. /data/lib/clacky/{thinking_verbs.rb → ui2/thinking_verbs.rb} +0 -0
  76. /data/lib/clacky/{gitignore_parser.rb → utils/gitignore_parser.rb} +0 -0
  77. /data/lib/clacky/{model_pricing.rb → utils/model_pricing.rb} +0 -0
  78. /data/lib/clacky/{trash_directory.rb → utils/trash_directory.rb} +0 -0
@@ -6,12 +6,15 @@ require_relative "components/input_area"
6
6
  require_relative "components/todo_area"
7
7
  require_relative "components/welcome_banner"
8
8
  require_relative "components/inline_input"
9
- require_relative "../thinking_verbs"
9
+ require_relative "thinking_verbs"
10
+ require_relative "../ui_interface"
10
11
 
11
12
  module Clacky
12
13
  module UI2
13
14
  # UIController is the MVC controller layer that coordinates UI state and user interactions
14
15
  class UIController
16
+ include Clacky::UIInterface
17
+
15
18
  attr_reader :layout, :renderer, :running, :inline_input, :input_area
16
19
  attr_accessor :config
17
20
 
@@ -42,11 +45,13 @@ module Clacky
42
45
  @running = false
43
46
  @input_callback = nil
44
47
  @interrupt_callback = nil
48
+ @time_machine_callback = nil
45
49
  @tasks_count = 0
46
50
  @total_cost = 0.0
47
51
  @progress_thread = nil
48
52
  @progress_start_time = nil
49
53
  @progress_message = nil
54
+ @last_diff_lines = nil
50
55
  end
51
56
 
52
57
  # Start the UI controller
@@ -129,6 +134,18 @@ module Clacky
129
134
  @layout.cleanup_screen
130
135
  end
131
136
 
137
+ # Clear the input area
138
+ def clear_input
139
+ @input_area.clear
140
+ end
141
+
142
+ # Set input tips message
143
+ # @param message [String] Tip message to display
144
+ # @param type [Symbol] Tip type (:info, :warning, etc.)
145
+ def set_input_tips(message, type: :info)
146
+ @input_area.set_tips(message, type: type)
147
+ end
148
+
132
149
  # Set callback for user input
133
150
  # @param block [Proc] Callback to execute with user input
134
151
  def on_input(&block)
@@ -147,6 +164,12 @@ module Clacky
147
164
  @mode_toggle_callback = block
148
165
  end
149
166
 
167
+ # Set callback for time machine (ESC key)
168
+ # @param block [Proc] Callback to execute on time machine
169
+ def on_time_machine(&block)
170
+ @time_machine_callback = block
171
+ end
172
+
150
173
  # Set skill loader for command suggestions
151
174
  # @param skill_loader [Clacky::SkillLoader] The skill loader instance
152
175
  def set_skill_loader(skill_loader)
@@ -621,18 +644,31 @@ module Clacky
621
644
  diff = Diffy::Diff.new(old_content, new_content, context: 3)
622
645
  diff_lines = diff.to_s(:color).lines
623
646
 
647
+ # Store for fullscreen toggle
648
+ @last_diff_lines = diff_lines
649
+
624
650
  # Show diff without line numbers
625
651
  diff_lines.take(max_lines).each do |line|
626
652
  append_output(line.chomp)
627
653
  end
628
654
 
629
655
  if diff_lines.size > max_lines
630
- append_output("\n... (#{diff_lines.size - max_lines} more lines, diff truncated)")
656
+ append_output("\n... (#{diff_lines.size - max_lines} more lines, diff truncated. Press Ctrl+O to expand)")
631
657
  end
632
658
  rescue LoadError
633
659
  # Fallback if diffy is not available
634
660
  append_output(" Old size: #{old_content.bytesize} bytes")
635
661
  append_output(" New size: #{new_content.bytesize} bytes")
662
+ @last_diff_lines = nil
663
+ end
664
+
665
+ # Show fullscreen diff view (only if not already expanded)
666
+ private def redisplay_diff
667
+ return unless @last_diff_lines
668
+ return if @layout.fullscreen_mode? # Already in fullscreen, ignore
669
+
670
+ # Enter fullscreen diff mode
671
+ @layout.enter_fullscreen(@last_diff_lines, hint: "Press Ctrl+O to return")
636
672
  end
637
673
 
638
674
  private
@@ -686,15 +722,17 @@ module Clacky
686
722
  )
687
723
  append_output(content)
688
724
 
689
- # Check if API key is configured
725
+ # Check if API key is configured (show warning AFTER banner)
690
726
  check_api_key_configuration
691
727
  end
692
728
 
693
729
  # Check if API key is configured and show warning if missing
694
730
  private def check_api_key_configuration
695
- config = Clacky::Config.load
731
+ config = Clacky::AgentConfig.load
696
732
 
697
- if config.api_key.nil? || config.api_key.empty?
733
+ if !config.models_configured?
734
+ show_warning("No models configured! Please run /config to set up your models and API keys.")
735
+ elsif config.api_key.nil? || config.api_key.empty?
698
736
  show_warning("API key is not configured! Please run /config to set up your API key.")
699
737
  end
700
738
  end
@@ -748,6 +786,14 @@ module Clacky
748
786
  # Handle keyboard input - delegate to InputArea or InlineInput
749
787
  # @param key [Symbol, String, Hash] Key input or rapid input hash
750
788
  def handle_key(key)
789
+ # If in fullscreen mode, only handle Ctrl+O to exit
790
+ if @layout.fullscreen_mode?
791
+ if key == :ctrl_o
792
+ @layout.exit_fullscreen
793
+ end
794
+ return
795
+ end
796
+
751
797
  # If InlineInput is active, delegate to it
752
798
  if @inline_input&.active?
753
799
  handle_inline_input_key(key)
@@ -778,19 +824,23 @@ module Clacky
778
824
  # Notify CLI to handle interrupt (stop agent or exit)
779
825
  @interrupt_callback&.call(input_was_empty: input_was_empty)
780
826
  when :clear_output
781
- # Clear the screen
782
- @layout.clear_output
783
- # Notify the callback to reset session/agent
784
- @input_callback&.call("/clear", [])
827
+ # Pass to callback with data for display
828
+ @input_callback&.call("/clear", [], display: result[:data][:display])
785
829
  when :scroll_up
786
830
  @layout.scroll_output_up
787
831
  when :scroll_down
788
832
  @layout.scroll_output_down
789
833
  when :help
790
- show_help
791
- @input_area.clear
834
+ # Pass to callback with data for display
835
+ @input_callback&.call("/help", [], display: result[:data][:display])
792
836
  when :toggle_mode
793
837
  toggle_mode
838
+ when :toggle_expand
839
+ # Enter fullscreen diff view
840
+ redisplay_diff
841
+ when :time_machine
842
+ # Trigger time machine callback
843
+ @time_machine_callback&.call
794
844
  end
795
845
 
796
846
  # Always re-render input area after key handling
@@ -813,6 +863,9 @@ module Clacky
813
863
  when :submit, :cancel
814
864
  # InlineInput is done, will be cleaned up by request_confirmation after collect returns
815
865
  nil
866
+ when :toggle_expand
867
+ # Enter fullscreen diff view (will return when user presses Ctrl+O)
868
+ redisplay_diff
816
869
  when :toggle_mode
817
870
  # Update mode and session bar info, but don't render yet
818
871
  current_mode = @config[:mode]
@@ -841,64 +894,290 @@ module Clacky
841
894
 
842
895
  # Handle submit action
843
896
  private def handle_submit(data)
844
- # Call callback first (allows interrupting previous agent before showing new input)
845
- @input_callback&.call(data[:text], data[:images])
846
-
847
- # Append the input content to output area after callback completes
897
+ # Append the input content to output area first (so it displays before callback execution)
848
898
  @layout.append_output(data[:display]) unless data[:display].empty?
899
+
900
+ # Then call callback (allows interrupting previous agent before processing new input)
901
+ @input_callback&.call(data[:text], data[:images])
849
902
  end
850
903
 
851
- # Show configuration modal dialog
852
- # @param current_config [Clacky::Config] Current configuration object
904
+ # Show configuration modal dialog with multi-model support
905
+ # @param current_config [Clacky::AgentConfig] Current configuration object
853
906
  # @return [Hash, nil] Hash with updated config values, or nil if cancelled
854
907
  public def show_config_modal(current_config, test_callback: nil)
855
908
  modal = Components::ModalComponent.new
909
+
910
+ loop do
911
+ # Build menu choices
912
+ choices = []
913
+
914
+ # Add model list
915
+ current_config.models.each_with_index do |model, idx|
916
+ is_current = (idx == current_config.current_model_index)
917
+ model_name = model["model"] || "unnamed"
918
+ masked_key = mask_api_key(model["api_key"])
919
+
920
+ # Add type badge if present
921
+ type_badge = case model["type"]
922
+ when "default" then "[default] "
923
+ when "lite" then "[lite] "
924
+ else ""
925
+ end
926
+
927
+ display_name = "#{type_badge}#{model_name} (#{masked_key})"
928
+ choices << {
929
+ name: display_name,
930
+ value: { action: :switch, index: idx }
931
+ }
932
+ end
933
+
934
+ # Add action buttons
935
+ choices << { name: "─" * 50, disabled: true }
936
+ choices << { name: "[+] Add New Model", value: { action: :add } }
937
+ choices << { name: "[*] Edit Current Model", value: { action: :edit } }
938
+ choices << { name: "[-] Delete Model", value: { action: :delete } } if current_config.models.length > 1
939
+ choices << { name: "[X] Close", value: { action: :close } }
940
+
941
+ # Show menu
942
+ result = modal.show(title: "Model Configuration", choices: choices)
943
+
944
+ return nil if result.nil?
945
+
946
+ case result[:action]
947
+ when :switch
948
+ current_config.switch_model(result[:index])
949
+ # Auto-save after switching
950
+ current_config.save
951
+ # Return to indicate config changed (need to update client)
952
+ return { action: :switch }
953
+ when :add
954
+ new_model = show_model_edit_form(nil, test_callback: test_callback)
955
+ if new_model
956
+ # Determine anthropic_format based on provider
957
+ # For Anthropic provider, use Anthropic API format
958
+ anthropic_format = new_model[:provider] == "anthropic"
959
+
960
+ current_config.add_model(
961
+ model: new_model[:model],
962
+ api_key: new_model[:api_key],
963
+ base_url: new_model[:base_url],
964
+ anthropic_format: anthropic_format
965
+ )
966
+ # Auto-save after adding
967
+ current_config.save
968
+ # Set newly added model as default
969
+ current_config.switch_model(current_config.models.length - 1)
970
+ current_config.save
971
+ # Return to exit the menu
972
+ return { action: :switch }
973
+ end
974
+ when :edit
975
+ current_model = current_config.current_model
976
+ edited = show_model_edit_form(current_model, test_callback: test_callback)
977
+ if edited
978
+ # Update current model in place (keep anthropic_format unchanged)
979
+ current_model["api_key"] = edited[:api_key]
980
+ current_model["model"] = edited[:model]
981
+ current_model["base_url"] = edited[:base_url]
982
+ # Auto-save after editing
983
+ current_config.save
984
+ # Return to indicate config changed (need to update client)
985
+ return { action: :edit }
986
+ end
987
+ when :delete
988
+ if current_config.models.length <= 1
989
+ # Can't delete - show error and continue
990
+ next
991
+ end
992
+
993
+ # Delete current model
994
+ current_config.remove_model(current_config.current_model_index)
995
+ # Auto-save after deleting
996
+ current_config.save
997
+ when :close
998
+ # Just close the modal
999
+ return nil
1000
+ end
1001
+ end
1002
+ end
856
1003
 
1004
+ # Show time machine menu for task undo/redo
1005
+ # @param history [Array<Hash>] Task history with format: [{task_id, summary, status, has_branches}]
1006
+ # @return [Integer, nil] Selected task ID or nil if cancelled
1007
+ public def show_time_machine_menu(history)
1008
+ modal = Components::ModalComponent.new
1009
+
1010
+ # Build menu choices from history
1011
+ choices = history.map do |task|
1012
+ # Build visual indicator
1013
+ indicator = if task[:status] == :current
1014
+ "→ " # Current task
1015
+ elsif task[:status] == :future
1016
+ "↯ " # Future task (after undo)
1017
+ else
1018
+ " " # Past task
1019
+ end
1020
+
1021
+ # Add branch indicator
1022
+ indicator += "⎇ " if task[:has_branches]
1023
+
1024
+ # Truncate summary to fit on screen
1025
+ max_summary_length = 60
1026
+ summary = task[:summary]
1027
+ if summary.length > max_summary_length
1028
+ summary = summary[0...max_summary_length] + "..."
1029
+ end
1030
+
1031
+ {
1032
+ name: "#{indicator}Task #{task[:task_id]}: #{summary}",
1033
+ value: task[:task_id]
1034
+ }
1035
+ end
1036
+
1037
+ # Show modal
1038
+ result = modal.show(
1039
+ title: "Time Machine - Select Task to Navigate",
1040
+ choices: choices
1041
+ )
1042
+
1043
+ result # Return selected task_id or nil
1044
+ end
1045
+
1046
+ # Show form for editing a model
1047
+ # @param model [Hash, nil] Existing model hash or nil for new model
1048
+ # @return [Hash, nil] Updated model hash or nil if cancelled
1049
+ private def show_model_edit_form(model, test_callback: nil)
1050
+ modal = Components::ModalComponent.new
1051
+
1052
+ is_new = model.nil?
1053
+ model ||= {}
1054
+
1055
+ # For new models, show provider selection first
1056
+ selected_provider = nil
1057
+ if is_new
1058
+ # Build provider choices
1059
+ provider_choices = Clacky::Providers.list.map do |id, name|
1060
+ { name: name, value: id }
1061
+ end
1062
+ provider_choices << { name: "─" * 40, disabled: true }
1063
+ provider_choices << { name: "Custom (manual configuration)", value: "custom" }
1064
+
1065
+ # Show provider selection
1066
+ selected_provider = modal.show(
1067
+ title: "Select Provider",
1068
+ choices: provider_choices
1069
+ )
1070
+
1071
+ # User cancelled
1072
+ return nil if selected_provider.nil?
1073
+ end
1074
+
857
1075
  # Prepare masked API key for display
858
- masked_key = if current_config.api_key && !current_config.api_key.empty?
859
- "#{current_config.api_key[0..7]}#{'*' * 20}#{current_config.api_key[-4..]}"
860
- else
861
- "not set"
1076
+ masked_key = mask_api_key(model["api_key"])
1077
+
1078
+ # Pre-fill values from provider preset if selected
1079
+ provider_preset = nil
1080
+ if selected_provider && selected_provider != "custom"
1081
+ provider_preset = Clacky::Providers.get(selected_provider)
862
1082
  end
863
-
1083
+
1084
+ # Get default values from provider or existing model
1085
+ default_model = provider_preset ? provider_preset["default_model"] : model["model"]
1086
+ default_base_url = provider_preset ? provider_preset["base_url"] : model["base_url"]
1087
+ default_api_key = model["api_key"] || ""
1088
+
864
1089
  # Define fields
865
1090
  fields = [
866
1091
  {
867
1092
  name: :api_key,
868
- label: "API Key (current: #{masked_key}):",
1093
+ label: "API Key #{is_new ? '' : "(current: #{masked_key})"}:",
869
1094
  default: "",
870
1095
  mask: true
871
1096
  },
872
1097
  {
873
1098
  name: :model,
874
- label: "Model (current: #{current_config.model}):",
875
- default: ""
1099
+ label: "Model #{is_new && default_model ? "(default: #{default_model})" : (is_new ? '' : "(current: #{model['model']})")}:",
1100
+ default: default_model || ""
876
1101
  },
877
1102
  {
878
1103
  name: :base_url,
879
- label: "Base URL (current: #{current_config.base_url}):",
880
- default: ""
1104
+ label: "Base URL #{is_new && default_base_url ? "(default: #{default_base_url})" : (is_new ? '' : "(current: #{model['base_url']})")}:",
1105
+ default: default_base_url || ""
881
1106
  }
882
1107
  ]
883
-
1108
+
884
1109
  # Create validator if test_callback provided
885
1110
  validator = if test_callback
886
1111
  lambda do |values|
887
- # Merge values with current config
888
- test_config = current_config.dup
889
- test_config.api_key = values[:api_key] unless values[:api_key].to_s.empty?
890
- test_config.model = values[:model] unless values[:model].to_s.empty?
891
- test_config.base_url = values[:base_url] unless values[:base_url].to_s.empty?
1112
+ # Merge values: use user input if provided, otherwise keep existing model value
1113
+ api_key = values[:api_key].to_s.empty? ? model["api_key"] : values[:api_key]
1114
+ model_name = values[:model].to_s.empty? ? model["model"] : values[:model]
1115
+ base_url = values[:base_url].to_s.empty? ? model["base_url"] : values[:base_url]
1116
+ anthropic_format = model["anthropic_format"] # Not editable in form, use model's value
1117
+
1118
+ test_config_values = {
1119
+ "api_key" => api_key,
1120
+ "model" => model_name,
1121
+ "base_url" => base_url,
1122
+ "anthropic_format" => anthropic_format
1123
+ }
892
1124
 
893
- # Call the test callback with merged config
894
- test_callback.call(test_config)
1125
+ # For new models, require all fields
1126
+ if is_new
1127
+ if test_config_values["api_key"].to_s.empty?
1128
+ return { success: false, error: "API Key is required for new model" }
1129
+ end
1130
+ if test_config_values["model"].to_s.empty?
1131
+ return { success: false, error: "Model name is required" }
1132
+ end
1133
+ if test_config_values["base_url"].to_s.empty?
1134
+ return { success: false, error: "Base URL is required" }
1135
+ end
1136
+ end
1137
+
1138
+ # Create a temporary config for testing
1139
+ temp_config = Clacky::AgentConfig.new(models: [test_config_values], current_model_index: 0)
1140
+ test_callback.call(temp_config)
895
1141
  end
896
1142
  else
897
1143
  nil
898
1144
  end
899
-
1145
+
1146
+ # Determine modal title based on provider
1147
+ modal_title = if is_new && selected_provider && selected_provider != "custom"
1148
+ provider_name = Clacky::Providers.get(selected_provider)&.dig("name") || selected_provider
1149
+ "Add #{provider_name} Model"
1150
+ elsif is_new
1151
+ "Add Custom Model"
1152
+ else
1153
+ "Edit Model"
1154
+ end
1155
+
900
1156
  # Show modal and collect values
901
- modal.show(title: "Configuration", fields: fields, validator: validator)
1157
+ result = modal.show(
1158
+ title: modal_title,
1159
+ fields: fields,
1160
+ validator: validator
1161
+ )
1162
+
1163
+ return nil if result.nil?
1164
+
1165
+ # Merge with existing model values or provider defaults
1166
+ {
1167
+ api_key: result[:api_key].to_s.empty? ? model["api_key"] : result[:api_key],
1168
+ model: result[:model].to_s.empty? ? (model["model"] || default_model) : result[:model],
1169
+ base_url: result[:base_url].to_s.empty? ? (model["base_url"] || default_base_url) : result[:base_url],
1170
+ provider: selected_provider
1171
+ }
1172
+ end
1173
+
1174
+ # Mask API key for display
1175
+ private def mask_api_key(api_key)
1176
+ if api_key && !api_key.empty?
1177
+ "#{api_key[0..5]}...#{api_key[-4..]}"
1178
+ else
1179
+ "not set"
1180
+ end
902
1181
  end
903
1182
  end
904
1183
  end
data/lib/clacky/ui2.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  # UI2 - MVC-based terminal UI system for Clacky
4
4
  # Provides split-screen interface with scrollable output and fixed input
5
5
 
6
+ require_relative "ui2/thinking_verbs"
7
+ require_relative "ui2/progress_indicator"
6
8
  require_relative "ui2/theme_manager"
7
9
  require_relative "ui2/screen_buffer"
8
10
  require_relative "ui2/layout_manager"
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ # UIInterface defines the standard interface between Agent/CLI and UI implementations.
5
+ # All UI controllers (UIController, JsonUIController) must implement these methods.
6
+ module UIInterface
7
+ # === Output display ===
8
+ def show_user_message(content, images: []); end
9
+ def show_assistant_message(content); end
10
+ def show_tool_call(name, args); end
11
+ def show_tool_result(result); end
12
+ def show_tool_error(error); end
13
+ def show_tool_args(formatted_args); end
14
+ def show_file_write_preview(path, is_new_file:); end
15
+ def show_file_edit_preview(path); end
16
+ def show_file_error(error_message); end
17
+ def show_shell_preview(command); end
18
+ def show_diff(old_content, new_content, max_lines: 50); end
19
+ def show_token_usage(token_data); end
20
+ def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false); end
21
+ def append_output(content); end
22
+
23
+ # === Status messages ===
24
+ def show_info(message, prefix_newline: true); end
25
+ def show_warning(message); end
26
+ def show_error(message); end
27
+ def show_success(message); end
28
+ def log(message, level: :info); end
29
+
30
+ # === Progress ===
31
+ def show_progress(message = nil, prefix_newline: true); end
32
+ def clear_progress; end
33
+
34
+ # === State updates ===
35
+ def update_sessionbar(tasks: nil, cost: nil, status: nil); end
36
+ def update_todos(todos); end
37
+ def set_working_status; end
38
+ def set_idle_status; end
39
+
40
+ # === Blocking interaction ===
41
+ def request_confirmation(message, default: true); end
42
+
43
+ # === Input control (CLI layer) ===
44
+ def clear_input; end
45
+ def set_input_tips(message, type: :info); end
46
+
47
+ # === Lifecycle ===
48
+ def stop; end
49
+ end
50
+ end
@@ -29,14 +29,42 @@ module Clacky
29
29
 
30
30
  private
31
31
 
32
- # Simple JSON repair: complete brackets and quotes
32
+ # Simple JSON repair: complete brackets and quotes, and remove XML contamination
33
33
  def self.repair_json(json_str)
34
34
  result = json_str.strip
35
35
 
36
- # Complete unclosed strings
36
+ # Step 1: Remove XML-style parameter tags that Claude might mix in
37
+ # Pattern 1: </parameter> closing tags - remove completely
38
+ result = result.gsub(/<\/parameter>/, '')
39
+
40
+ # Pattern 2: <parameter name="key"> or <parameter name="key": opening tags -> convert to JSON key
41
+ # Example: \n<parameter name="end_line"> 330 -> , "end_line": 330
42
+ # Also handles: \n<parameter name="end_line": 330 -> , "end_line": 330
43
+ result = result.gsub(/<parameter\s+name="([^"]+)":\s*/) { |match| ", \"#{$1}\": " }
44
+ result = result.gsub(/<parameter\s+name="([^"]+)">/) { |match| ", \"#{$1}\":" }
45
+
46
+ # Pattern 3: Remove any remaining XML-like tags
47
+ result = result.gsub(/<[^>]+>/, '')
48
+
49
+ # Step 2: Clean up newlines with commas
50
+ # Example: 315\n, "end_line" -> 315, "end_line"
51
+ result = result.gsub(/\n\s*,/, ',')
52
+ result = result.gsub(/,\s*\n/, ',')
53
+
54
+ # Step 3: Clean up formatting issues
55
+ # Remove multiple consecutive commas
56
+ result = result.gsub(/,+/, ',')
57
+ # Remove trailing commas before closing braces/brackets
58
+ result = result.gsub(/,\s*}/, '}')
59
+ result = result.gsub(/,\s*\]/, ']')
60
+ # Remove leading commas after opening braces/brackets
61
+ result = result.gsub(/\{\s*,/, '{')
62
+ result = result.gsub(/\[\s*,/, '[')
63
+
64
+ # Step 4: Complete unclosed strings
37
65
  result += '"' if result.count('"').odd?
38
66
 
39
- # Complete unclosed braces
67
+ # Step 5: Complete unclosed braces
40
68
  depth = 0
41
69
  result.each_char { |c| depth += 1 if c == '{'; depth -= 1 if c == '}' }
42
70
  result += '}' * depth if depth > 0
@@ -166,35 +166,30 @@ module Clacky
166
166
  end
167
167
 
168
168
  # Check if file is binary (not text)
169
- # @param data [String] File content
169
+ # @param data [String] File content (should be read in binary mode, encoding: ASCII-8BIT)
170
170
  # @param sample_size [Integer] Number of bytes to check (default: 8192)
171
171
  # @return [Boolean] True if file appears to be binary
172
- def binary_file?(data, sample_size: 8192, min_binary_length: 512)
173
- # Check first N bytes for binary content
174
- sample = data[0, sample_size] || ""
172
+ #
173
+ # Strategy: only trust known magic byte signatures.
174
+ # We intentionally avoid heuristics (byte-ratio, UTF-8 validity, etc.) because
175
+ # they produce false positives on legitimate text files containing multibyte
176
+ # characters (e.g. Chinese, Japanese). Occasionally missing an unlabelled binary
177
+ # is acceptable; misclassifying a real text file is not.
178
+ def binary_file?(data, sample_size: 8192)
179
+ sample = data.b[0, sample_size] || ""
175
180
  return false if sample.empty?
176
181
 
177
- # Check for known binary signatures first
182
+ # Check for known binary file signatures (magic bytes)
178
183
  FILE_SIGNATURES.each do |signature, _format|
179
184
  return true if sample.start_with?(signature)
180
185
  end
181
186
 
182
- # Check for WebP (RIFF format)
183
- if sample.start_with?("RIFF".b) && sample.length >= 12 && sample[8..11] == "WEBP".b
187
+ # Check for WebP (RIFF....WEBP header)
188
+ if sample.start_with?("RIFF".b) && sample.bytesize >= 12 && sample[8..11] == "WEBP".b
184
189
  return true
185
190
  end
186
191
 
187
- # Only check non-printable ratio for samples above minimum length
188
- # This prevents short outputs from being incorrectly flagged as binary
189
- return false if sample.size < min_binary_length
190
-
191
- # Count non-printable characters (excluding common whitespace)
192
- non_printable = sample.bytes.count do |byte|
193
- byte < 32 && ![9, 10, 13].include?(byte) || byte >= 127
194
- end
195
-
196
- # If more than 30% non-printable, consider it binary
197
- (non_printable.to_f / sample.size) > 0.3
192
+ false
198
193
  end
199
194
 
200
195
  # Check if a file at the given path is binary (not text)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.7.0"
4
+ VERSION = "0.7.2"
5
5
  end