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,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "webrick"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
require_relative "callback_servlet"
|
|
7
|
+
|
|
8
|
+
module RubyCoded
|
|
9
|
+
module Auth
|
|
10
|
+
# This class is used to start a local webrick server for the
|
|
11
|
+
# OAuth authentication callback
|
|
12
|
+
class OAuthCallbackServer
|
|
13
|
+
PORT = 1_455
|
|
14
|
+
TIMEOUT = 120
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@result_queue = Queue.new
|
|
18
|
+
@server = WEBrick::HTTPServer.new(Port: PORT, Logger: WEBrick::Log.new(File::NULL), AccessLog: [])
|
|
19
|
+
@server.mount "/auth/callback", CallbackServlet, @result_queue
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def start
|
|
23
|
+
@thread = Thread.new { @server.start }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def wait_for_callback
|
|
27
|
+
Timeout.timeout(TIMEOUT) { @result_queue.pop }
|
|
28
|
+
ensure
|
|
29
|
+
shutdown
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def shutdown
|
|
33
|
+
@server.shutdown
|
|
34
|
+
@thread&.join(5)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module RubyCoded
|
|
8
|
+
module Auth
|
|
9
|
+
# Generates a Proof Key for Code Exchange
|
|
10
|
+
# This will be used to authenticate the user with some AI providers
|
|
11
|
+
module PKCE
|
|
12
|
+
def self.generate
|
|
13
|
+
verifier = SecureRandom.urlsafe_base64(32)
|
|
14
|
+
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
15
|
+
{ verifier: verifier, challenge: challenge }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Auth
|
|
5
|
+
module Providers
|
|
6
|
+
# Anthropic provider's configuration
|
|
7
|
+
module Anthropic
|
|
8
|
+
def self.display_name
|
|
9
|
+
"Anthropic"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.auth_methods
|
|
13
|
+
[
|
|
14
|
+
{ key: :api_key, label: "With an Anthropic API key (requires API credits at console.anthropic.com)" }
|
|
15
|
+
]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.console_url
|
|
19
|
+
"https://console.anthropic.com/settings/keys"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.key_pattern
|
|
23
|
+
/\Ask-ant-/
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.ruby_llm_key
|
|
27
|
+
:anthropic_api_key
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Auth
|
|
5
|
+
module Providers
|
|
6
|
+
# OpenAI provider's configuration
|
|
7
|
+
module OpenAI
|
|
8
|
+
def self.display_name
|
|
9
|
+
"OpenAI"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.client_id
|
|
13
|
+
"app_EMoamEEZ73f0CkXaXp7hrann"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.auth_methods
|
|
17
|
+
[
|
|
18
|
+
{ key: :oauth,
|
|
19
|
+
label: "With your OpenAI account (requires API credits, " \
|
|
20
|
+
"your ChatGPT subscription does not cover API usage)" },
|
|
21
|
+
{ key: :api_key, label: "With an OpenAI API key (requires API credits at platform.openai.com)" }
|
|
22
|
+
]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.auth_url
|
|
26
|
+
"https://auth.openai.com/oauth/authorize"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.token_url
|
|
30
|
+
"https://auth.openai.com/oauth/token"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.console_url
|
|
34
|
+
"https://platform.openai.com/account/api-keys"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.key_pattern
|
|
38
|
+
/\Ask-/
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.redirect_uri
|
|
42
|
+
"http://localhost:1455/auth/callback"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.scopes
|
|
46
|
+
"offline_access"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.ruby_llm_key
|
|
50
|
+
:openai_api_key
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class App
|
|
6
|
+
# Routes TUI events to the appropriate handler methods.
|
|
7
|
+
module EventDispatch
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def dispatch_event(event)
|
|
11
|
+
action = @input_handler.process(event)
|
|
12
|
+
case action
|
|
13
|
+
when :quit then :quit
|
|
14
|
+
when :submit then handle_submit
|
|
15
|
+
when :model_selected, :model_select_cancel then dispatch_model_action(action)
|
|
16
|
+
when :cancel_streaming, :tool_approved, :tool_approved_all, :tool_rejected then dispatch_llm_action(action)
|
|
17
|
+
when :plan_clarification_selected, :plan_clarification_custom, :plan_clarification_skip
|
|
18
|
+
dispatch_plan_clarification(action)
|
|
19
|
+
when :scroll_up, :scroll_down, :scroll_top, :scroll_bottom then handle_scroll(action)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def handle_submit
|
|
24
|
+
input = @state.consume_input!
|
|
25
|
+
if input.start_with?("/")
|
|
26
|
+
@command_handler.handle(input)
|
|
27
|
+
:quit if @state.should_quit?
|
|
28
|
+
else
|
|
29
|
+
@state.add_message(:user, input)
|
|
30
|
+
@llm_bridge.send_async(input)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def dispatch_model_action(action)
|
|
35
|
+
case action
|
|
36
|
+
when :model_selected then apply_selected_model
|
|
37
|
+
when :model_select_cancel then @state.exit_model_select!
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def dispatch_llm_action(action)
|
|
42
|
+
case action
|
|
43
|
+
when :cancel_streaming then @llm_bridge.cancel!
|
|
44
|
+
when :tool_approved then @llm_bridge.approve_tool!
|
|
45
|
+
when :tool_approved_all then @llm_bridge.approve_all_tools!
|
|
46
|
+
when :tool_rejected then @llm_bridge.reject_tool!
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def dispatch_plan_clarification(action)
|
|
51
|
+
case action
|
|
52
|
+
when :plan_clarification_selected
|
|
53
|
+
handle_plan_clarification_response(@state.selected_clarification_option)
|
|
54
|
+
when :plan_clarification_custom
|
|
55
|
+
handle_plan_clarification_response(@state.clarification_custom_input.dup)
|
|
56
|
+
when :plan_clarification_skip
|
|
57
|
+
@state.exit_plan_clarification!
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def handle_plan_clarification_response(response)
|
|
62
|
+
@state.exit_plan_clarification!
|
|
63
|
+
@state.add_message(:user, response)
|
|
64
|
+
@llm_bridge.send_async(response)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def handle_scroll(action)
|
|
68
|
+
case action
|
|
69
|
+
when :scroll_up then @state.scroll_up
|
|
70
|
+
when :scroll_down then @state.scroll_down
|
|
71
|
+
when :scroll_top then @state.scroll_to_top
|
|
72
|
+
when :scroll_bottom then @state.scroll_to_bottom
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ratatui_ruby"
|
|
4
|
+
|
|
5
|
+
require_relative "state"
|
|
6
|
+
require_relative "input_handler"
|
|
7
|
+
require_relative "renderer"
|
|
8
|
+
require_relative "command_handler"
|
|
9
|
+
require_relative "llm_bridge"
|
|
10
|
+
require_relative "../auth/credentials_store"
|
|
11
|
+
require_relative "app/event_dispatch"
|
|
12
|
+
|
|
13
|
+
module RubyCoded
|
|
14
|
+
module Chat
|
|
15
|
+
# Main class for the AI chat interface
|
|
16
|
+
class App
|
|
17
|
+
include EventDispatch
|
|
18
|
+
|
|
19
|
+
def initialize(model:, user_config: nil)
|
|
20
|
+
@model = model
|
|
21
|
+
@user_config = user_config
|
|
22
|
+
apply_plugin_extensions!
|
|
23
|
+
@state = State.new(model: model)
|
|
24
|
+
@llm_bridge = LLMBridge.new(@state)
|
|
25
|
+
@input_handler = InputHandler.new(@state)
|
|
26
|
+
@credentials_store = Auth::CredentialsStore.new
|
|
27
|
+
@command_handler = build_command_handler
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
IDLE_POLL_TIMEOUT = 0.016
|
|
31
|
+
STREAMING_POLL_TIMEOUT = 0.05
|
|
32
|
+
|
|
33
|
+
def run
|
|
34
|
+
RatatuiRuby.run do |tui|
|
|
35
|
+
init_tui(tui)
|
|
36
|
+
run_event_loop
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def init_tui(tui)
|
|
43
|
+
@tui = tui
|
|
44
|
+
@renderer = Renderer.new(tui, @state)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def run_event_loop
|
|
48
|
+
loop do
|
|
49
|
+
refresh_screen
|
|
50
|
+
event = @tui.poll_event(timeout: poll_timeout)
|
|
51
|
+
next if event.none?
|
|
52
|
+
|
|
53
|
+
break if dispatch_event(event) == :quit
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def refresh_screen
|
|
58
|
+
return unless @state.dirty?
|
|
59
|
+
|
|
60
|
+
@renderer.draw
|
|
61
|
+
@state.mark_clean!
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def poll_timeout
|
|
65
|
+
@state.streaming? ? STREAMING_POLL_TIMEOUT : IDLE_POLL_TIMEOUT
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def apply_plugin_extensions!
|
|
69
|
+
RubyCoded.plugin_registry.apply_extensions!(
|
|
70
|
+
state_class: State,
|
|
71
|
+
input_handler_class: InputHandler,
|
|
72
|
+
renderer_class: Renderer,
|
|
73
|
+
command_handler_class: CommandHandler
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def build_command_handler
|
|
78
|
+
CommandHandler.new(
|
|
79
|
+
@state, llm_bridge: @llm_bridge, user_config: @user_config, credentials_store: @credentials_store
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def apply_selected_model
|
|
84
|
+
selected = @state.selected_model
|
|
85
|
+
return @state.exit_model_select! unless selected
|
|
86
|
+
|
|
87
|
+
switch_model(selected)
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
@state.exit_model_select!
|
|
90
|
+
@state.add_message(:system, "Failed to switch model: #{e.message}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def switch_model(selected)
|
|
94
|
+
model_name = selected.respond_to?(:id) ? selected.id : selected.to_s
|
|
95
|
+
@state.model = model_name
|
|
96
|
+
@llm_bridge.reset_chat!(model_name)
|
|
97
|
+
@user_config&.set_config("model", model_name)
|
|
98
|
+
@state.exit_model_select!
|
|
99
|
+
@state.add_message(:system, "Model switched to #{model_name}.")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class CommandHandler
|
|
6
|
+
# Slash commands for toggling agentic mode on/off.
|
|
7
|
+
module AgentCommands
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def cmd_agent(rest)
|
|
11
|
+
case rest&.strip&.downcase
|
|
12
|
+
when "on"
|
|
13
|
+
enable_agent_mode
|
|
14
|
+
when "off"
|
|
15
|
+
disable_agent_mode
|
|
16
|
+
when nil, ""
|
|
17
|
+
show_agent_status
|
|
18
|
+
else
|
|
19
|
+
@state.add_message(:system, "Usage: /agent [on|off]")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def enable_agent_mode
|
|
24
|
+
if @llm_bridge.agentic_mode
|
|
25
|
+
@llm_bridge.reset_agent_session!
|
|
26
|
+
@state.add_message(:system,
|
|
27
|
+
"Agent session reset. Tool call counters cleared — ready for a new task.")
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@llm_bridge.toggle_agentic_mode!(true)
|
|
32
|
+
@state.add_message(:system,
|
|
33
|
+
"Agent mode enabled. The model can now use tools to interact with your project files.")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def disable_agent_mode
|
|
37
|
+
unless @llm_bridge.agentic_mode
|
|
38
|
+
@state.add_message(:system, "Agent mode is already disabled.")
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@llm_bridge.toggle_agentic_mode!(false)
|
|
43
|
+
@state.add_message(:system, "Agent mode disabled. Switched back to chat-only mode.")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def show_agent_status
|
|
47
|
+
status = @llm_bridge.agentic_mode ? "enabled" : "disabled"
|
|
48
|
+
@state.add_message(:system, "Agent mode: #{status}. Use /agent on or /agent off to toggle.")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class CommandHandler
|
|
6
|
+
# Slash commands for viewing conversation history.
|
|
7
|
+
module HistoryCommands
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def cmd_history(_rest)
|
|
11
|
+
conv = conversation_messages
|
|
12
|
+
if conv.empty?
|
|
13
|
+
@state.add_message(:system, "No conversation history yet.")
|
|
14
|
+
return
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@state.add_message(:system, format_history(conv))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def conversation_messages
|
|
21
|
+
@state.messages_snapshot.reject { |m| m[:role] == :system }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def format_history(conv)
|
|
25
|
+
lines = conv.map.with_index(1) { |msg, i| format_history_line(msg, i) }
|
|
26
|
+
"Conversation history (#{conv.size} messages):\n#{lines.join("\n")}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def format_history_line(msg, index)
|
|
30
|
+
role = msg[:role].to_s.capitalize
|
|
31
|
+
preview = msg[:content].to_s.lines.first&.strip || ""
|
|
32
|
+
preview = "#{preview[0..60]}..." if preview.length > 60
|
|
33
|
+
" #{index}. [#{role}] #{preview}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../model_filter"
|
|
4
|
+
|
|
5
|
+
module RubyCoded
|
|
6
|
+
module Chat
|
|
7
|
+
class CommandHandler
|
|
8
|
+
# This module contains the logic for the CLI commands' management
|
|
9
|
+
module ModelCommands
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def cmd_model(rest)
|
|
13
|
+
return open_model_selector if rest.nil? || rest.strip.empty?
|
|
14
|
+
|
|
15
|
+
name = rest.strip
|
|
16
|
+
return open_model_selector(show_all: true) if name == "--all"
|
|
17
|
+
return unless model_match?(name)
|
|
18
|
+
|
|
19
|
+
switch_to_model(name)
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
@state.add_message(:system, "Failed to switch model: #{e.message}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def model_match?(name)
|
|
25
|
+
models = fetch_chat_models
|
|
26
|
+
return true unless models.any?
|
|
27
|
+
return true if models.find { |m| model_id(m) == name }
|
|
28
|
+
|
|
29
|
+
suggest_models(name, models)
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def suggest_models(name, models)
|
|
34
|
+
suggestions = models.select { |m| model_id(m).include?(name) }.map { |m| model_id(m) }.first(5)
|
|
35
|
+
msg = "Model '#{name}' not found."
|
|
36
|
+
msg += " Did you mean: #{suggestions.join(", ")}?" if suggestions.any?
|
|
37
|
+
@state.add_message(:system, msg)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def switch_to_model(name)
|
|
41
|
+
@state.model = name
|
|
42
|
+
@llm_bridge.reset_chat!(name)
|
|
43
|
+
@user_config&.set_config("model", name)
|
|
44
|
+
@state.add_message(:system, "Model switched to #{name}.")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def open_model_selector(show_all: false)
|
|
48
|
+
models = fetch_models_for_authenticated_providers
|
|
49
|
+
models = ModelFilter.filter(models) unless show_all
|
|
50
|
+
|
|
51
|
+
if models.empty?
|
|
52
|
+
@state.add_message(:system,
|
|
53
|
+
"Current model: #{@state.model}. " \
|
|
54
|
+
"No available models found for your authenticated providers.")
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@state.enter_model_select!(models, show_all: show_all)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fetch_models_for_authenticated_providers
|
|
62
|
+
return fetch_chat_models unless @credentials_store
|
|
63
|
+
|
|
64
|
+
models = []
|
|
65
|
+
|
|
66
|
+
Auth::AuthManager::PROVIDERS.each_key do |name|
|
|
67
|
+
next unless @credentials_store.retrieve(name)
|
|
68
|
+
|
|
69
|
+
provider_models = RubyLLM.models.by_provider(name).chat_models.to_a
|
|
70
|
+
models.concat(provider_models)
|
|
71
|
+
end
|
|
72
|
+
models
|
|
73
|
+
rescue StandardError
|
|
74
|
+
fetch_chat_models
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fetch_chat_models
|
|
78
|
+
RubyLLM.models.chat_models.to_a
|
|
79
|
+
rescue StandardError
|
|
80
|
+
[]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def model_id(model)
|
|
84
|
+
return model.id if model.respond_to?(:id)
|
|
85
|
+
|
|
86
|
+
model.to_s
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Chat
|
|
5
|
+
class CommandHandler
|
|
6
|
+
# Slash commands for toggling plan mode and saving plans.
|
|
7
|
+
module PlanCommands
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def cmd_plan(rest)
|
|
11
|
+
case rest&.strip&.downcase
|
|
12
|
+
when "on" then enable_plan_mode
|
|
13
|
+
when "off" then disable_plan_mode(force: false)
|
|
14
|
+
when "off --force" then disable_plan_mode(force: true)
|
|
15
|
+
when nil, "" then show_plan_status
|
|
16
|
+
else handle_plan_subcommand(rest.strip)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def handle_plan_subcommand(rest)
|
|
21
|
+
if rest.downcase.start_with?("save")
|
|
22
|
+
filename = rest.sub(/\Asave\s*/i, "").strip
|
|
23
|
+
save_plan(filename.empty? ? nil : filename)
|
|
24
|
+
else
|
|
25
|
+
@state.add_message(:system, "Usage: /plan [on|off|save [filename]]")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def enable_plan_mode
|
|
30
|
+
if @state.plan_mode_active?
|
|
31
|
+
@state.add_message(:system, "Plan mode is already enabled.")
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
deactivate_agent_if_needed
|
|
36
|
+
@llm_bridge.toggle_plan_mode!(true)
|
|
37
|
+
@state.activate_plan_mode!
|
|
38
|
+
@state.add_message(:system,
|
|
39
|
+
"Plan mode enabled. Describe what you want to build and the model " \
|
|
40
|
+
"will help you create a structured plan.")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def deactivate_agent_if_needed
|
|
44
|
+
return unless @llm_bridge.agentic_mode
|
|
45
|
+
|
|
46
|
+
@llm_bridge.toggle_agentic_mode!(false)
|
|
47
|
+
@state.add_message(:system, "Agent mode disabled.")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def disable_plan_mode(force:)
|
|
51
|
+
unless @state.plan_mode_active?
|
|
52
|
+
@state.add_message(:system, "Plan mode is already disabled.")
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
return if unsaved_plan_warned?(force)
|
|
57
|
+
|
|
58
|
+
@llm_bridge.toggle_plan_mode!(false)
|
|
59
|
+
@state.deactivate_plan_mode!
|
|
60
|
+
@state.add_message(:system, "Plan mode disabled. Switched back to chat mode.")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def unsaved_plan_warned?(force)
|
|
64
|
+
return false if force || !@state.has_unsaved_plan?
|
|
65
|
+
|
|
66
|
+
@state.add_message(:system,
|
|
67
|
+
"You have an unsaved plan. Use /plan save [filename] first, " \
|
|
68
|
+
"or /plan off --force to discard.")
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def show_plan_status
|
|
73
|
+
if @state.plan_mode_active?
|
|
74
|
+
saved_status = @state.has_unsaved_plan? ? " (unsaved changes)" : ""
|
|
75
|
+
@state.add_message(:system, "Plan mode: enabled#{saved_status}. Use /plan off to disable.")
|
|
76
|
+
else
|
|
77
|
+
@state.add_message(:system, "Plan mode: disabled. Use /plan on to enable.")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def save_plan(filename)
|
|
82
|
+
plan_content = @state.current_plan
|
|
83
|
+
|
|
84
|
+
unless plan_content
|
|
85
|
+
@state.add_message(:system, "No plan to save. Generate a plan first.")
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
filename ||= generate_plan_filename(plan_content)
|
|
90
|
+
write_plan_file(filename, plan_content)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def write_plan_file(filename, content)
|
|
94
|
+
path = File.join(@llm_bridge.project_root, filename)
|
|
95
|
+
File.write(path, content)
|
|
96
|
+
@state.mark_plan_saved!
|
|
97
|
+
@state.add_message(:system, "Plan saved to #{filename}")
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
@state.add_message(:system, "Failed to save plan: #{e.message}")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def generate_plan_filename(content)
|
|
103
|
+
date = Time.now.strftime("%Y-%m-%d")
|
|
104
|
+
first_line = content.lines.first&.strip || "plan"
|
|
105
|
+
slug = first_line.gsub(/[^a-zA-Z0-9\s]/, "").split.first(4).join("_").downcase
|
|
106
|
+
slug = "plan" if slug.empty?
|
|
107
|
+
"plan_#{date}_#{slug}.md"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|