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,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "state/model_selection"
|
|
4
|
+
require_relative "state/messages"
|
|
5
|
+
require_relative "state/message_assistant"
|
|
6
|
+
require_relative "state/message_token_tracking"
|
|
7
|
+
require_relative "state/scrollable"
|
|
8
|
+
require_relative "state/tool_confirmation"
|
|
9
|
+
require_relative "state/plan_tracking"
|
|
10
|
+
require_relative "state/token_cost"
|
|
11
|
+
|
|
12
|
+
module RubyCoded
|
|
13
|
+
module Chat
|
|
14
|
+
# This class is used to manage the state of the chat
|
|
15
|
+
class State
|
|
16
|
+
include ModelSelection
|
|
17
|
+
include Messages
|
|
18
|
+
include MessageAssistant
|
|
19
|
+
include MessageTokenTracking
|
|
20
|
+
include Scrollable
|
|
21
|
+
include ToolConfirmation
|
|
22
|
+
include PlanTracking
|
|
23
|
+
include TokenCost
|
|
24
|
+
|
|
25
|
+
attr_reader :input_buffer, :cursor_position, :input_scroll_offset, :messages, :scroll_offset,
|
|
26
|
+
:mode, :model_list, :model_select_index, :model_select_filter,
|
|
27
|
+
:streaming, :mutex
|
|
28
|
+
attr_accessor :model, :should_quit
|
|
29
|
+
|
|
30
|
+
MIN_RENDER_INTERVAL = 0.05
|
|
31
|
+
|
|
32
|
+
def initialize(model:)
|
|
33
|
+
@model = model
|
|
34
|
+
# String.new: literals like "" are frozen under frozen_string_literal
|
|
35
|
+
@input_buffer = String.new
|
|
36
|
+
@cursor_position = 0
|
|
37
|
+
@input_scroll_offset = 0
|
|
38
|
+
@messages = []
|
|
39
|
+
@streaming = false
|
|
40
|
+
@should_quit = false
|
|
41
|
+
@agentic_mode = false
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
@dirty = true
|
|
44
|
+
@last_render_at = 0.0
|
|
45
|
+
@scroll_offset = 0
|
|
46
|
+
@total_lines = 0
|
|
47
|
+
@visible_height = 0
|
|
48
|
+
@mode = :chat
|
|
49
|
+
@model_list = []
|
|
50
|
+
@model_select_index = 0
|
|
51
|
+
@model_select_filter = String.new
|
|
52
|
+
@model_select_show_all = false
|
|
53
|
+
init_messages
|
|
54
|
+
init_tool_confirmation
|
|
55
|
+
init_plan_tracking
|
|
56
|
+
init_token_cost
|
|
57
|
+
init_plugin_state
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def streaming=(value)
|
|
61
|
+
@streaming = value
|
|
62
|
+
mark_dirty!
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def streaming?
|
|
66
|
+
@streaming
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def agentic_mode?
|
|
70
|
+
@agentic_mode
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def agentic_mode=(value)
|
|
74
|
+
@agentic_mode = value
|
|
75
|
+
mark_dirty!
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def should_quit?
|
|
79
|
+
@should_quit
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def dirty?
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
return false unless @dirty
|
|
85
|
+
return true unless @streaming
|
|
86
|
+
|
|
87
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
88
|
+
(now - @last_render_at) >= MIN_RENDER_INTERVAL
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def mark_clean!
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
@dirty = false
|
|
95
|
+
@last_render_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def mark_dirty!
|
|
100
|
+
@mutex.synchronize { @dirty = true }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Updates the horizontal scroll offset of the input area so the
|
|
104
|
+
# cursor is always visible. Call this after every cursor / buffer
|
|
105
|
+
# change. +visible_width+ is set by the renderer each frame via
|
|
106
|
+
# +update_input_visible_width+.
|
|
107
|
+
def update_input_scroll_offset
|
|
108
|
+
visible = @input_visible_width || 0
|
|
109
|
+
return if visible <= 0
|
|
110
|
+
|
|
111
|
+
if @cursor_position < @input_scroll_offset
|
|
112
|
+
@input_scroll_offset = @cursor_position
|
|
113
|
+
elsif @cursor_position >= @input_scroll_offset + visible
|
|
114
|
+
@input_scroll_offset = @cursor_position - visible + 1
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Called by the renderer so the state knows how many characters
|
|
119
|
+
# fit on screen (inner width minus the prompt prefix).
|
|
120
|
+
def update_input_visible_width(width)
|
|
121
|
+
@input_visible_width = width
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def append_to_input(text)
|
|
125
|
+
@input_buffer.insert(@cursor_position, text)
|
|
126
|
+
@cursor_position += text.length
|
|
127
|
+
update_input_scroll_offset
|
|
128
|
+
mark_dirty!
|
|
129
|
+
reset_command_completion_index if respond_to?(:reset_command_completion_index, true)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def delete_last_char
|
|
133
|
+
return if @cursor_position <= 0
|
|
134
|
+
|
|
135
|
+
@input_buffer.slice!(@cursor_position - 1)
|
|
136
|
+
@cursor_position -= 1
|
|
137
|
+
update_input_scroll_offset
|
|
138
|
+
mark_dirty!
|
|
139
|
+
reset_command_completion_index if respond_to?(:reset_command_completion_index, true)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def move_cursor_left
|
|
143
|
+
return if @cursor_position <= 0
|
|
144
|
+
|
|
145
|
+
@cursor_position -= 1
|
|
146
|
+
update_input_scroll_offset
|
|
147
|
+
mark_dirty!
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def move_cursor_right
|
|
151
|
+
return if @cursor_position >= @input_buffer.length
|
|
152
|
+
|
|
153
|
+
@cursor_position += 1
|
|
154
|
+
update_input_scroll_offset
|
|
155
|
+
mark_dirty!
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def move_cursor_to_start
|
|
159
|
+
return if @cursor_position == 0
|
|
160
|
+
|
|
161
|
+
@cursor_position = 0
|
|
162
|
+
update_input_scroll_offset
|
|
163
|
+
mark_dirty!
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def move_cursor_to_end
|
|
167
|
+
return if @cursor_position == @input_buffer.length
|
|
168
|
+
|
|
169
|
+
@cursor_position = @input_buffer.length
|
|
170
|
+
update_input_scroll_offset
|
|
171
|
+
mark_dirty!
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def clear_input!
|
|
175
|
+
@input_buffer.clear
|
|
176
|
+
@cursor_position = 0
|
|
177
|
+
@input_scroll_offset = 0
|
|
178
|
+
mark_dirty!
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def consume_input!
|
|
182
|
+
input = @input_buffer.dup
|
|
183
|
+
@input_buffer.clear
|
|
184
|
+
@cursor_position = 0
|
|
185
|
+
@input_scroll_offset = 0
|
|
186
|
+
input
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
# Calls plugin state initializers (e.g. init_command_completion).
|
|
192
|
+
def init_plugin_state
|
|
193
|
+
return unless RubyCoded.respond_to?(:plugin_registry)
|
|
194
|
+
|
|
195
|
+
RubyCoded.plugin_registry.plugins.each do |plugin|
|
|
196
|
+
ext = plugin.state_extension
|
|
197
|
+
next unless ext
|
|
198
|
+
|
|
199
|
+
init_method = :"init_#{plugin.plugin_name}"
|
|
200
|
+
send(init_method) if respond_to?(init_method, true)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RubyCoded
|
|
7
|
+
# This class is used to manage the users configurations for this gem
|
|
8
|
+
class UserConfig
|
|
9
|
+
CONFIG_DIR = File.join(Dir.home, ".ruby_coded").freeze
|
|
10
|
+
CONFIG_PATH = File.join(CONFIG_DIR, "config.yaml").freeze
|
|
11
|
+
|
|
12
|
+
def initialize(config_path: CONFIG_PATH)
|
|
13
|
+
@config_path = config_path
|
|
14
|
+
@config_dir = File.dirname(@config_path)
|
|
15
|
+
@user_config = find_or_create_config_file
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def full_config
|
|
19
|
+
@user_config
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def get_config(key)
|
|
23
|
+
full_config["user_config"][key]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def set_config(key, value)
|
|
27
|
+
full_config["user_config"][key] = value
|
|
28
|
+
File.write(@config_path, full_config.to_yaml)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def save
|
|
32
|
+
File.write(@config_path, full_config.to_yaml)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def directory_trusted?(dir = Dir.pwd)
|
|
36
|
+
trusted = get_config("trusted_directories") || []
|
|
37
|
+
trusted.include?(resolve_path(dir))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def trust_directory!(dir = Dir.pwd)
|
|
41
|
+
trusted = get_config("trusted_directories") || []
|
|
42
|
+
resolved = resolve_path(dir)
|
|
43
|
+
return if trusted.include?(resolved)
|
|
44
|
+
|
|
45
|
+
trusted << resolved
|
|
46
|
+
set_config("trusted_directories", trusted)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def find_or_create_config_file
|
|
52
|
+
migrate_legacy_config
|
|
53
|
+
FileUtils.mkdir_p(@config_dir)
|
|
54
|
+
|
|
55
|
+
config = File.exist?(@config_path) ? YAML.load_file(@config_path) : nil
|
|
56
|
+
|
|
57
|
+
if config.is_a?(Hash) && config["user_config"]
|
|
58
|
+
config
|
|
59
|
+
else
|
|
60
|
+
default = user_config_info
|
|
61
|
+
File.write(@config_path, default.to_yaml)
|
|
62
|
+
default
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def migrate_legacy_config
|
|
67
|
+
legacy_path = File.join(Dir.pwd, ".config.yaml")
|
|
68
|
+
return unless File.exist?(legacy_path) && !File.exist?(@config_path)
|
|
69
|
+
|
|
70
|
+
legacy_config = load_legacy_config(legacy_path)
|
|
71
|
+
write_migrated_config(legacy_config, legacy_path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def load_legacy_config(legacy_path)
|
|
75
|
+
FileUtils.mkdir_p(@config_dir)
|
|
76
|
+
config = YAML.load_file(legacy_path)
|
|
77
|
+
normalize_legacy_permissions(config)
|
|
78
|
+
config
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize_legacy_permissions(config)
|
|
82
|
+
return unless config.is_a?(Hash) && config["user_config"]
|
|
83
|
+
|
|
84
|
+
if config["user_config"]["current_directory_permission"]
|
|
85
|
+
config["user_config"]["trusted_directories"] = [resolve_path(Dir.pwd)]
|
|
86
|
+
end
|
|
87
|
+
config["user_config"].delete("current_directory_permission")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def write_migrated_config(config, legacy_path)
|
|
91
|
+
File.write(@config_path, config.to_yaml)
|
|
92
|
+
File.delete(legacy_path)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def resolve_path(path)
|
|
96
|
+
File.realpath(File.expand_path(path))
|
|
97
|
+
rescue Errno::ENOENT
|
|
98
|
+
File.expand_path(path)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def user_config_info
|
|
102
|
+
{
|
|
103
|
+
"user_config" => {
|
|
104
|
+
"trusted_directories" => [],
|
|
105
|
+
"model" => nil
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
class Initializer
|
|
5
|
+
# Cover class for printing the cover of the RubyCoded gem
|
|
6
|
+
module Cover
|
|
7
|
+
BANNER = <<~'COVER'
|
|
8
|
+
|
|
9
|
+
/\
|
|
10
|
+
/ \
|
|
11
|
+
/ \ ____ _ ____ _ _
|
|
12
|
+
/------\ | _ \ _ _| |__ _ _ / ___|___ __| | ___ __| |
|
|
13
|
+
/ \ / \ | |_) | | | | '_ \| | | | | | / _ \ / _` |/ _ \/ _` |
|
|
14
|
+
/ \/ \ | _ <| |_| | |_) | |_| | | |__| (_) | (_| | __/ (_| |
|
|
15
|
+
\ /\ / |_| \_\\__,_|_.__/ \__, | \____\___/ \__,_|\___|\__,_|
|
|
16
|
+
\ / \ / |___/
|
|
17
|
+
\/ \/
|
|
18
|
+
\ / v%<version>s
|
|
19
|
+
\ /
|
|
20
|
+
\/
|
|
21
|
+
|
|
22
|
+
COVER
|
|
23
|
+
|
|
24
|
+
def self.print_cover_message
|
|
25
|
+
puts BANNER.sub("%<version>s", RubyCoded::VERSION)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require "tty-prompt"
|
|
5
|
+
|
|
6
|
+
require_relative "initializer/cover"
|
|
7
|
+
require_relative "config/user_config"
|
|
8
|
+
require_relative "auth/auth_manager"
|
|
9
|
+
require_relative "chat/app"
|
|
10
|
+
|
|
11
|
+
module RubyCoded
|
|
12
|
+
# Initializer class for the RubyCoded gem (think of it as a main class)
|
|
13
|
+
class Initializer
|
|
14
|
+
PROVIDER_DEFAULT_MODELS = {
|
|
15
|
+
openai: "gpt-5-codex",
|
|
16
|
+
anthropic: "claude-sonnet-4-6"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@user_cfg = UserConfig.new
|
|
21
|
+
@prompt = TTY::Prompt.new
|
|
22
|
+
@auth_manager = Auth::AuthManager.new
|
|
23
|
+
|
|
24
|
+
ask_for_directory_permission unless @user_cfg.directory_trusted?
|
|
25
|
+
@auth_manager.check_authentication
|
|
26
|
+
@auth_manager.configure_ruby_llm!
|
|
27
|
+
start_chat
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def ask_for_directory_permission
|
|
33
|
+
if @prompt.yes?("Do you trust this directory? (#{Dir.pwd})")
|
|
34
|
+
@user_cfg.trust_directory!
|
|
35
|
+
else
|
|
36
|
+
exit 0
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def start_chat
|
|
41
|
+
Chat::App.new(model: resolved_chat_model, user_config: @user_cfg).run
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def resolved_chat_model
|
|
45
|
+
stored = @user_cfg.get_config("model")
|
|
46
|
+
return stored.to_s if stored && !stored.to_s.strip.empty?
|
|
47
|
+
|
|
48
|
+
provider = @auth_manager.authenticated_provider_names.first
|
|
49
|
+
PROVIDER_DEFAULT_MODELS.fetch(provider, RubyLLM.config.default_model).to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Plugins
|
|
5
|
+
# Abstract base class for all plugins. Subclass this and override
|
|
6
|
+
# the class methods to declare what your plugin contributes.
|
|
7
|
+
class Base
|
|
8
|
+
class << self
|
|
9
|
+
# Unique identifier for the plugin (Symbol).
|
|
10
|
+
def plugin_name
|
|
11
|
+
raise NotImplementedError, "#{name} must implement .plugin_name"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Module to include in Chat::State (or nil).
|
|
15
|
+
def state_extension = nil
|
|
16
|
+
|
|
17
|
+
# Module to include in Chat::InputHandler (or nil).
|
|
18
|
+
def input_extension = nil
|
|
19
|
+
|
|
20
|
+
# Module to include in Chat::Renderer (or nil).
|
|
21
|
+
def renderer_extension = nil
|
|
22
|
+
|
|
23
|
+
# Module to include in Chat::CommandHandler (or nil).
|
|
24
|
+
def command_handler_extension = nil
|
|
25
|
+
|
|
26
|
+
# Symbol — method name defined by input_extension that processes
|
|
27
|
+
# keyboard events. Signature: method(event) -> action_symbol | nil
|
|
28
|
+
def input_handler_method = nil
|
|
29
|
+
|
|
30
|
+
# Symbol — method name defined by renderer_extension that draws
|
|
31
|
+
# the plugin overlay. Signature: method(frame, chat_area, input_area)
|
|
32
|
+
def render_method = nil
|
|
33
|
+
|
|
34
|
+
# Hash mapping command strings to method symbols,
|
|
35
|
+
# e.g. { "/deploy" => :cmd_deploy }
|
|
36
|
+
def commands = {}
|
|
37
|
+
|
|
38
|
+
# Hash mapping command strings to human-readable descriptions,
|
|
39
|
+
# e.g. { "/deploy" => "Deploy the application" }
|
|
40
|
+
def command_descriptions = {}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Plugins
|
|
5
|
+
module CommandCompletion
|
|
6
|
+
# Mixed into Chat::InputHandler to intercept Tab and arrow keys
|
|
7
|
+
# when command completion suggestions are visible.
|
|
8
|
+
module InputExtension
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def handle_command_completion_input(event)
|
|
12
|
+
return nil unless @state.command_completion_active?
|
|
13
|
+
return :plugin_handled if dispatch_completion(event)
|
|
14
|
+
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def dispatch_completion(event)
|
|
19
|
+
if event.tab?
|
|
20
|
+
@state.accept_command_completion!
|
|
21
|
+
elsif event.up?
|
|
22
|
+
@state.command_completion_up
|
|
23
|
+
elsif event.down?
|
|
24
|
+
@state.command_completion_down
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "state_extension"
|
|
4
|
+
require_relative "input_extension"
|
|
5
|
+
require_relative "renderer_extension"
|
|
6
|
+
|
|
7
|
+
module RubyCoded
|
|
8
|
+
module Plugins
|
|
9
|
+
module CommandCompletion
|
|
10
|
+
# Built-in plugin that shows a filtered list of slash-command
|
|
11
|
+
# suggestions as the user types, with Tab to accept.
|
|
12
|
+
class Plugin < Base
|
|
13
|
+
def self.plugin_name = :command_completion
|
|
14
|
+
|
|
15
|
+
def self.state_extension = StateExtension
|
|
16
|
+
|
|
17
|
+
def self.input_extension = InputExtension
|
|
18
|
+
|
|
19
|
+
def self.renderer_extension = RendererExtension
|
|
20
|
+
|
|
21
|
+
def self.input_handler_method = :handle_command_completion_input
|
|
22
|
+
|
|
23
|
+
def self.render_method = :render_command_completer
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Plugins
|
|
5
|
+
module CommandCompletion
|
|
6
|
+
# Mixed into Chat::Renderer to draw a compact suggestion popup
|
|
7
|
+
# directly above the input panel when completions are active.
|
|
8
|
+
module RendererExtension
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def render_command_completer(frame, chat_area, _input_area)
|
|
12
|
+
return unless @state.command_completion_active?
|
|
13
|
+
|
|
14
|
+
suggestions = @state.command_suggestions
|
|
15
|
+
return if suggestions.empty?
|
|
16
|
+
|
|
17
|
+
popup_area = completer_popup_area(chat_area, suggestions.size)
|
|
18
|
+
frame.render_widget(@tui.clear, popup_area)
|
|
19
|
+
|
|
20
|
+
items = suggestions.map { |cmd, desc| format_suggestion(cmd, desc) }
|
|
21
|
+
widget = completer_list_widget(items)
|
|
22
|
+
frame.render_widget(widget, popup_area)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def completer_popup_area(chat_area, count)
|
|
26
|
+
height = [count + 2, 10].min
|
|
27
|
+
popup_y = [chat_area.y + chat_area.height - height, chat_area.y].max
|
|
28
|
+
popup_width = [chat_area.width / 2, 40].max
|
|
29
|
+
|
|
30
|
+
@tui.rect(
|
|
31
|
+
x: chat_area.x,
|
|
32
|
+
y: popup_y,
|
|
33
|
+
width: [popup_width, chat_area.width].min,
|
|
34
|
+
height: height
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def completer_list_widget(items)
|
|
39
|
+
@tui.list(
|
|
40
|
+
items: items,
|
|
41
|
+
selected_index: @state.command_completion_index,
|
|
42
|
+
highlight_style: @tui.style(bg: :blue, fg: :white, modifiers: [:bold]),
|
|
43
|
+
highlight_symbol: "> ",
|
|
44
|
+
block: @tui.block(borders: [:all])
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def format_suggestion(cmd, desc)
|
|
49
|
+
"#{cmd.ljust(16)} #{desc}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Plugins
|
|
5
|
+
module CommandCompletion
|
|
6
|
+
# Mixed into Chat::State to add command-completion tracking.
|
|
7
|
+
module StateExtension
|
|
8
|
+
COMMAND_INFO = {
|
|
9
|
+
"/help" => "Show help message",
|
|
10
|
+
"/model" => "Select or switch model",
|
|
11
|
+
"/clear" => "Clear conversation history",
|
|
12
|
+
"/history" => "Show conversation summary",
|
|
13
|
+
"/tokens" => "Show detailed token usage and cost",
|
|
14
|
+
"/agent" => "Toggle agent mode (on/off)",
|
|
15
|
+
"/plan" => "Toggle plan mode (on/off/save)",
|
|
16
|
+
"/exit" => "Exit the chat",
|
|
17
|
+
"/quit" => "Exit the chat"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.included(base)
|
|
21
|
+
base.attr_reader :command_completion_index
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def init_command_completion
|
|
25
|
+
@command_completion_index = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def command_completion_active?
|
|
29
|
+
buf = @input_buffer
|
|
30
|
+
buf.start_with?("/") && !buf.include?(" ") && !command_suggestions.empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns an array of [command, description] pairs matching the
|
|
34
|
+
# current input buffer prefix.
|
|
35
|
+
def command_suggestions
|
|
36
|
+
prefix = @input_buffer.downcase
|
|
37
|
+
all_descriptions = merged_command_descriptions
|
|
38
|
+
all_descriptions.select { |cmd, _| cmd.start_with?(prefix) }
|
|
39
|
+
.sort_by { |cmd, _| cmd }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def current_command_suggestion
|
|
43
|
+
suggestions = command_suggestions
|
|
44
|
+
return nil if suggestions.empty?
|
|
45
|
+
|
|
46
|
+
idx = @command_completion_index % suggestions.size
|
|
47
|
+
suggestions[idx]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def command_completion_up
|
|
51
|
+
suggestions = command_suggestions
|
|
52
|
+
return if suggestions.empty?
|
|
53
|
+
|
|
54
|
+
@command_completion_index = (@command_completion_index - 1) % suggestions.size
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def command_completion_down
|
|
58
|
+
suggestions = command_suggestions
|
|
59
|
+
return if suggestions.empty?
|
|
60
|
+
|
|
61
|
+
@command_completion_index = (@command_completion_index + 1) % suggestions.size
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def accept_command_completion!
|
|
65
|
+
suggestion = current_command_suggestion
|
|
66
|
+
return unless suggestion
|
|
67
|
+
|
|
68
|
+
cmd, = suggestion
|
|
69
|
+
@input_buffer.clear
|
|
70
|
+
@input_buffer << cmd
|
|
71
|
+
@cursor_position = @input_buffer.length
|
|
72
|
+
@command_completion_index = 0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Reset index when the buffer changes so selection stays coherent.
|
|
76
|
+
def reset_command_completion_index
|
|
77
|
+
@command_completion_index = 0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def merged_command_descriptions
|
|
83
|
+
base = COMMAND_INFO.dup
|
|
84
|
+
base.merge!(RubyCoded.plugin_registry.all_command_descriptions) if RubyCoded.respond_to?(:plugin_registry)
|
|
85
|
+
base
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|