ruby_coded 0.1.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 (85) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +76 -0
  3. data/.github/workflows/release.yml +24 -0
  4. data/.rubocop_todo.yml +122 -0
  5. data/CHANGELOG.md +9 -0
  6. data/CODE_OF_CONDUCT.md +10 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +140 -0
  9. data/Rakefile +12 -0
  10. data/exe/ruby_coded +6 -0
  11. data/lib/ruby_coded/auth/auth_manager.rb +145 -0
  12. data/lib/ruby_coded/auth/callback_servlet.rb +41 -0
  13. data/lib/ruby_coded/auth/credentials_store.rb +35 -0
  14. data/lib/ruby_coded/auth/oauth_callback_server.rb +38 -0
  15. data/lib/ruby_coded/auth/pkce.rb +19 -0
  16. data/lib/ruby_coded/auth/providers/anthropic.rb +32 -0
  17. data/lib/ruby_coded/auth/providers/openai.rb +55 -0
  18. data/lib/ruby_coded/chat/app/event_dispatch.rb +78 -0
  19. data/lib/ruby_coded/chat/app.rb +104 -0
  20. data/lib/ruby_coded/chat/command_handler/agent_commands.rb +53 -0
  21. data/lib/ruby_coded/chat/command_handler/history_commands.rb +38 -0
  22. data/lib/ruby_coded/chat/command_handler/model_commands.rb +91 -0
  23. data/lib/ruby_coded/chat/command_handler/plan_commands.rb +112 -0
  24. data/lib/ruby_coded/chat/command_handler/token_commands.rb +128 -0
  25. data/lib/ruby_coded/chat/command_handler/token_formatting.rb +26 -0
  26. data/lib/ruby_coded/chat/command_handler.rb +89 -0
  27. data/lib/ruby_coded/chat/help.txt +28 -0
  28. data/lib/ruby_coded/chat/input_handler/modal_inputs.rb +102 -0
  29. data/lib/ruby_coded/chat/input_handler/normal_mode_input.rb +116 -0
  30. data/lib/ruby_coded/chat/input_handler.rb +39 -0
  31. data/lib/ruby_coded/chat/llm_bridge/plan_mode.rb +73 -0
  32. data/lib/ruby_coded/chat/llm_bridge/streaming_retries.rb +86 -0
  33. data/lib/ruby_coded/chat/llm_bridge/tool_call_handling.rb +129 -0
  34. data/lib/ruby_coded/chat/llm_bridge.rb +131 -0
  35. data/lib/ruby_coded/chat/model_filter.rb +115 -0
  36. data/lib/ruby_coded/chat/plan_clarification_parser.rb +38 -0
  37. data/lib/ruby_coded/chat/renderer/chat_panel.rb +128 -0
  38. data/lib/ruby_coded/chat/renderer/chat_panel_input.rb +56 -0
  39. data/lib/ruby_coded/chat/renderer/chat_panel_thinking.rb +124 -0
  40. data/lib/ruby_coded/chat/renderer/model_selector.rb +96 -0
  41. data/lib/ruby_coded/chat/renderer/plan_clarifier.rb +112 -0
  42. data/lib/ruby_coded/chat/renderer/plan_clarifier_layout.rb +42 -0
  43. data/lib/ruby_coded/chat/renderer/status_bar.rb +47 -0
  44. data/lib/ruby_coded/chat/renderer.rb +64 -0
  45. data/lib/ruby_coded/chat/state/message_assistant.rb +77 -0
  46. data/lib/ruby_coded/chat/state/message_token_tracking.rb +57 -0
  47. data/lib/ruby_coded/chat/state/messages.rb +70 -0
  48. data/lib/ruby_coded/chat/state/model_selection.rb +79 -0
  49. data/lib/ruby_coded/chat/state/plan_tracking.rb +140 -0
  50. data/lib/ruby_coded/chat/state/scrollable.rb +42 -0
  51. data/lib/ruby_coded/chat/state/token_cost.rb +128 -0
  52. data/lib/ruby_coded/chat/state/tool_confirmation.rb +129 -0
  53. data/lib/ruby_coded/chat/state.rb +205 -0
  54. data/lib/ruby_coded/config/user_config.rb +110 -0
  55. data/lib/ruby_coded/errors/auth_error.rb +12 -0
  56. data/lib/ruby_coded/initializer/cover.rb +29 -0
  57. data/lib/ruby_coded/initializer.rb +52 -0
  58. data/lib/ruby_coded/plugins/base.rb +44 -0
  59. data/lib/ruby_coded/plugins/command_completion/input_extension.rb +30 -0
  60. data/lib/ruby_coded/plugins/command_completion/plugin.rb +27 -0
  61. data/lib/ruby_coded/plugins/command_completion/renderer_extension.rb +54 -0
  62. data/lib/ruby_coded/plugins/command_completion/state_extension.rb +90 -0
  63. data/lib/ruby_coded/plugins/registry.rb +88 -0
  64. data/lib/ruby_coded/plugins.rb +21 -0
  65. data/lib/ruby_coded/strategies/api_key_strategy.rb +39 -0
  66. data/lib/ruby_coded/strategies/base.rb +37 -0
  67. data/lib/ruby_coded/strategies/oauth_strategy.rb +106 -0
  68. data/lib/ruby_coded/tools/agent_cancelled_error.rb +7 -0
  69. data/lib/ruby_coded/tools/agent_iteration_limit_error.rb +7 -0
  70. data/lib/ruby_coded/tools/base_tool.rb +50 -0
  71. data/lib/ruby_coded/tools/create_directory_tool.rb +34 -0
  72. data/lib/ruby_coded/tools/delete_path_tool.rb +50 -0
  73. data/lib/ruby_coded/tools/edit_file_tool.rb +40 -0
  74. data/lib/ruby_coded/tools/list_directory_tool.rb +53 -0
  75. data/lib/ruby_coded/tools/plan_system_prompt.rb +72 -0
  76. data/lib/ruby_coded/tools/read_file_tool.rb +54 -0
  77. data/lib/ruby_coded/tools/registry.rb +66 -0
  78. data/lib/ruby_coded/tools/run_command_tool.rb +75 -0
  79. data/lib/ruby_coded/tools/system_prompt.rb +32 -0
  80. data/lib/ruby_coded/tools/tool_rejected_error.rb +7 -0
  81. data/lib/ruby_coded/tools/write_file_tool.rb +31 -0
  82. data/lib/ruby_coded/version.rb +10 -0
  83. data/lib/ruby_coded.rb +16 -0
  84. data/sig/ruby_coded.rbs +4 -0
  85. metadata +206 -0
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class CommandHandler
6
+ # Slash commands for displaying session token usage and cost reports.
7
+ module TokenCommands
8
+ private
9
+
10
+ def cmd_tokens(_rest)
11
+ breakdown = @state.session_cost_breakdown
12
+
13
+ if breakdown.empty?
14
+ @state.add_message(:system, "No token usage recorded yet.")
15
+ return
16
+ end
17
+
18
+ @state.add_message(:system, build_token_report(breakdown))
19
+ end
20
+
21
+ def build_token_report(breakdown)
22
+ lines = ["Session Token Usage & Cost Report", "═" * 50]
23
+ breakdown.each { |entry| lines << format_token_entry(entry) }
24
+ lines << ("─" * 50)
25
+ lines << format_token_totals(breakdown)
26
+ lines.join("\n")
27
+ end
28
+
29
+ def format_token_entry(entry)
30
+ lines = []
31
+ lines << "Model: #{entry[:model]}"
32
+
33
+ if entry[:input_price_per_million]
34
+ format_priced_entry(lines, entry)
35
+ else
36
+ format_unpriced_entry(lines, entry)
37
+ end
38
+
39
+ lines.join("\n")
40
+ end
41
+
42
+ def format_priced_entry(lines, entry)
43
+ append_base_priced_lines(lines, entry)
44
+ append_optional_priced_lines(lines, entry)
45
+ lines << subtotal_line(entry)
46
+ end
47
+
48
+ def append_base_priced_lines(lines, entry)
49
+ lines << priced_line("Input: ", entry[:input_tokens], entry[:input_cost], entry[:input_price_per_million])
50
+ lines << priced_line("Output: ", entry[:output_tokens], entry[:output_cost],
51
+ entry[:output_price_per_million])
52
+ end
53
+
54
+ def append_optional_priced_lines(lines, entry)
55
+ optional_priced_fields.each do |label, tokens_key, cost_key, price_key|
56
+ next unless entry[tokens_key].positive?
57
+
58
+ lines << priced_line(label, entry[tokens_key], entry[cost_key], entry[price_key])
59
+ end
60
+ end
61
+
62
+ def optional_priced_fields
63
+ [
64
+ ["Thinking:", :thinking_tokens, :thinking_cost, :thinking_price_per_million],
65
+ ["Cached: ", :cached_tokens, :cached_cost, :cached_input_price_per_million],
66
+ ["Cache wr:", :cache_creation_tokens, :cache_creation_cost, :cache_creation_price_per_million]
67
+ ]
68
+ end
69
+
70
+ def priced_line(label, tokens, cost, price_per_million)
71
+ " #{label} #{format_num(tokens)} tokens (#{format_usd(cost)} @ $#{price_per_million}/1M)"
72
+ end
73
+
74
+ def subtotal_line(entry)
75
+ " Subtotal: #{format_num(entry_total_tokens(entry))} tokens #{format_usd(entry[:total_cost])}"
76
+ end
77
+
78
+ def format_unpriced_entry(lines, entry)
79
+ lines << " Input: #{format_num(entry[:input_tokens])} tokens"
80
+ lines << " Output: #{format_num(entry[:output_tokens])} tokens"
81
+ append_optional_unpriced_lines(lines, entry)
82
+ lines << " Subtotal: #{format_num(entry_total_tokens(entry))} tokens (pricing unavailable)"
83
+ end
84
+
85
+ def append_optional_unpriced_lines(lines, entry)
86
+ lines << " Thinking: #{format_num(entry[:thinking_tokens])} tokens" if entry[:thinking_tokens].positive?
87
+ lines << " Cached: #{format_num(entry[:cached_tokens])} tokens" if entry[:cached_tokens].positive?
88
+ end
89
+
90
+ def entry_total_tokens(entry)
91
+ entry[:input_tokens] + entry[:output_tokens] + entry[:thinking_tokens] +
92
+ entry[:cached_tokens] + entry[:cache_creation_tokens]
93
+ end
94
+
95
+ def format_token_totals(breakdown)
96
+ totals = compute_totals(breakdown)
97
+ format_totals_summary(totals)
98
+ end
99
+
100
+ def compute_totals(breakdown)
101
+ {
102
+ input: breakdown.sum { |e| e[:input_tokens] },
103
+ output: breakdown.sum { |e| e[:output_tokens] },
104
+ thinking: breakdown.sum { |e| e[:thinking_tokens] },
105
+ cost: total_cost(breakdown)
106
+ }
107
+ end
108
+
109
+ def total_cost(breakdown)
110
+ costs = breakdown.map { |e| e[:total_cost] }.compact
111
+ costs.empty? ? nil : costs.sum
112
+ end
113
+
114
+ def format_totals_summary(totals)
115
+ total_tokens = totals[:input] + totals[:output] + totals[:thinking]
116
+ "Total: #{format_num(total_tokens)} tokens (#{token_parts(totals)}) | Cost: #{cost_string(totals[:cost])}"
117
+ end
118
+
119
+ def token_parts(totals)
120
+ parts = ["↑#{format_num(totals[:input])}", "↓#{format_num(totals[:output])}"]
121
+ parts << "💭#{format_num(totals[:thinking])}" if totals[:thinking].positive?
122
+ parts.join(" ")
123
+ end
124
+
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class CommandHandler
6
+ # Shared formatting helpers for token and cost display.
7
+ module TokenFormatting
8
+ private
9
+
10
+ def format_num(num)
11
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
12
+ end
13
+
14
+ def format_usd(amount)
15
+ return "N/A" if amount.nil?
16
+
17
+ "$#{format("%.2f", amount)}"
18
+ end
19
+
20
+ def cost_string(cost)
21
+ cost ? format_usd(cost) : "N/A"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ require_relative "command_handler/model_commands"
6
+ require_relative "command_handler/history_commands"
7
+ require_relative "command_handler/token_formatting"
8
+ require_relative "command_handler/token_commands"
9
+ require_relative "command_handler/agent_commands"
10
+ require_relative "command_handler/plan_commands"
11
+
12
+ module RubyCoded
13
+ module Chat
14
+ # Handles slash commands entered in the chat input.
15
+ # Base commands are always available; plugins can contribute
16
+ # additional commands via the plugin registry.
17
+ class CommandHandler
18
+ include ModelCommands
19
+ include HistoryCommands
20
+ include TokenFormatting
21
+ include TokenCommands
22
+ include AgentCommands
23
+ include PlanCommands
24
+
25
+ BASE_COMMANDS = {
26
+ "/help" => :cmd_help,
27
+ "/exit" => :cmd_exit,
28
+ "/quit" => :cmd_exit,
29
+ "/clear" => :cmd_clear,
30
+ "/model" => :cmd_model,
31
+ "/history" => :cmd_history,
32
+ "/tokens" => :cmd_tokens,
33
+ "/agent" => :cmd_agent,
34
+ "/plan" => :cmd_plan
35
+ }.freeze
36
+
37
+ HELP_TEXT = File.read(File.join(__dir__, "help.txt")).freeze
38
+
39
+ def initialize(state, llm_bridge:, user_config: nil, credentials_store: nil)
40
+ @state = state
41
+ @llm_bridge = llm_bridge
42
+ @user_config = user_config
43
+ @credentials_store = credentials_store
44
+ @commands = build_command_map
45
+ end
46
+
47
+ def handle(raw_input)
48
+ stripped = raw_input.strip
49
+ return if stripped.empty?
50
+
51
+ command, rest = stripped.split(" ", 2)
52
+ method_name = @commands[command.downcase]
53
+
54
+ if method_name
55
+ send(method_name, rest)
56
+ else
57
+ @state.add_message(:system, "Unknown command: #{command}. Type /help for available commands.")
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def build_command_map
64
+ cmds = BASE_COMMANDS.dup
65
+ cmds.merge!(RubyCoded.plugin_registry.all_commands)
66
+ cmds
67
+ end
68
+
69
+ def cmd_help(_rest)
70
+ text = HELP_TEXT.dup
71
+ plugin_descs = RubyCoded.plugin_registry.all_command_descriptions
72
+ unless plugin_descs.empty?
73
+ text += "\nPlugin commands:\n"
74
+ plugin_descs.each { |cmd, desc| text += " #{cmd.ljust(18)} #{desc}\n" }
75
+ end
76
+ @state.add_message(:system, text)
77
+ end
78
+
79
+ def cmd_exit(_rest)
80
+ @state.should_quit = true
81
+ end
82
+
83
+ def cmd_clear(_rest)
84
+ @state.clear_messages!
85
+ @state.add_message(:system, "Conversation cleared.")
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,28 @@
1
+ Available commands:
2
+ /help Show this help message
3
+ /model Select a model from available providers
4
+ /model <name> Switch directly to a named model
5
+ /model --all Select from all models (including deprecated)
6
+ /clear Clear the conversation history
7
+ /history Show conversation summary
8
+ /tokens Show detailed token usage and cost for this session
9
+ /agent Show agent mode status
10
+ /agent on Enable agent mode (tools for file operations)
11
+ /agent off Disable agent mode (chat only)
12
+ /plan Show plan mode status
13
+ /plan on Enable plan mode (structured development planning)
14
+ /plan off Disable plan mode (warns if unsaved plan)
15
+ /plan off --force Disable plan mode discarding unsaved plan
16
+ /plan save [file] Save current plan to a markdown file
17
+ /exit, /quit Exit the chat
18
+
19
+ Tool confirmations (agent mode):
20
+ [y] / Enter Approve the current tool call
21
+ [n] / Esc Reject the current tool call
22
+ [a] Approve current and all future tool calls (yes to all)
23
+
24
+ Plan clarification (plan mode):
25
+ [Up/Down] Navigate options
26
+ [Enter] Select highlighted option / send custom response
27
+ [Tab] Switch between options and free text input
28
+ [Esc] Skip clarification question
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class InputHandler
6
+ # Handles input events for specialized UI modes:
7
+ # tool confirmation, plan clarification, model selection, streaming, and mouse.
8
+ module ModalInputs
9
+ private
10
+
11
+ def handle_tool_confirmation_mode(event)
12
+ return :quit if event.ctrl_c?
13
+ return :tool_rejected if event.esc?
14
+
15
+ char = event.to_s.downcase
16
+ return :tool_approved if event.enter? || char == "y"
17
+ return :tool_rejected if char == "n"
18
+ return :tool_approved_all if char == "a"
19
+
20
+ nil
21
+ end
22
+
23
+ def handle_plan_clarification_mode(event)
24
+ return :quit if event.ctrl_c?
25
+ return :plan_clarification_skip if event.esc?
26
+ return toggle_clarification_mode if event.tab?
27
+
28
+ if @state.clarification_input_mode == :custom
29
+ handle_clarification_custom_input(event)
30
+ else
31
+ handle_clarification_options_input(event)
32
+ end
33
+ end
34
+
35
+ def handle_clarification_options_input(event)
36
+ if event.up?
37
+ @state.clarification_up
38
+ elsif event.down?
39
+ @state.clarification_down
40
+ elsif event.enter?
41
+ return :plan_clarification_selected
42
+ end
43
+ nil
44
+ end
45
+
46
+ def handle_clarification_custom_input(event)
47
+ if event.enter?
48
+ return :plan_clarification_custom unless @state.clarification_custom_input.strip.empty?
49
+ elsif event.backspace?
50
+ @state.delete_last_clarification_char
51
+ else
52
+ char = event.to_s
53
+ @state.append_to_clarification_input(char) unless char.empty? || event.ctrl? || event.alt?
54
+ end
55
+ nil
56
+ end
57
+
58
+ def toggle_clarification_mode
59
+ @state.toggle_clarification_input_mode!
60
+ nil
61
+ end
62
+
63
+ def handle_model_select_mode(event)
64
+ return :quit if event.ctrl_c?
65
+ return :model_select_cancel if event.esc?
66
+ return :model_selected if event.enter?
67
+
68
+ handle_model_select_input(event)
69
+ nil
70
+ end
71
+
72
+ def handle_model_select_input(event)
73
+ if event.up?
74
+ @state.model_select_up
75
+ elsif event.down?
76
+ @state.model_select_down
77
+ elsif event.backspace?
78
+ @state.delete_last_filter_char
79
+ else
80
+ append_filter_character(event)
81
+ end
82
+ end
83
+
84
+ def handle_streaming_mode(event)
85
+ return :quit if event.ctrl_c?
86
+ return :cancel_streaming if event.esc?
87
+ return :scroll_up if event.up? || event.page_up?
88
+ return :scroll_down if event.down? || event.page_down?
89
+
90
+ nil
91
+ end
92
+
93
+ def handle_mouse(event)
94
+ return :scroll_up if event.scroll_up?
95
+ return :scroll_down if event.scroll_down?
96
+
97
+ nil
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class InputHandler
6
+ # Handles input events for normal chat mode:
7
+ # text editing, cursor movement, scrolling, paste, and plugin dispatch.
8
+ module NormalModeInput
9
+ private
10
+
11
+ def handle_normal_mode(event)
12
+ return :quit if event.ctrl_c?
13
+
14
+ plugin_action = try_plugin_inputs(event)
15
+ return plugin_action if plugin_action
16
+
17
+ return submit if event.enter?
18
+ return backspace if event.backspace?
19
+ return clear_input if event.esc?
20
+
21
+ scroll_or_append(event)
22
+ end
23
+
24
+ # Runs each plugin's input handler in registration order.
25
+ # Returns the first non-nil action, or nil if no plugin handled it.
26
+ def try_plugin_inputs(event)
27
+ RubyCoded.plugin_registry.input_handler_configs.each do |config|
28
+ result = send(config[:method], event)
29
+ return result if result
30
+ end
31
+ nil
32
+ end
33
+
34
+ def scroll_or_append(event)
35
+ scroll = detect_scroll(event)
36
+ return scroll if scroll
37
+ return move_cursor_left if event.left?
38
+ return move_cursor_right if event.right?
39
+ return move_cursor_home if event.home?
40
+ return move_cursor_end if event.end?
41
+
42
+ append_character(event)
43
+ end
44
+
45
+ def detect_scroll(event)
46
+ return :scroll_up if event.up? || event.page_up?
47
+
48
+ :scroll_down if event.down? || event.page_down?
49
+ end
50
+
51
+ def submit
52
+ return nil if @state.input_buffer.strip.empty?
53
+
54
+ :submit
55
+ end
56
+
57
+ def backspace
58
+ @state.delete_last_char
59
+ nil
60
+ end
61
+
62
+ def clear_input
63
+ @state.clear_input!
64
+ nil
65
+ end
66
+
67
+ def move_cursor_left
68
+ @state.move_cursor_left
69
+ nil
70
+ end
71
+
72
+ def move_cursor_right
73
+ @state.move_cursor_right
74
+ nil
75
+ end
76
+
77
+ def move_cursor_home
78
+ @state.move_cursor_to_start
79
+ nil
80
+ end
81
+
82
+ def move_cursor_end
83
+ @state.move_cursor_to_end
84
+ nil
85
+ end
86
+
87
+ def append_character(event)
88
+ char = event.to_s
89
+ return nil if char.empty?
90
+ return nil if event.ctrl? || event.alt?
91
+
92
+ @state.append_to_input(char)
93
+ nil
94
+ end
95
+
96
+ def append_filter_character(event)
97
+ char = event.to_s
98
+ return if char.empty?
99
+ return if event.ctrl? || event.alt?
100
+
101
+ @state.append_to_model_filter(char)
102
+ end
103
+
104
+ def handle_paste(event)
105
+ text = event.content.tr("\n", " ")
106
+ if @state.model_select?
107
+ @state.append_to_model_filter(text) unless text.empty?
108
+ else
109
+ @state.append_to_input(text) unless text.empty?
110
+ end
111
+ nil
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ratatui_ruby"
4
+
5
+ require_relative "input_handler/modal_inputs"
6
+ require_relative "input_handler/normal_mode_input"
7
+
8
+ module RubyCoded
9
+ module Chat
10
+ # This class is used to handle the input events for the chat
11
+ class InputHandler
12
+ include ModalInputs
13
+ include NormalModeInput
14
+
15
+ def initialize(state)
16
+ @state = state
17
+ end
18
+
19
+ def process(event)
20
+ return handle_paste(event) if event.is_a?(RatatuiRuby::Event::Paste)
21
+ return handle_mouse(event) if event.is_a?(RatatuiRuby::Event::Mouse)
22
+ return nil unless event.key?
23
+
24
+ dispatch_key_event(event)
25
+ end
26
+
27
+ private
28
+
29
+ def dispatch_key_event(event)
30
+ return handle_tool_confirmation_mode(event) if @state.awaiting_tool_confirmation?
31
+ return handle_plan_clarification_mode(event) if @state.plan_clarification?
32
+ return handle_model_select_mode(event) if @state.model_select?
33
+ return handle_streaming_mode(event) if @state.streaming?
34
+
35
+ handle_normal_mode(event)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class LLMBridge
6
+ # Plan mode configuration, auto-switching to agent, and clarification handling.
7
+ module PlanMode
8
+ IMPLEMENTATION_PATTERNS = [
9
+ /\bimplement/i,
10
+ /\bgo ahead/i,
11
+ /\bproceed/i,
12
+ /\bexecut/i,
13
+ /\bejecutar?/i,
14
+ /\bcomenz/i,
15
+ /\bcomienz/i,
16
+ /\bhazlo/i,
17
+ /\bconstru[iy]/i,
18
+ /\badelante/i,
19
+ /\bdale\b/i,
20
+ /\bdo it/i,
21
+ /\bbuild it/i
22
+ ].freeze
23
+
24
+ private
25
+
26
+ def should_auto_switch_to_agent?(input)
27
+ @plan_mode && @state.current_plan && implementation_request?(input)
28
+ end
29
+
30
+ def implementation_request?(input)
31
+ IMPLEMENTATION_PATTERNS.any? { |pattern| input.match?(pattern) }
32
+ end
33
+
34
+ def auto_switch_to_agent!
35
+ toggle_agentic_mode!(true)
36
+ @state.add_message(:system,
37
+ "Plan mode disabled — switching to agent mode to implement the plan.")
38
+ end
39
+
40
+ def configure_plan!(chat)
41
+ readonly_tools = @tool_registry.build_readonly_tools
42
+ chat.with_tools(*readonly_tools, replace: true)
43
+ chat.with_instructions(Tools::PlanSystemPrompt.build(project_root: @project_root))
44
+
45
+ chat.on_tool_call { |tool_call| handle_tool_call(tool_call) }
46
+ chat.on_tool_result { |result| handle_tool_result(result) }
47
+ end
48
+
49
+ def post_process_plan_response
50
+ last_msg = @state.messages_snapshot.last
51
+ return unless last_msg && last_msg[:role] == :assistant
52
+
53
+ content = last_msg[:content]
54
+ if PlanClarificationParser.clarification?(content)
55
+ handle_plan_clarification(content)
56
+ else
57
+ @state.update_current_plan!(content)
58
+ end
59
+ end
60
+
61
+ def handle_plan_clarification(content)
62
+ parsed = PlanClarificationParser.parse(content)
63
+ return unless parsed
64
+
65
+ stripped = PlanClarificationParser.strip_clarification(content)
66
+ @state.reset_last_assistant_content
67
+ @state.append_to_last_message(stripped) unless stripped.empty?
68
+ @state.enter_plan_clarification!(parsed[:question], parsed[:options])
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class LLMBridge
6
+ # Manages streaming responses, retry logic, and error recovery.
7
+ module StreamingRetries
8
+ private
9
+
10
+ def prepare_streaming
11
+ @cancel_requested = false
12
+ @state.streaming = true
13
+ @state.add_message(:assistant, "")
14
+ @chat_mutex.synchronize { @chat }
15
+ end
16
+
17
+ def update_response_tokens(response)
18
+ return unless response && !@cancel_requested && response.respond_to?(:input_tokens)
19
+
20
+ @state.update_last_message_tokens(
21
+ input_tokens: response.input_tokens,
22
+ output_tokens: response.output_tokens,
23
+ thinking_tokens: response.respond_to?(:thinking_tokens) ? response.thinking_tokens : nil,
24
+ cached_tokens: response.respond_to?(:cached_tokens) ? response.cached_tokens : nil,
25
+ cache_creation_tokens: response.respond_to?(:cache_creation_tokens) ? response.cache_creation_tokens : nil
26
+ )
27
+ end
28
+
29
+ def attempt_with_retries(chat, input, retries = 0)
30
+ stream_response(chat, input, retries)
31
+ rescue Tools::AgentCancelledError, Tools::AgentIterationLimitError, RubyCoded::Tools::ToolRejectedError => e
32
+ @state.add_message(:system, e.message)
33
+ nil
34
+ rescue RubyLLM::RateLimitError => e
35
+ retry if (retries = handle_rate_limit_retry(e, retries))
36
+ handle_api_failure(e, rate_limit_user_message(e))
37
+ rescue StandardError => e
38
+ handle_api_failure(e, generic_api_error_message(e))
39
+ end
40
+
41
+ def handle_api_failure(error, message)
42
+ @state.fail_last_assistant(error, friendly_message: message)
43
+ nil
44
+ end
45
+
46
+ def stream_response(chat, input, retries)
47
+ block = streaming_block
48
+ retries.zero? ? chat.ask(input, &block) : chat.complete(&block)
49
+ end
50
+
51
+ def streaming_block
52
+ proc do |chunk|
53
+ break if @cancel_requested
54
+
55
+ @state.streaming_append(chunk.content) if chunk.content
56
+ end
57
+ end
58
+
59
+ def handle_rate_limit_retry(error, retries)
60
+ return unless retries < MAX_RATE_LIMIT_RETRIES && !@cancel_requested
61
+
62
+ retries += 1
63
+ delay = RATE_LIMIT_BASE_DELAY * (2**(retries - 1))
64
+ @state.fail_last_assistant(
65
+ error,
66
+ friendly_message: "Rate limit alcanzado. Reintentando en #{delay}s... (#{retries}/#{MAX_RATE_LIMIT_RETRIES})"
67
+ )
68
+ sleep(delay)
69
+ @state.reset_last_assistant_content
70
+ retries
71
+ end
72
+
73
+ def rate_limit_user_message(error)
74
+ <<~MSG.strip
75
+ Límite de peticiones del proveedor (rate limit). Espera un minuto y vuelve a intentar; si se repite, revisa cuotas y plan en la consola de tu API (OpenAI, Anthropic, etc.).
76
+ Detalle: #{error.message}
77
+ MSG
78
+ end
79
+
80
+ def generic_api_error_message(error)
81
+ "No se pudo obtener respuesta del modelo: #{error.message}"
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end