ruby_coded 0.2.2 → 0.3.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +88 -3
  4. data/lib/ruby_coded/auth/jwt_decoder.rb +14 -0
  5. data/lib/ruby_coded/chat/app.rb +23 -4
  6. data/lib/ruby_coded/chat/codex_bridge/error_handling.rb +68 -10
  7. data/lib/ruby_coded/chat/codex_bridge/sse_parser.rb +20 -0
  8. data/lib/ruby_coded/chat/codex_models.rb +36 -7
  9. data/lib/ruby_coded/chat/command_handler/custom_commands.rb +91 -0
  10. data/lib/ruby_coded/chat/command_handler/model_commands.rb +8 -1
  11. data/lib/ruby_coded/chat/command_handler.rb +64 -36
  12. data/lib/ruby_coded/chat/help.txt +0 -20
  13. data/lib/ruby_coded/chat/renderer/model_selector.rb +4 -1
  14. data/lib/ruby_coded/chat/renderer/status_bar.rb +7 -0
  15. data/lib/ruby_coded/chat/state/context_window.rb +59 -0
  16. data/lib/ruby_coded/chat/state/message_token_tracking.rb +16 -0
  17. data/lib/ruby_coded/chat/state.rb +19 -3
  18. data/lib/ruby_coded/commands/catalog.rb +170 -0
  19. data/lib/ruby_coded/commands/command_definition.rb +30 -0
  20. data/lib/ruby_coded/commands/core_provider.rb +94 -0
  21. data/lib/ruby_coded/commands/markdown_loader.rb +101 -0
  22. data/lib/ruby_coded/commands/markdown_provider.rb +45 -0
  23. data/lib/ruby_coded/commands/plugin_provider.rb +38 -0
  24. data/lib/ruby_coded/commands.rb +8 -0
  25. data/lib/ruby_coded/plugins/command_completion/state_extension.rb +5 -18
  26. data/lib/ruby_coded/tools/git_add_tool.rb +55 -0
  27. data/lib/ruby_coded/tools/git_base_tool.rb +67 -0
  28. data/lib/ruby_coded/tools/git_commit_tool.rb +45 -0
  29. data/lib/ruby_coded/tools/git_diff_tool.rb +23 -0
  30. data/lib/ruby_coded/tools/git_status_tool.rb +17 -0
  31. data/lib/ruby_coded/tools/registry.rb +12 -2
  32. data/lib/ruby_coded/tools/run_command_tool.rb +8 -1
  33. data/lib/ruby_coded/tools/system_prompt.rb +3 -0
  34. data/lib/ruby_coded/version.rb +1 -1
  35. data/lib/ruby_coded.rb +1 -0
  36. metadata +16 -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
- # Base commands are always available; plugins can contribute
17
- # additional commands via the plugin registry.
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:, user_config: nil, credentials_store: nil, auth_manager: nil)
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
- method_name = @commands[command.downcase]
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
- cmds = BASE_COMMANDS.dup
69
- cmds.merge!(RubyCoded.plugin_registry.all_commands)
70
- cmds
67
+ return {} unless @command_catalog
68
+
69
+ @command_catalog.command_map
71
70
  end
72
71
 
73
72
  def cmd_help(_rest)
74
- text = HELP_TEXT.dup
75
- plugin_descs = RubyCoded.plugin_registry.all_command_descriptions
76
- unless plugin_descs.empty?
77
- text += "\nPlugin commands:\n"
78
- plugin_descs.each { |cmd, desc| text += " #{cmd.ljust(18)} #{desc}\n" }
79
- end
80
- @state.add_message(:system, text)
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
- "#{id} (#{provider})#{current_marker}"
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 :model, :should_quit, :codex_mode
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