ruby_coded 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +76 -0
  3. data/.github/workflows/release.yml +24 -0
  4. data/.rubocop_todo.yml +122 -0
  5. data/CHANGELOG.md +9 -0
  6. data/CODE_OF_CONDUCT.md +10 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +140 -0
  9. data/Rakefile +12 -0
  10. data/exe/ruby_coded +6 -0
  11. data/lib/ruby_coded/auth/auth_manager.rb +145 -0
  12. data/lib/ruby_coded/auth/callback_servlet.rb +41 -0
  13. data/lib/ruby_coded/auth/credentials_store.rb +35 -0
  14. data/lib/ruby_coded/auth/oauth_callback_server.rb +38 -0
  15. data/lib/ruby_coded/auth/pkce.rb +19 -0
  16. data/lib/ruby_coded/auth/providers/anthropic.rb +32 -0
  17. data/lib/ruby_coded/auth/providers/openai.rb +55 -0
  18. data/lib/ruby_coded/chat/app/event_dispatch.rb +78 -0
  19. data/lib/ruby_coded/chat/app.rb +104 -0
  20. data/lib/ruby_coded/chat/command_handler/agent_commands.rb +53 -0
  21. data/lib/ruby_coded/chat/command_handler/history_commands.rb +38 -0
  22. data/lib/ruby_coded/chat/command_handler/model_commands.rb +91 -0
  23. data/lib/ruby_coded/chat/command_handler/plan_commands.rb +112 -0
  24. data/lib/ruby_coded/chat/command_handler/token_commands.rb +128 -0
  25. data/lib/ruby_coded/chat/command_handler/token_formatting.rb +26 -0
  26. data/lib/ruby_coded/chat/command_handler.rb +89 -0
  27. data/lib/ruby_coded/chat/help.txt +28 -0
  28. data/lib/ruby_coded/chat/input_handler/modal_inputs.rb +102 -0
  29. data/lib/ruby_coded/chat/input_handler/normal_mode_input.rb +116 -0
  30. data/lib/ruby_coded/chat/input_handler.rb +39 -0
  31. data/lib/ruby_coded/chat/llm_bridge/plan_mode.rb +73 -0
  32. data/lib/ruby_coded/chat/llm_bridge/streaming_retries.rb +86 -0
  33. data/lib/ruby_coded/chat/llm_bridge/tool_call_handling.rb +129 -0
  34. data/lib/ruby_coded/chat/llm_bridge.rb +131 -0
  35. data/lib/ruby_coded/chat/model_filter.rb +115 -0
  36. data/lib/ruby_coded/chat/plan_clarification_parser.rb +38 -0
  37. data/lib/ruby_coded/chat/renderer/chat_panel.rb +128 -0
  38. data/lib/ruby_coded/chat/renderer/chat_panel_input.rb +56 -0
  39. data/lib/ruby_coded/chat/renderer/chat_panel_thinking.rb +124 -0
  40. data/lib/ruby_coded/chat/renderer/model_selector.rb +96 -0
  41. data/lib/ruby_coded/chat/renderer/plan_clarifier.rb +112 -0
  42. data/lib/ruby_coded/chat/renderer/plan_clarifier_layout.rb +42 -0
  43. data/lib/ruby_coded/chat/renderer/status_bar.rb +47 -0
  44. data/lib/ruby_coded/chat/renderer.rb +64 -0
  45. data/lib/ruby_coded/chat/state/message_assistant.rb +77 -0
  46. data/lib/ruby_coded/chat/state/message_token_tracking.rb +57 -0
  47. data/lib/ruby_coded/chat/state/messages.rb +70 -0
  48. data/lib/ruby_coded/chat/state/model_selection.rb +79 -0
  49. data/lib/ruby_coded/chat/state/plan_tracking.rb +140 -0
  50. data/lib/ruby_coded/chat/state/scrollable.rb +42 -0
  51. data/lib/ruby_coded/chat/state/token_cost.rb +128 -0
  52. data/lib/ruby_coded/chat/state/tool_confirmation.rb +129 -0
  53. data/lib/ruby_coded/chat/state.rb +205 -0
  54. data/lib/ruby_coded/config/user_config.rb +110 -0
  55. data/lib/ruby_coded/errors/auth_error.rb +12 -0
  56. data/lib/ruby_coded/initializer/cover.rb +29 -0
  57. data/lib/ruby_coded/initializer.rb +52 -0
  58. data/lib/ruby_coded/plugins/base.rb +44 -0
  59. data/lib/ruby_coded/plugins/command_completion/input_extension.rb +30 -0
  60. data/lib/ruby_coded/plugins/command_completion/plugin.rb +27 -0
  61. data/lib/ruby_coded/plugins/command_completion/renderer_extension.rb +54 -0
  62. data/lib/ruby_coded/plugins/command_completion/state_extension.rb +90 -0
  63. data/lib/ruby_coded/plugins/registry.rb +88 -0
  64. data/lib/ruby_coded/plugins.rb +21 -0
  65. data/lib/ruby_coded/strategies/api_key_strategy.rb +39 -0
  66. data/lib/ruby_coded/strategies/base.rb +37 -0
  67. data/lib/ruby_coded/strategies/oauth_strategy.rb +106 -0
  68. data/lib/ruby_coded/tools/agent_cancelled_error.rb +7 -0
  69. data/lib/ruby_coded/tools/agent_iteration_limit_error.rb +7 -0
  70. data/lib/ruby_coded/tools/base_tool.rb +50 -0
  71. data/lib/ruby_coded/tools/create_directory_tool.rb +34 -0
  72. data/lib/ruby_coded/tools/delete_path_tool.rb +50 -0
  73. data/lib/ruby_coded/tools/edit_file_tool.rb +40 -0
  74. data/lib/ruby_coded/tools/list_directory_tool.rb +53 -0
  75. data/lib/ruby_coded/tools/plan_system_prompt.rb +72 -0
  76. data/lib/ruby_coded/tools/read_file_tool.rb +54 -0
  77. data/lib/ruby_coded/tools/registry.rb +66 -0
  78. data/lib/ruby_coded/tools/run_command_tool.rb +75 -0
  79. data/lib/ruby_coded/tools/system_prompt.rb +32 -0
  80. data/lib/ruby_coded/tools/tool_rejected_error.rb +7 -0
  81. data/lib/ruby_coded/tools/write_file_tool.rb +31 -0
  82. data/lib/ruby_coded/version.rb +10 -0
  83. data/lib/ruby_coded.rb +16 -0
  84. data/sig/ruby_coded.rbs +4 -0
  85. metadata +206 -0
@@ -0,0 +1,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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Errors
5
+ # Authentication error
6
+ class AuthError < StandardError
7
+ def initialize(message = "Authentication failed")
8
+ super(message)
9
+ end
10
+ end
11
+ end
12
+ 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