ruby_coded 0.2.1 → 0.3.0
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 +4 -4
- data/CHANGELOG.md +30 -4
- data/README.md +84 -3
- data/lib/ruby_coded/auth/auth_manager.rb +3 -2
- data/lib/ruby_coded/auth/credentials_store.rb +2 -2
- data/lib/ruby_coded/auth/jwt_decoder.rb +14 -0
- data/lib/ruby_coded/chat/app.rb +17 -5
- data/lib/ruby_coded/chat/codex_bridge/error_handling.rb +68 -10
- data/lib/ruby_coded/chat/codex_bridge/sse_parser.rb +20 -0
- data/lib/ruby_coded/chat/codex_models.rb +36 -7
- data/lib/ruby_coded/chat/command_handler/custom_commands.rb +91 -0
- data/lib/ruby_coded/chat/command_handler/model_commands.rb +8 -1
- data/lib/ruby_coded/chat/command_handler.rb +64 -36
- data/lib/ruby_coded/chat/help.txt +0 -20
- data/lib/ruby_coded/chat/renderer/model_selector.rb +4 -1
- data/lib/ruby_coded/chat/renderer/status_bar.rb +7 -0
- data/lib/ruby_coded/chat/state/context_window.rb +59 -0
- data/lib/ruby_coded/chat/state/message_token_tracking.rb +16 -0
- data/lib/ruby_coded/chat/state.rb +19 -3
- data/lib/ruby_coded/commands/catalog.rb +170 -0
- data/lib/ruby_coded/commands/command_definition.rb +30 -0
- data/lib/ruby_coded/commands/core_provider.rb +94 -0
- data/lib/ruby_coded/commands/markdown_loader.rb +101 -0
- data/lib/ruby_coded/commands/markdown_provider.rb +45 -0
- data/lib/ruby_coded/commands/plugin_provider.rb +38 -0
- data/lib/ruby_coded/commands.rb +8 -0
- data/lib/ruby_coded/initializer.rb +1 -1
- data/lib/ruby_coded/plugins/command_completion/state_extension.rb +5 -18
- data/lib/ruby_coded/version.rb +1 -1
- data/lib/ruby_coded.rb +1 -0
- metadata +11 -2
|
@@ -9,12 +9,13 @@ require_relative "command_handler/token_commands"
|
|
|
9
9
|
require_relative "command_handler/agent_commands"
|
|
10
10
|
require_relative "command_handler/plan_commands"
|
|
11
11
|
require_relative "command_handler/login_commands"
|
|
12
|
+
require_relative "command_handler/custom_commands"
|
|
12
13
|
|
|
13
14
|
module RubyCoded
|
|
14
15
|
module Chat
|
|
15
16
|
# Handles slash commands entered in the chat input.
|
|
16
|
-
#
|
|
17
|
-
#
|
|
17
|
+
# Commands are resolved from a unified command catalog, which may
|
|
18
|
+
# include core commands, plugin commands, and markdown commands.
|
|
18
19
|
class CommandHandler
|
|
19
20
|
include ModelCommands
|
|
20
21
|
include HistoryCommands
|
|
@@ -23,28 +24,17 @@ module RubyCoded
|
|
|
23
24
|
include AgentCommands
|
|
24
25
|
include PlanCommands
|
|
25
26
|
include LoginCommands
|
|
26
|
-
|
|
27
|
-
BASE_COMMANDS = {
|
|
28
|
-
"/help" => :cmd_help,
|
|
29
|
-
"/exit" => :cmd_exit,
|
|
30
|
-
"/quit" => :cmd_exit,
|
|
31
|
-
"/clear" => :cmd_clear,
|
|
32
|
-
"/model" => :cmd_model,
|
|
33
|
-
"/history" => :cmd_history,
|
|
34
|
-
"/tokens" => :cmd_tokens,
|
|
35
|
-
"/agent" => :cmd_agent,
|
|
36
|
-
"/plan" => :cmd_plan,
|
|
37
|
-
"/login" => :cmd_login
|
|
38
|
-
}.freeze
|
|
27
|
+
include CustomCommands
|
|
39
28
|
|
|
40
29
|
HELP_TEXT = File.read(File.join(__dir__, "help.txt")).freeze
|
|
41
30
|
|
|
42
|
-
def initialize(state, llm_bridge:,
|
|
31
|
+
def initialize(state, llm_bridge:, command_catalog: nil, **deps)
|
|
43
32
|
@state = state
|
|
44
33
|
@llm_bridge = llm_bridge
|
|
45
|
-
@user_config = user_config
|
|
46
|
-
@credentials_store = credentials_store
|
|
47
|
-
@auth_manager = auth_manager
|
|
34
|
+
@user_config = deps[:user_config]
|
|
35
|
+
@credentials_store = deps[:credentials_store]
|
|
36
|
+
@auth_manager = deps[:auth_manager]
|
|
37
|
+
@command_catalog = command_catalog
|
|
48
38
|
@commands = build_command_map
|
|
49
39
|
end
|
|
50
40
|
|
|
@@ -53,31 +43,49 @@ module RubyCoded
|
|
|
53
43
|
return if stripped.empty?
|
|
54
44
|
|
|
55
45
|
command, rest = stripped.split(" ", 2)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if method_name
|
|
59
|
-
send(method_name, rest)
|
|
60
|
-
else
|
|
61
|
-
@state.add_message(:system, "Unknown command: #{command}. Type /help for available commands.")
|
|
62
|
-
end
|
|
46
|
+
dispatch_command(command, rest)
|
|
63
47
|
end
|
|
64
48
|
|
|
65
49
|
private
|
|
66
50
|
|
|
51
|
+
def dispatch_command(command, rest)
|
|
52
|
+
normalized = command.downcase
|
|
53
|
+
method_name = @commands[normalized]
|
|
54
|
+
return send(method_name, rest) if method_name
|
|
55
|
+
|
|
56
|
+
dispatch_dynamic_command(command, normalized, rest)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def dispatch_dynamic_command(command, normalized, rest)
|
|
60
|
+
definition = @command_catalog&.find(normalized)
|
|
61
|
+
return handle_markdown_command(definition, rest) if definition&.markdown?
|
|
62
|
+
|
|
63
|
+
@state.add_message(:system, "Unknown command: #{command}. Type /help for available commands.")
|
|
64
|
+
end
|
|
65
|
+
|
|
67
66
|
def build_command_map
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
return {} unless @command_catalog
|
|
68
|
+
|
|
69
|
+
@command_catalog.command_map
|
|
71
70
|
end
|
|
72
71
|
|
|
73
72
|
def cmd_help(_rest)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
73
|
+
lines = ["Available commands:"]
|
|
74
|
+
lines.concat(command_help_lines)
|
|
75
|
+
append_static_help(lines)
|
|
76
|
+
@state.add_message(:system, lines.join("\n"))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def command_help_lines
|
|
80
|
+
@command_catalog.all_definitions.map { |definition| formatted_command_line(definition) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def append_static_help(lines)
|
|
84
|
+
static_help = HELP_TEXT.strip
|
|
85
|
+
return if static_help.empty?
|
|
86
|
+
|
|
87
|
+
lines << ""
|
|
88
|
+
lines << static_help
|
|
81
89
|
end
|
|
82
90
|
|
|
83
91
|
def cmd_exit(_rest)
|
|
@@ -88,6 +96,26 @@ module RubyCoded
|
|
|
88
96
|
@state.clear_messages!
|
|
89
97
|
@state.add_message(:system, "Conversation cleared.")
|
|
90
98
|
end
|
|
99
|
+
|
|
100
|
+
def handle_markdown_command(definition, rest)
|
|
101
|
+
prompt = build_markdown_prompt(definition, rest)
|
|
102
|
+
|
|
103
|
+
@state.add_message(:system, "Running custom command #{definition.name}...")
|
|
104
|
+
@state.add_message(:user, prompt)
|
|
105
|
+
@llm_bridge.send_async(prompt)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_markdown_prompt(definition, rest)
|
|
109
|
+
extra = rest.to_s.strip
|
|
110
|
+
return definition.content if extra.empty?
|
|
111
|
+
|
|
112
|
+
<<~PROMPT
|
|
113
|
+
#{definition.content}
|
|
114
|
+
|
|
115
|
+
Additional user input:
|
|
116
|
+
#{extra}
|
|
117
|
+
PROMPT
|
|
118
|
+
end
|
|
91
119
|
end
|
|
92
120
|
end
|
|
93
121
|
end
|
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
Available commands:
|
|
2
|
-
/help Show this help message
|
|
3
|
-
/model Select a model from available providers
|
|
4
|
-
/model <name> Switch directly to a named model
|
|
5
|
-
/model --all Select from all models (including deprecated)
|
|
6
|
-
/clear Clear the conversation history
|
|
7
|
-
/history Show conversation summary
|
|
8
|
-
/tokens Show detailed token usage and cost for this session
|
|
9
|
-
/agent Show agent mode status
|
|
10
|
-
/agent on Enable agent mode (tools for file operations)
|
|
11
|
-
/agent off Disable agent mode (chat only)
|
|
12
|
-
/plan Show plan mode status
|
|
13
|
-
/plan on Enable plan mode (structured development planning)
|
|
14
|
-
/plan off Disable plan mode (warns if unsaved plan)
|
|
15
|
-
/plan off --force Disable plan mode discarding unsaved plan
|
|
16
|
-
/plan save [file] Save current plan to a markdown file
|
|
17
|
-
/login Authenticate with an AI provider
|
|
18
|
-
/login <provider> Authenticate directly with a specific provider (openai, anthropic)
|
|
19
|
-
/exit, /quit Exit the chat
|
|
20
|
-
|
|
21
1
|
Tool confirmations (agent mode):
|
|
22
2
|
[y] / Enter Approve the current tool call
|
|
23
3
|
[n] / Esc Reject the current tool call
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../codex_models"
|
|
4
|
+
|
|
3
5
|
module RubyCoded
|
|
4
6
|
module Chat
|
|
5
7
|
class Renderer
|
|
@@ -88,7 +90,8 @@ module RubyCoded
|
|
|
88
90
|
id = model.respond_to?(:id) ? model.id : model.to_s
|
|
89
91
|
provider = model.respond_to?(:provider) ? model.provider : "unknown"
|
|
90
92
|
current_marker = id == @state.model ? " *" : ""
|
|
91
|
-
|
|
93
|
+
pro_marker = CodexModels.pro_only?(id) ? " (Pro only)" : ""
|
|
94
|
+
"#{id} (#{provider})#{pro_marker}#{current_marker}"
|
|
92
95
|
end
|
|
93
96
|
end
|
|
94
97
|
end
|
|
@@ -30,9 +30,16 @@ module RubyCoded
|
|
|
30
30
|
left = " ↑#{format_number(input_tok)} ↓#{format_number(output_tok)}"
|
|
31
31
|
left << " 💭#{format_number(thinking_tok)}" if thinking_tok.positive?
|
|
32
32
|
left << " (#{format_number(total_tok)} tokens)"
|
|
33
|
+
left << " | #{format_context_usage(@state.session_context_usage_percentage)}"
|
|
33
34
|
left
|
|
34
35
|
end
|
|
35
36
|
|
|
37
|
+
def format_context_usage(percentage)
|
|
38
|
+
return "Ctx: N/A" if percentage.nil?
|
|
39
|
+
|
|
40
|
+
"Ctx: #{percentage}%"
|
|
41
|
+
end
|
|
42
|
+
|
|
36
43
|
def format_number(num)
|
|
37
44
|
num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
38
45
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require_relative "../codex_models"
|
|
5
|
+
|
|
6
|
+
module RubyCoded
|
|
7
|
+
module Chat
|
|
8
|
+
class State
|
|
9
|
+
# Resolves current model context window and computes live context usage
|
|
10
|
+
# based on the last turn's server-reported `usage` block. This reflects
|
|
11
|
+
# the size of the prompt actually sent to the model on the most recent
|
|
12
|
+
# request, not the total tokens spent in the session.
|
|
13
|
+
module ContextWindow
|
|
14
|
+
def current_model_context_window
|
|
15
|
+
model_name = @model
|
|
16
|
+
return unless model_name
|
|
17
|
+
|
|
18
|
+
codex_model = CodexModels.find(model_name)
|
|
19
|
+
return codex_model.context_window if codex_model.respond_to?(:context_window)
|
|
20
|
+
|
|
21
|
+
resolve_ruby_llm_context_window(model_name)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def session_context_tokens_used
|
|
25
|
+
last_turn_context_tokens
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def session_context_usage_percentage
|
|
29
|
+
context_window = current_model_context_window
|
|
30
|
+
return nil unless context_window.to_i.positive?
|
|
31
|
+
|
|
32
|
+
percentage = ((session_context_tokens_used.to_f / context_window) * 100).round
|
|
33
|
+
percentage.clamp(0, 100)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def resolve_ruby_llm_context_window(model_name)
|
|
39
|
+
info = RubyLLM.models.find(model_name)
|
|
40
|
+
return info.context_window if info.respond_to?(:context_window) && info.context_window
|
|
41
|
+
return info.max_context_window if info.respond_to?(:max_context_window) && info.max_context_window
|
|
42
|
+
|
|
43
|
+
metadata_context_window(info)
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def metadata_context_window(info)
|
|
49
|
+
return unless info.respond_to?(:metadata)
|
|
50
|
+
|
|
51
|
+
metadata = info.metadata
|
|
52
|
+
return unless metadata.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
metadata[:context_window] || metadata["context_window"]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -35,6 +35,22 @@ module RubyCoded
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Live size of the model's context window as reported by the last
|
|
39
|
+
# turn that carried usage info. Bridges (both API and Codex) are
|
|
40
|
+
# effectively stateless: every request re-sends the full history,
|
|
41
|
+
# so the server-reported `input_tokens` of the latest turn already
|
|
42
|
+
# represents the full live prompt. Summing across turns would
|
|
43
|
+
# double-count. We fall back to 0 when no turn has reported usage
|
|
44
|
+
# yet.
|
|
45
|
+
def last_turn_context_tokens
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
last = @messages.reverse_each.find { |m| m[:input_tokens].to_i.positive? }
|
|
48
|
+
return 0 unless last
|
|
49
|
+
|
|
50
|
+
last[:input_tokens].to_i + last[:output_tokens].to_i + last[:thinking_tokens].to_i
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
38
54
|
def token_usage_by_model
|
|
39
55
|
@mutex.synchronize do
|
|
40
56
|
@token_usage_by_model.transform_values(&:dup)
|
|
@@ -8,6 +8,7 @@ require_relative "state/scrollable"
|
|
|
8
8
|
require_relative "state/tool_confirmation"
|
|
9
9
|
require_relative "state/plan_tracking"
|
|
10
10
|
require_relative "state/token_cost"
|
|
11
|
+
require_relative "state/context_window"
|
|
11
12
|
require_relative "state/login_flow"
|
|
12
13
|
|
|
13
14
|
module RubyCoded
|
|
@@ -22,17 +23,19 @@ module RubyCoded
|
|
|
22
23
|
include ToolConfirmation
|
|
23
24
|
include PlanTracking
|
|
24
25
|
include TokenCost
|
|
26
|
+
include ContextWindow
|
|
25
27
|
include LoginFlow
|
|
26
28
|
|
|
27
29
|
attr_reader :input_buffer, :cursor_position, :input_scroll_offset, :messages, :scroll_offset,
|
|
28
30
|
:mode, :model_list, :model_select_index, :model_select_filter,
|
|
29
|
-
:streaming, :mutex, :tui_suspend_reason
|
|
30
|
-
attr_accessor :
|
|
31
|
+
:streaming, :mutex, :tui_suspend_reason, :command_catalog, :model
|
|
32
|
+
attr_accessor :should_quit, :codex_mode
|
|
31
33
|
|
|
32
34
|
MIN_RENDER_INTERVAL = 0.05
|
|
33
35
|
|
|
34
|
-
def initialize(model:)
|
|
36
|
+
def initialize(model:, command_catalog: nil)
|
|
35
37
|
@model = model
|
|
38
|
+
@command_catalog = command_catalog
|
|
36
39
|
# String.new: literals like "" are frozen under frozen_string_literal
|
|
37
40
|
@input_buffer = String.new
|
|
38
41
|
@cursor_position = 0
|
|
@@ -61,6 +64,13 @@ module RubyCoded
|
|
|
61
64
|
init_plugin_state
|
|
62
65
|
end
|
|
63
66
|
|
|
67
|
+
def model=(value)
|
|
68
|
+
return if @model == value
|
|
69
|
+
|
|
70
|
+
@model = value
|
|
71
|
+
mark_dirty!
|
|
72
|
+
end
|
|
73
|
+
|
|
64
74
|
def streaming=(value)
|
|
65
75
|
@streaming = value
|
|
66
76
|
mark_dirty!
|
|
@@ -83,6 +93,12 @@ module RubyCoded
|
|
|
83
93
|
@should_quit
|
|
84
94
|
end
|
|
85
95
|
|
|
96
|
+
def command_descriptions
|
|
97
|
+
return {} unless @command_catalog
|
|
98
|
+
|
|
99
|
+
@command_catalog.command_descriptions
|
|
100
|
+
end
|
|
101
|
+
|
|
86
102
|
def dirty?
|
|
87
103
|
@mutex.synchronize do
|
|
88
104
|
return false unless @dirty
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "core_provider"
|
|
4
|
+
require_relative "plugin_provider"
|
|
5
|
+
require_relative "markdown_provider"
|
|
6
|
+
|
|
7
|
+
module RubyCoded
|
|
8
|
+
module Commands
|
|
9
|
+
# Merges core, plugin, and markdown commands into a single catalog.
|
|
10
|
+
# rubocop:disable Metrics/ClassLength
|
|
11
|
+
class Catalog
|
|
12
|
+
SOURCE_PRIORITY = {
|
|
13
|
+
markdown: 1,
|
|
14
|
+
plugin: 2,
|
|
15
|
+
core: 3
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(project_root:, plugin_registry:)
|
|
19
|
+
@project_root = project_root
|
|
20
|
+
@plugin_registry = plugin_registry
|
|
21
|
+
@last_reload_report = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def all_definitions
|
|
25
|
+
merged.values.sort_by { |definition| definition.name.downcase }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def command_map
|
|
29
|
+
all_definitions.filter_map { |definition| command_pair(definition) }.to_h
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def command_descriptions
|
|
33
|
+
all_definitions.to_h { |definition| [definition.name, definition.description] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def find(name)
|
|
37
|
+
merged[name.downcase]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def definitions_for_source(source)
|
|
41
|
+
all_definitions.select { |definition| definition.source == source }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reload!
|
|
45
|
+
previous_markdown_names = cached_markdown_names
|
|
46
|
+
clear_cached_reports!
|
|
47
|
+
current_markdown_names = markdown_names
|
|
48
|
+
@last_reload_report = build_reload_report(previous_markdown_names, current_markdown_names)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def last_reload_report
|
|
52
|
+
@last_reload_report || default_reload_report
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def command_pair(definition)
|
|
58
|
+
return unless definition.handler
|
|
59
|
+
|
|
60
|
+
[definition.name.downcase, definition.handler]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def cached_markdown_names
|
|
64
|
+
return [] unless @merged
|
|
65
|
+
|
|
66
|
+
definitions_for_source(:markdown).map { |definition| definition.name.downcase }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def markdown_names
|
|
70
|
+
definitions_for_source(:markdown).map { |definition| definition.name.downcase }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def clear_cached_reports!
|
|
74
|
+
@markdown_report = nil
|
|
75
|
+
@merged = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_reload_report(previous_names, current_names)
|
|
79
|
+
conflicts = markdown_conflicts
|
|
80
|
+
|
|
81
|
+
base_reload_report(previous_names, current_names).merge(
|
|
82
|
+
conflicts: conflicts.size,
|
|
83
|
+
conflict_commands: conflicts.map { |conflict| conflict[:command] },
|
|
84
|
+
conflict_files: conflicts.map { |conflict| conflict[:file] }
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def base_reload_report(previous_names, current_names)
|
|
89
|
+
{
|
|
90
|
+
total: current_names.size,
|
|
91
|
+
added: (current_names - previous_names).size,
|
|
92
|
+
removed: (previous_names - current_names).size,
|
|
93
|
+
invalid: markdown_report[:invalid_count],
|
|
94
|
+
invalid_files: markdown_report[:invalid_files]
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def default_reload_report
|
|
99
|
+
build_reload_report([], markdown_names).merge(added: 0)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def merged
|
|
103
|
+
@merged ||= begin
|
|
104
|
+
result = {}
|
|
105
|
+
providers.each { |provider| merge_provider!(result, provider) }
|
|
106
|
+
result
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def merge_provider!(result, provider)
|
|
111
|
+
provider.definitions.each { |definition| merge_definition!(result, definition) }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def merge_definition!(result, definition)
|
|
115
|
+
key = definition.name.downcase
|
|
116
|
+
existing = result[key]
|
|
117
|
+
return if existing && priority(definition.source) <= priority(existing.source)
|
|
118
|
+
|
|
119
|
+
result[key] = definition
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def providers
|
|
123
|
+
[
|
|
124
|
+
markdown_provider,
|
|
125
|
+
PluginProvider.new(registry: @plugin_registry),
|
|
126
|
+
CoreProvider.new
|
|
127
|
+
]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def markdown_provider
|
|
131
|
+
@markdown_provider ||= MarkdownProvider.new(project_root: @project_root)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def markdown_report
|
|
135
|
+
@markdown_report ||= markdown_provider.load_report
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def markdown_conflicts
|
|
139
|
+
markdown_report[:definitions].filter_map { |definition| build_conflict(definition) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def build_conflict(definition)
|
|
143
|
+
return unless reserved_command_names.include?(definition.name.downcase)
|
|
144
|
+
|
|
145
|
+
{
|
|
146
|
+
command: definition.name,
|
|
147
|
+
file: definition.path ? File.basename(definition.path) : definition.name
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def reserved_command_names
|
|
152
|
+
@reserved_command_names ||= (core_command_names + plugin_command_names).uniq
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def core_command_names
|
|
156
|
+
CoreProvider.new.definitions.map { |definition| definition.name.downcase }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def plugin_command_names
|
|
160
|
+
provider = PluginProvider.new(registry: @plugin_registry)
|
|
161
|
+
provider.definitions.map { |definition| definition.name.downcase }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def priority(source)
|
|
165
|
+
SOURCE_PRIORITY.fetch(source, 0)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
# rubocop:enable Metrics/ClassLength
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCoded
|
|
4
|
+
module Commands
|
|
5
|
+
# Normalized command metadata shared by core, plugin, and markdown commands.
|
|
6
|
+
class CommandDefinition
|
|
7
|
+
ATTRIBUTES = %i[name description handler source usage content path].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader(*ATTRIBUTES)
|
|
10
|
+
|
|
11
|
+
def initialize(**attrs)
|
|
12
|
+
ATTRIBUTES.each { |name| instance_variable_set(ivar(name), attrs[name]) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def markdown?
|
|
16
|
+
@source == :markdown
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def executable?
|
|
20
|
+
!@handler.nil? || markdown?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def ivar(name)
|
|
26
|
+
:"@#{name}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command_definition"
|
|
4
|
+
|
|
5
|
+
module RubyCoded
|
|
6
|
+
module Commands
|
|
7
|
+
# Provides built-in slash commands.
|
|
8
|
+
class CoreProvider
|
|
9
|
+
DEFINITIONS = [
|
|
10
|
+
{
|
|
11
|
+
name: "/help",
|
|
12
|
+
description: "Show help message",
|
|
13
|
+
handler: :cmd_help,
|
|
14
|
+
source: :core,
|
|
15
|
+
usage: "/help"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "/exit",
|
|
19
|
+
description: "Exit the chat",
|
|
20
|
+
handler: :cmd_exit,
|
|
21
|
+
source: :core,
|
|
22
|
+
usage: "/exit"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "/quit",
|
|
26
|
+
description: "Exit the chat",
|
|
27
|
+
handler: :cmd_exit,
|
|
28
|
+
source: :core,
|
|
29
|
+
usage: "/quit"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "/clear",
|
|
33
|
+
description: "Clear conversation history",
|
|
34
|
+
handler: :cmd_clear,
|
|
35
|
+
source: :core,
|
|
36
|
+
usage: "/clear"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "/model",
|
|
40
|
+
description: "Select a model from available providers",
|
|
41
|
+
handler: :cmd_model,
|
|
42
|
+
source: :core,
|
|
43
|
+
usage: "/model [name|--all]"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "/history",
|
|
47
|
+
description: "Show conversation summary",
|
|
48
|
+
handler: :cmd_history,
|
|
49
|
+
source: :core,
|
|
50
|
+
usage: "/history"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "/tokens",
|
|
54
|
+
description: "Show detailed token usage and cost",
|
|
55
|
+
handler: :cmd_tokens,
|
|
56
|
+
source: :core,
|
|
57
|
+
usage: "/tokens"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "/agent",
|
|
61
|
+
description: "Toggle agent mode (on/off)",
|
|
62
|
+
handler: :cmd_agent,
|
|
63
|
+
source: :core,
|
|
64
|
+
usage: "/agent [on|off]"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "/plan",
|
|
68
|
+
description: "Toggle plan mode (on/off/save)",
|
|
69
|
+
handler: :cmd_plan,
|
|
70
|
+
source: :core,
|
|
71
|
+
usage: "/plan [on|off|save [file]]"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "/login",
|
|
75
|
+
description: "Authenticate with an AI provider",
|
|
76
|
+
handler: :cmd_login,
|
|
77
|
+
source: :core,
|
|
78
|
+
usage: "/login [provider]"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "/commands",
|
|
82
|
+
description: "Manage custom markdown commands",
|
|
83
|
+
handler: :cmd_commands,
|
|
84
|
+
source: :core,
|
|
85
|
+
usage: "/commands [reload|list]"
|
|
86
|
+
}
|
|
87
|
+
].freeze
|
|
88
|
+
|
|
89
|
+
def definitions
|
|
90
|
+
DEFINITIONS.map { |attrs| CommandDefinition.new(**attrs) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|