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,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class State
|
|
6
|
+
# This module contains the logic for the model selection management
|
|
7
|
+
module ModelSelection
|
|
8
|
+
def model_select?
|
|
9
|
+
@mode == :model_select
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def model_select_show_all?
|
|
13
|
+
@model_select_show_all == true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def enter_model_select!(models, show_all: false)
|
|
17
|
+
@model_list = models
|
|
18
|
+
@model_select_index = 0
|
|
19
|
+
@model_select_filter = String.new
|
|
20
|
+
@model_select_show_all = show_all
|
|
21
|
+
@mode = :model_select
|
|
22
|
+
mark_dirty!
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def exit_model_select!
|
|
26
|
+
@mode = :chat
|
|
27
|
+
@model_list = []
|
|
28
|
+
@model_select_index = 0
|
|
29
|
+
@model_select_filter = String.new
|
|
30
|
+
@model_select_show_all = false
|
|
31
|
+
mark_dirty!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def model_select_up
|
|
35
|
+
filtered = filtered_model_list
|
|
36
|
+
return if filtered.empty?
|
|
37
|
+
|
|
38
|
+
@model_select_index = (@model_select_index - 1) % filtered.size
|
|
39
|
+
mark_dirty!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def model_select_down
|
|
43
|
+
filtered = filtered_model_list
|
|
44
|
+
return if filtered.empty?
|
|
45
|
+
|
|
46
|
+
@model_select_index = (@model_select_index + 1) % filtered.size
|
|
47
|
+
mark_dirty!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def selected_model
|
|
51
|
+
filtered_model_list[@model_select_index]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def filtered_model_list
|
|
55
|
+
return @model_list if @model_select_filter.empty?
|
|
56
|
+
|
|
57
|
+
query = @model_select_filter.downcase
|
|
58
|
+
@model_list.select do |m|
|
|
59
|
+
model_id = m.respond_to?(:id) ? m.id : m.to_s
|
|
60
|
+
provider = m.respond_to?(:provider) ? m.provider.to_s : ""
|
|
61
|
+
model_id.downcase.include?(query) || provider.downcase.include?(query)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def append_to_model_filter(text)
|
|
66
|
+
@model_select_filter << text
|
|
67
|
+
@model_select_index = 0
|
|
68
|
+
mark_dirty!
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def delete_last_filter_char
|
|
72
|
+
@model_select_filter.chop!
|
|
73
|
+
@model_select_index = 0
|
|
74
|
+
mark_dirty!
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class State
|
|
6
|
+
# Manages plan mode state and the clarification UI flow.
|
|
7
|
+
# Plan mode tracks the current plan in memory and exposes
|
|
8
|
+
# a clarification overlay (similar to ModelSelection) when
|
|
9
|
+
# the LLM needs user input before generating the plan.
|
|
10
|
+
module PlanTracking
|
|
11
|
+
attr_reader :clarification_question, :clarification_options,
|
|
12
|
+
:clarification_index, :clarification_custom_input,
|
|
13
|
+
:clarification_input_mode
|
|
14
|
+
|
|
15
|
+
def init_plan_tracking
|
|
16
|
+
@plan_mode_active = false
|
|
17
|
+
@current_plan = nil
|
|
18
|
+
@plan_saved = true
|
|
19
|
+
reset_clarification_state
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# --- Plan mode ---
|
|
23
|
+
|
|
24
|
+
def plan_mode_active?
|
|
25
|
+
@plan_mode_active
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def activate_plan_mode!
|
|
29
|
+
@plan_mode_active = true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def deactivate_plan_mode!
|
|
33
|
+
@plan_mode_active = false
|
|
34
|
+
@current_plan = nil
|
|
35
|
+
@plan_saved = true
|
|
36
|
+
reset_clarification_state
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def update_current_plan!(content)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
@current_plan = content
|
|
42
|
+
@plan_saved = false
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def current_plan
|
|
47
|
+
@mutex.synchronize { @current_plan }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def plan_saved?
|
|
51
|
+
@mutex.synchronize { @plan_saved }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mark_plan_saved!
|
|
55
|
+
@mutex.synchronize { @plan_saved = true }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def has_unsaved_plan?
|
|
59
|
+
@mutex.synchronize { @current_plan && !@plan_saved }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clear_plan!
|
|
63
|
+
@mutex.synchronize do
|
|
64
|
+
@current_plan = nil
|
|
65
|
+
@plan_saved = true
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# --- Clarification UI (pattern: ModelSelection) ---
|
|
70
|
+
|
|
71
|
+
def plan_clarification?
|
|
72
|
+
@mode == :plan_clarification
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def enter_plan_clarification!(question, options)
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
@clarification_question = question
|
|
78
|
+
@clarification_options = options
|
|
79
|
+
@clarification_index = 0
|
|
80
|
+
@clarification_custom_input = String.new
|
|
81
|
+
@clarification_input_mode = :options
|
|
82
|
+
@mode = :plan_clarification
|
|
83
|
+
@dirty = true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def exit_plan_clarification!
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
@mode = :chat
|
|
90
|
+
reset_clarification_state
|
|
91
|
+
@dirty = true
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def clarification_up
|
|
96
|
+
return if @clarification_options.empty?
|
|
97
|
+
|
|
98
|
+
@clarification_index = (@clarification_index - 1) % @clarification_options.size
|
|
99
|
+
mark_dirty!
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def clarification_down
|
|
103
|
+
return if @clarification_options.empty?
|
|
104
|
+
|
|
105
|
+
@clarification_index = (@clarification_index + 1) % @clarification_options.size
|
|
106
|
+
mark_dirty!
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def selected_clarification_option
|
|
110
|
+
@clarification_options[@clarification_index]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def toggle_clarification_input_mode!
|
|
114
|
+
@clarification_input_mode = @clarification_input_mode == :options ? :custom : :options
|
|
115
|
+
mark_dirty!
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def append_to_clarification_input(text)
|
|
119
|
+
@clarification_custom_input << text
|
|
120
|
+
mark_dirty!
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def delete_last_clarification_char
|
|
124
|
+
@clarification_custom_input.chop!
|
|
125
|
+
mark_dirty!
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def reset_clarification_state
|
|
131
|
+
@clarification_question = nil
|
|
132
|
+
@clarification_options = []
|
|
133
|
+
@clarification_index = 0
|
|
134
|
+
@clarification_custom_input = String.new
|
|
135
|
+
@clarification_input_mode = :options
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class State
|
|
6
|
+
# This module contains the logic for the scrollable management.
|
|
7
|
+
# scroll_offset represents "lines from the bottom": 0 = latest content visible.
|
|
8
|
+
module Scrollable
|
|
9
|
+
def scroll_up(amount = 1)
|
|
10
|
+
@scroll_offset = [@scroll_offset + amount, max_scroll].min
|
|
11
|
+
mark_dirty!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def scroll_down(amount = 1)
|
|
15
|
+
@scroll_offset = [@scroll_offset - amount, 0].max
|
|
16
|
+
mark_dirty!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def scroll_to_top
|
|
20
|
+
@scroll_offset = max_scroll
|
|
21
|
+
mark_dirty!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def scroll_to_bottom
|
|
25
|
+
@scroll_offset = 0
|
|
26
|
+
mark_dirty!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def update_scroll_metrics(total_lines:, visible_height:)
|
|
30
|
+
@total_lines = total_lines
|
|
31
|
+
@visible_height = visible_height
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def max_scroll
|
|
37
|
+
[@total_lines - @visible_height, 0].max
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module RubyCoded
|
|
6
|
+
module Chat
|
|
7
|
+
class State
|
|
8
|
+
# Provides session cost calculation based on token usage and model pricing.
|
|
9
|
+
# Looks up per-model pricing via RubyLLM's model registry.
|
|
10
|
+
# Accounts for thinking/reasoning tokens, cached input reads, and
|
|
11
|
+
# cache creation tokens in addition to regular input/output.
|
|
12
|
+
module TokenCost
|
|
13
|
+
# Anthropic charges 1.25× input price for cache writes.
|
|
14
|
+
# OpenAI reports cache_creation as 0, so this only affects Anthropic.
|
|
15
|
+
CACHE_CREATION_INPUT_MULTIPLIER = 1.25
|
|
16
|
+
|
|
17
|
+
UNPRICED_DEFAULTS = {
|
|
18
|
+
input_price_per_million: nil, output_price_per_million: nil,
|
|
19
|
+
thinking_price_per_million: nil, cached_input_price_per_million: nil,
|
|
20
|
+
cache_creation_price_per_million: nil,
|
|
21
|
+
input_cost: nil, output_cost: nil, thinking_cost: nil,
|
|
22
|
+
cached_cost: nil, cache_creation_cost: nil, total_cost: nil
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def init_token_cost
|
|
26
|
+
@model_price_cache = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns an array of cost breakdown hashes, one per model used.
|
|
30
|
+
# Cost fields are nil when pricing is unavailable.
|
|
31
|
+
def session_cost_breakdown
|
|
32
|
+
token_usage_by_model.map do |model_name, usage|
|
|
33
|
+
pricing = fetch_model_pricing(model_name)
|
|
34
|
+
build_cost_entry(model_name, usage, pricing)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def total_session_cost
|
|
39
|
+
breakdown = session_cost_breakdown
|
|
40
|
+
costs = breakdown.map { |entry| entry[:total_cost] }.compact
|
|
41
|
+
return nil if costs.empty?
|
|
42
|
+
|
|
43
|
+
costs.sum
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def fetch_model_pricing(model_name)
|
|
49
|
+
return @model_price_cache[model_name] if @model_price_cache.key?(model_name)
|
|
50
|
+
|
|
51
|
+
info = RubyLLM.models.find(model_name)
|
|
52
|
+
pricing = if info.respond_to?(:input_price_per_million) && info.input_price_per_million
|
|
53
|
+
build_pricing_hash(info)
|
|
54
|
+
end
|
|
55
|
+
@model_price_cache[model_name] = pricing
|
|
56
|
+
pricing
|
|
57
|
+
rescue StandardError
|
|
58
|
+
@model_price_cache[model_name] = nil
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_pricing_hash(info)
|
|
63
|
+
input_price = info.input_price_per_million.to_f
|
|
64
|
+
output_price = info.output_price_per_million.to_f
|
|
65
|
+
text_tokens = info.pricing.text_tokens
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
input_price_per_million: input_price,
|
|
69
|
+
output_price_per_million: output_price,
|
|
70
|
+
thinking_price_per_million: resolve_thinking_price(text_tokens, output_price),
|
|
71
|
+
cached_input_price_per_million: resolve_cached_price(text_tokens),
|
|
72
|
+
cache_creation_price_per_million: input_price * CACHE_CREATION_INPUT_MULTIPLIER
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def resolve_cached_price(text_tokens)
|
|
77
|
+
return unless text_tokens.respond_to?(:cached_input) && text_tokens.cached_input
|
|
78
|
+
|
|
79
|
+
text_tokens.cached_input.to_f
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve_thinking_price(text_tokens, output_price)
|
|
83
|
+
standard = text_tokens.standard
|
|
84
|
+
if standard.respond_to?(:reasoning_output_per_million) && standard.reasoning_output_per_million
|
|
85
|
+
standard.reasoning_output_per_million.to_f
|
|
86
|
+
else
|
|
87
|
+
output_price
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_cost_entry(model_name, usage, pricing)
|
|
92
|
+
if pricing
|
|
93
|
+
build_priced_entry(model_name, usage, pricing)
|
|
94
|
+
else
|
|
95
|
+
build_unpriced_entry(model_name, usage)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_priced_entry(model_name, usage, pricing)
|
|
100
|
+
costs = compute_entry_costs(usage, pricing)
|
|
101
|
+
{ model: model_name, **usage, **pricing, **costs, total_cost: costs.values.sum }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def compute_entry_costs(usage, pricing)
|
|
105
|
+
{
|
|
106
|
+
input_cost: token_cost(usage[:input_tokens], pricing[:input_price_per_million]),
|
|
107
|
+
output_cost: token_cost(usage[:output_tokens], pricing[:output_price_per_million]),
|
|
108
|
+
thinking_cost: token_cost(usage[:thinking_tokens], pricing[:thinking_price_per_million]),
|
|
109
|
+
cached_cost: cached_token_cost(usage[:cached_tokens], pricing[:cached_input_price_per_million]),
|
|
110
|
+
cache_creation_cost: token_cost(usage[:cache_creation_tokens], pricing[:cache_creation_price_per_million])
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def cached_token_cost(tokens, price)
|
|
115
|
+
price ? token_cost(tokens, price) : 0.0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_unpriced_entry(model_name, usage)
|
|
119
|
+
{ model: model_name, **usage, **UNPRICED_DEFAULTS }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def token_cost(count, price_per_million)
|
|
123
|
+
(count.to_f / 1_000_000) * price_per_million
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class State
|
|
6
|
+
# Manages the tool confirmation flow as inline chat messages.
|
|
7
|
+
# When a destructive tool is requested, a :tool_pending message
|
|
8
|
+
# is added to the conversation; the user approves or rejects
|
|
9
|
+
# via keyboard, and the message is updated in place.
|
|
10
|
+
#
|
|
11
|
+
# The user can press [a] to approve all future tool calls for
|
|
12
|
+
# the current session, bypassing individual confirmations.
|
|
13
|
+
module ToolConfirmation
|
|
14
|
+
attr_reader :tool_cv
|
|
15
|
+
|
|
16
|
+
def init_tool_confirmation
|
|
17
|
+
@pending_tool_name = nil
|
|
18
|
+
@pending_tool_args = nil
|
|
19
|
+
@tool_confirmation_response = nil
|
|
20
|
+
@auto_approve_tools = false
|
|
21
|
+
@tool_cv = ConditionVariable.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def awaiting_tool_confirmation?
|
|
25
|
+
@mode == :tool_confirmation
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def auto_approve_tools?
|
|
29
|
+
@auto_approve_tools
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def enable_auto_approve!
|
|
33
|
+
@auto_approve_tools = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def disable_auto_approve!
|
|
37
|
+
@auto_approve_tools = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pending_tool_name
|
|
41
|
+
@pending_tool_name
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pending_tool_args
|
|
45
|
+
@pending_tool_args
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def tool_confirmation_response
|
|
49
|
+
@mutex.synchronize { @tool_confirmation_response }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def tool_confirmation_response=(value)
|
|
53
|
+
@mutex.synchronize do
|
|
54
|
+
@tool_confirmation_response = value
|
|
55
|
+
@tool_cv.signal
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def request_tool_confirmation!(tool_name, tool_args, risk_label: "WRITE")
|
|
60
|
+
pending_text = format_tool_pending_text(tool_name, tool_args, risk_label)
|
|
61
|
+
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
set_pending_tool(tool_name, tool_args)
|
|
64
|
+
append_tool_pending_message(pending_text)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
scroll_to_bottom
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def resolve_tool_confirmation!(decision)
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
finalize_pending_message(decision)
|
|
73
|
+
reset_pending_tool
|
|
74
|
+
@message_generation += 1
|
|
75
|
+
@dirty = true
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def clear_tool_confirmation!
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
reset_pending_tool
|
|
82
|
+
@dirty = true
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def format_tool_pending_text(tool_name, tool_args, risk_label)
|
|
89
|
+
args_text = tool_args.map { |k, v| "#{k}: #{v}" }.join(", ")
|
|
90
|
+
"[#{risk_label}] #{tool_name}(#{args_text}) — [y] approve / [n] reject / [a] approve all"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def finalize_pending_message(decision)
|
|
94
|
+
last_pending = @messages.reverse.find { |m| m[:role] == :tool_pending }
|
|
95
|
+
return unless last_pending
|
|
96
|
+
|
|
97
|
+
label = decision == :approved ? "approved" : "rejected"
|
|
98
|
+
last_pending[:role] = :tool_call
|
|
99
|
+
last_pending[:content] = last_pending[:content].sub(/ — \[y\].*/, " — #{label}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def reset_pending_tool
|
|
103
|
+
@pending_tool_name = nil
|
|
104
|
+
@pending_tool_args = nil
|
|
105
|
+
@tool_confirmation_response = nil
|
|
106
|
+
@mode = :chat
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def set_pending_tool(tool_name, tool_args)
|
|
110
|
+
@pending_tool_name = tool_name
|
|
111
|
+
@pending_tool_args = tool_args
|
|
112
|
+
@tool_confirmation_response = nil
|
|
113
|
+
@mode = :tool_confirmation
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def append_tool_pending_message(pending_text)
|
|
117
|
+
@messages << {
|
|
118
|
+
role: :tool_pending,
|
|
119
|
+
content: String.new(pending_text),
|
|
120
|
+
timestamp: Time.now,
|
|
121
|
+
**Messages::ZERO_TOKEN_USAGE
|
|
122
|
+
}
|
|
123
|
+
@message_generation += 1
|
|
124
|
+
@dirty = true
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|