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,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