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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +76 -0
- data/.github/workflows/release.yml +24 -0
- data/.rubocop_todo.yml +122 -0
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +140 -0
- data/Rakefile +12 -0
- data/exe/ruby_coded +6 -0
- data/lib/ruby_coded/auth/auth_manager.rb +145 -0
- data/lib/ruby_coded/auth/callback_servlet.rb +41 -0
- data/lib/ruby_coded/auth/credentials_store.rb +35 -0
- data/lib/ruby_coded/auth/oauth_callback_server.rb +38 -0
- data/lib/ruby_coded/auth/pkce.rb +19 -0
- data/lib/ruby_coded/auth/providers/anthropic.rb +32 -0
- data/lib/ruby_coded/auth/providers/openai.rb +55 -0
- data/lib/ruby_coded/chat/app/event_dispatch.rb +78 -0
- data/lib/ruby_coded/chat/app.rb +104 -0
- data/lib/ruby_coded/chat/command_handler/agent_commands.rb +53 -0
- data/lib/ruby_coded/chat/command_handler/history_commands.rb +38 -0
- data/lib/ruby_coded/chat/command_handler/model_commands.rb +91 -0
- data/lib/ruby_coded/chat/command_handler/plan_commands.rb +112 -0
- data/lib/ruby_coded/chat/command_handler/token_commands.rb +128 -0
- data/lib/ruby_coded/chat/command_handler/token_formatting.rb +26 -0
- data/lib/ruby_coded/chat/command_handler.rb +89 -0
- data/lib/ruby_coded/chat/help.txt +28 -0
- data/lib/ruby_coded/chat/input_handler/modal_inputs.rb +102 -0
- data/lib/ruby_coded/chat/input_handler/normal_mode_input.rb +116 -0
- data/lib/ruby_coded/chat/input_handler.rb +39 -0
- data/lib/ruby_coded/chat/llm_bridge/plan_mode.rb +73 -0
- data/lib/ruby_coded/chat/llm_bridge/streaming_retries.rb +86 -0
- data/lib/ruby_coded/chat/llm_bridge/tool_call_handling.rb +129 -0
- data/lib/ruby_coded/chat/llm_bridge.rb +131 -0
- data/lib/ruby_coded/chat/model_filter.rb +115 -0
- data/lib/ruby_coded/chat/plan_clarification_parser.rb +38 -0
- data/lib/ruby_coded/chat/renderer/chat_panel.rb +128 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_input.rb +56 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_thinking.rb +124 -0
- data/lib/ruby_coded/chat/renderer/model_selector.rb +96 -0
- data/lib/ruby_coded/chat/renderer/plan_clarifier.rb +112 -0
- data/lib/ruby_coded/chat/renderer/plan_clarifier_layout.rb +42 -0
- data/lib/ruby_coded/chat/renderer/status_bar.rb +47 -0
- data/lib/ruby_coded/chat/renderer.rb +64 -0
- data/lib/ruby_coded/chat/state/message_assistant.rb +77 -0
- data/lib/ruby_coded/chat/state/message_token_tracking.rb +57 -0
- data/lib/ruby_coded/chat/state/messages.rb +70 -0
- data/lib/ruby_coded/chat/state/model_selection.rb +79 -0
- data/lib/ruby_coded/chat/state/plan_tracking.rb +140 -0
- data/lib/ruby_coded/chat/state/scrollable.rb +42 -0
- data/lib/ruby_coded/chat/state/token_cost.rb +128 -0
- data/lib/ruby_coded/chat/state/tool_confirmation.rb +129 -0
- data/lib/ruby_coded/chat/state.rb +205 -0
- data/lib/ruby_coded/config/user_config.rb +110 -0
- data/lib/ruby_coded/errors/auth_error.rb +12 -0
- data/lib/ruby_coded/initializer/cover.rb +29 -0
- data/lib/ruby_coded/initializer.rb +52 -0
- data/lib/ruby_coded/plugins/base.rb +44 -0
- data/lib/ruby_coded/plugins/command_completion/input_extension.rb +30 -0
- data/lib/ruby_coded/plugins/command_completion/plugin.rb +27 -0
- data/lib/ruby_coded/plugins/command_completion/renderer_extension.rb +54 -0
- data/lib/ruby_coded/plugins/command_completion/state_extension.rb +90 -0
- data/lib/ruby_coded/plugins/registry.rb +88 -0
- data/lib/ruby_coded/plugins.rb +21 -0
- data/lib/ruby_coded/strategies/api_key_strategy.rb +39 -0
- data/lib/ruby_coded/strategies/base.rb +37 -0
- data/lib/ruby_coded/strategies/oauth_strategy.rb +106 -0
- data/lib/ruby_coded/tools/agent_cancelled_error.rb +7 -0
- data/lib/ruby_coded/tools/agent_iteration_limit_error.rb +7 -0
- data/lib/ruby_coded/tools/base_tool.rb +50 -0
- data/lib/ruby_coded/tools/create_directory_tool.rb +34 -0
- data/lib/ruby_coded/tools/delete_path_tool.rb +50 -0
- data/lib/ruby_coded/tools/edit_file_tool.rb +40 -0
- data/lib/ruby_coded/tools/list_directory_tool.rb +53 -0
- data/lib/ruby_coded/tools/plan_system_prompt.rb +72 -0
- data/lib/ruby_coded/tools/read_file_tool.rb +54 -0
- data/lib/ruby_coded/tools/registry.rb +66 -0
- data/lib/ruby_coded/tools/run_command_tool.rb +75 -0
- data/lib/ruby_coded/tools/system_prompt.rb +32 -0
- data/lib/ruby_coded/tools/tool_rejected_error.rb +7 -0
- data/lib/ruby_coded/tools/write_file_tool.rb +31 -0
- data/lib/ruby_coded/version.rb +10 -0
- data/lib/ruby_coded.rb +16 -0
- data/sig/ruby_coded.rbs +4 -0
- 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
|