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,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