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,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ class LLMBridge
6
+ # Handles tool call lifecycle: invocation, confirmation, limits, and results.
7
+ module ToolCallHandling
8
+ private
9
+
10
+ def configure_agentic!(chat)
11
+ tools = @tool_registry.build_tools
12
+ chat.with_tools(*tools, replace: true)
13
+ chat.with_instructions(Tools::SystemPrompt.build(
14
+ project_root: @project_root,
15
+ max_write_rounds: MAX_WRITE_TOOL_ROUNDS,
16
+ max_total_rounds: MAX_TOTAL_TOOL_ROUNDS
17
+ ))
18
+
19
+ chat.on_tool_call { |tool_call| handle_tool_call(tool_call) }
20
+ chat.on_tool_result { |result| handle_tool_result(result) }
21
+ end
22
+
23
+ def handle_tool_call(tool_call)
24
+ raise Tools::AgentCancelledError, "Operation cancelled by user" if @cancel_requested
25
+
26
+ display_name = short_tool_name(tool_call.name)
27
+ risk = @tool_registry.risk_level_for(tool_call.name)
28
+
29
+ increment_call_counts(risk)
30
+ check_tool_limits!
31
+ warn_approaching_limit
32
+
33
+ process_tool_approval(tool_call, display_name, risk)
34
+ end
35
+
36
+ def increment_call_counts(risk)
37
+ @tool_call_count += 1
38
+ @write_tool_call_count += 1 unless risk == Tools::BaseTool::SAFE_RISK
39
+ end
40
+
41
+ def process_tool_approval(tool_call, display_name, risk)
42
+ args_summary = tool_call.arguments.map { |k, v| "#{k}: #{v}" }.join(", ")
43
+
44
+ if risk == Tools::BaseTool::SAFE_RISK || @state.auto_approve_tools?
45
+ @state.add_message(:tool_call, "[#{display_name}] #{args_summary}")
46
+ else
47
+ risk_label = risk == Tools::BaseTool::DANGEROUS_RISK ? "DANGEROUS" : "WRITE"
48
+ @state.request_tool_confirmation!(display_name, tool_call.arguments, risk_label: risk_label)
49
+ wait_for_confirmation(tool_call)
50
+ end
51
+ end
52
+
53
+ def wait_for_confirmation(tool_call)
54
+ display_name = short_tool_name(tool_call.name)
55
+ decision = poll_tool_decision
56
+ apply_tool_decision(decision, display_name)
57
+ end
58
+
59
+ def apply_tool_decision(decision, display_name)
60
+ case decision
61
+ when :cancelled
62
+ @state.clear_tool_confirmation!
63
+ raise Tools::AgentCancelledError, "Operation cancelled by user"
64
+ when :approved
65
+ @state.resolve_tool_confirmation!(:approved)
66
+ when :rejected
67
+ @state.resolve_tool_confirmation!(:rejected)
68
+ raise RubyCoded::Tools::ToolRejectedError, "User rejected #{display_name}"
69
+ end
70
+ end
71
+
72
+ def poll_tool_decision
73
+ @state.mutex.synchronize do
74
+ loop do
75
+ return :cancelled if @cancel_requested
76
+
77
+ case @state.instance_variable_get(:@tool_confirmation_response)
78
+ when :approved then return :approved
79
+ when :rejected then return :rejected
80
+ end
81
+
82
+ @state.tool_cv.wait(@state.mutex, 0.1)
83
+ end
84
+ end
85
+ end
86
+
87
+ def handle_tool_result(result)
88
+ text = result.to_s
89
+ if text.length > MAX_TOOL_RESULT_CHARS
90
+ text = "#{text[0, MAX_TOOL_RESULT_CHARS]}\n... (truncated, #{text.length} total characters)"
91
+ end
92
+ @state.add_message(:tool_result, text)
93
+ end
94
+
95
+ def check_tool_limits!
96
+ if @write_tool_call_count >= MAX_WRITE_TOOL_ROUNDS
97
+ @write_tool_call_count = 0
98
+ @state.add_message(:system,
99
+ "Write tool call budget (#{MAX_WRITE_TOOL_ROUNDS}) reached — auto-resetting counter.")
100
+ end
101
+
102
+ return unless @tool_call_count > MAX_TOTAL_TOOL_ROUNDS
103
+
104
+ raise Tools::AgentIterationLimitError,
105
+ "Reached maximum of #{MAX_TOTAL_TOOL_ROUNDS} total tool calls. " \
106
+ "Send a new message to continue, or use /agent on to reset counters."
107
+ end
108
+
109
+ def warn_approaching_limit
110
+ warn_limit(@tool_call_count, MAX_TOTAL_TOOL_ROUNDS, "total")
111
+ end
112
+
113
+ def warn_limit(count, max, label)
114
+ warning_at = (max * TOOL_ROUNDS_WARNING_THRESHOLD).to_i
115
+ return unless count == warning_at
116
+
117
+ remaining = max - count
118
+ @state.add_message(:system,
119
+ "Approaching #{label} tool call limit: #{remaining} calls remaining. " \
120
+ "Prioritize completing the most important work.")
121
+ end
122
+
123
+ def short_tool_name(name)
124
+ name.split("--").last
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require_relative "../tools/registry"
5
+ require_relative "../tools/system_prompt"
6
+ require_relative "../tools/plan_system_prompt"
7
+ require_relative "../tools/agent_cancelled_error"
8
+ require_relative "../tools/agent_iteration_limit_error"
9
+ require_relative "plan_clarification_parser"
10
+ require_relative "llm_bridge/tool_call_handling"
11
+ require_relative "llm_bridge/streaming_retries"
12
+ require_relative "llm_bridge/plan_mode"
13
+
14
+ module RubyCoded
15
+ module Chat
16
+ # Sends prompts to RubyLLM and streams assistant output into State.
17
+ class LLMBridge
18
+ include ToolCallHandling
19
+ include StreamingRetries
20
+ include PlanMode
21
+
22
+ MAX_RATE_LIMIT_RETRIES = 2
23
+ RATE_LIMIT_BASE_DELAY = 2
24
+ MAX_WRITE_TOOL_ROUNDS = 50
25
+ MAX_TOTAL_TOOL_ROUNDS = 200
26
+ TOOL_ROUNDS_WARNING_THRESHOLD = 0.8
27
+ MAX_TOOL_RESULT_CHARS = 10_000
28
+
29
+ attr_reader :agentic_mode, :plan_mode, :project_root
30
+
31
+ def initialize(state, project_root: Dir.pwd)
32
+ @state = state
33
+ @chat_mutex = Mutex.new
34
+ @cancel_requested = false
35
+ @project_root = project_root
36
+ @agentic_mode = false
37
+ @plan_mode = false
38
+ @tool_registry = Tools::Registry.new(project_root: @project_root)
39
+ reset_chat!(@state.model)
40
+ end
41
+
42
+ def reset_chat!(model_name)
43
+ @chat_mutex.synchronize do
44
+ @chat = RubyLLM.chat(model: model_name)
45
+ apply_mode_config!(@chat)
46
+ end
47
+ end
48
+
49
+ def toggle_agentic_mode!(enabled)
50
+ @agentic_mode = enabled
51
+ @state.agentic_mode = enabled
52
+ if enabled && @plan_mode
53
+ @plan_mode = false
54
+ @state.deactivate_plan_mode!
55
+ end
56
+ @state.disable_auto_approve! unless enabled
57
+ reconfigure_chat!
58
+ end
59
+
60
+ def reset_agent_session!
61
+ @tool_call_count = 0
62
+ @write_tool_call_count = 0
63
+ reset_chat!(@state.model)
64
+ end
65
+
66
+ def toggle_plan_mode!(enabled)
67
+ @plan_mode = enabled
68
+ if enabled && @agentic_mode
69
+ @agentic_mode = false
70
+ @state.agentic_mode = false
71
+ @state.disable_auto_approve!
72
+ end
73
+ reconfigure_chat!
74
+ end
75
+
76
+ def send_async(input)
77
+ auto_switch_to_agent! if should_auto_switch_to_agent?(input)
78
+ reset_call_counts
79
+ chat = prepare_streaming
80
+ Thread.new do
81
+ response = attempt_with_retries(chat, input)
82
+ update_response_tokens(response)
83
+ post_process_plan_response if @plan_mode && !@cancel_requested
84
+ ensure
85
+ @state.streaming = false
86
+ end
87
+ end
88
+
89
+ def cancel!
90
+ @cancel_requested = true
91
+ @state.mutex.synchronize { @state.tool_cv.signal }
92
+ end
93
+
94
+ def approve_tool!
95
+ @state.tool_confirmation_response = :approved
96
+ end
97
+
98
+ def approve_all_tools!
99
+ @state.enable_auto_approve!
100
+ @state.tool_confirmation_response = :approved
101
+ end
102
+
103
+ def reject_tool!
104
+ @state.tool_confirmation_response = :rejected
105
+ end
106
+
107
+ private
108
+
109
+ def reset_call_counts
110
+ @tool_call_count = 0
111
+ @write_tool_call_count = 0
112
+ end
113
+
114
+ def reconfigure_chat!
115
+ @chat_mutex.synchronize do
116
+ apply_mode_config!(@chat)
117
+ end
118
+ end
119
+
120
+ def apply_mode_config!(chat)
121
+ if @agentic_mode
122
+ configure_agentic!(chat)
123
+ elsif @plan_mode
124
+ configure_plan!(chat)
125
+ else
126
+ chat.with_tools(replace: true)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ # Filters deprecated and obsolete models from the selector list.
6
+ # Uses a multi-layered approach: known deprecated patterns,
7
+ # age-based filtering, and latest-alias deduplication.
8
+ module ModelFilter
9
+ DEPRECATED_PATTERNS = [
10
+ /\Agpt-3\.5-turbo/,
11
+ /\Agpt-4-\d{4}/,
12
+ /\Agpt-4-turbo/,
13
+ /\Agpt-4\z/,
14
+ /\Atext-davinci/,
15
+ /\Ababbage/,
16
+ /\Acurie/,
17
+ /\Aada\b/,
18
+ /\Adavinci/,
19
+ /\Aclaude-instant/,
20
+ /\Aclaude-2/,
21
+ /\Aclaude-3-haiku-2024/,
22
+ /\Ao1-preview/,
23
+ /\Ao1-mini/
24
+ ].freeze
25
+
26
+ MAX_AGE_SECONDS = 18 * 30 * 24 * 3600
27
+
28
+ module_function
29
+
30
+ def filter(models)
31
+ models = reject_deprecated_patterns(models)
32
+ models = reject_stale(models)
33
+ deduplicate_latest_aliases(models)
34
+ end
35
+
36
+ def reject_deprecated_patterns(models)
37
+ models.reject do |m|
38
+ id = model_id(m)
39
+ DEPRECATED_PATTERNS.any? { |pattern| id.match?(pattern) }
40
+ end
41
+ end
42
+
43
+ def reject_stale(models)
44
+ cutoff = Time.now - MAX_AGE_SECONDS
45
+ models.select do |m|
46
+ created = model_created_at(m)
47
+ next true unless created
48
+
49
+ id = model_id(m)
50
+ id.include?("latest") || created >= cutoff
51
+ end
52
+ end
53
+
54
+ def deduplicate_latest_aliases(models)
55
+ latest_families = collect_latest_families(models)
56
+ return models if latest_families.empty?
57
+
58
+ reject_dated_snapshots(models, latest_families)
59
+ end
60
+
61
+ def collect_latest_families(models)
62
+ families = Set.new
63
+ models.each do |m|
64
+ id = model_id(m)
65
+ next unless id.end_with?("-latest")
66
+
67
+ family = model_family(m)
68
+ families.add("#{model_provider(m)}:#{family}") if family && !family.empty?
69
+ end
70
+ families
71
+ end
72
+
73
+ def reject_dated_snapshots(models, latest_families)
74
+ models.reject do |m|
75
+ id = model_id(m)
76
+ next false if id.end_with?("-latest")
77
+
78
+ family = model_family(m)
79
+ next false unless family && !family.empty?
80
+
81
+ key = "#{model_provider(m)}:#{family}"
82
+ next false unless latest_families.include?(key)
83
+
84
+ snapshot_with_date?(id)
85
+ end
86
+ end
87
+
88
+ def model_id(model)
89
+ model.respond_to?(:id) ? model.id.to_s : model.to_s
90
+ end
91
+
92
+ def model_created_at(model)
93
+ model.respond_to?(:created_at) ? model.created_at : nil
94
+ end
95
+
96
+ def model_family(model)
97
+ model.respond_to?(:family) ? model.family.to_s : ""
98
+ end
99
+
100
+ def model_provider(model)
101
+ model.respond_to?(:provider) ? model.provider.to_s : "unknown"
102
+ end
103
+
104
+ def snapshot_with_date?(id)
105
+ id.match?(/\d{4}[-_]?\d{2}[-_]?\d{2}/)
106
+ end
107
+
108
+ private_class_method :reject_deprecated_patterns, :reject_stale,
109
+ :deduplicate_latest_aliases, :collect_latest_families,
110
+ :reject_dated_snapshots, :model_id,
111
+ :model_created_at, :model_family,
112
+ :model_provider, :snapshot_with_date?
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCoded
4
+ module Chat
5
+ # Parses <clarification> tags from LLM responses in plan mode.
6
+ # Extracts a single question and its options for the UI.
7
+ module PlanClarificationParser
8
+ CLARIFICATION_REGEX = %r{<clarification>\s*(.*?)\s*</clarification>}m
9
+ QUESTION_REGEX = %r{<question>\s*(.*?)\s*</question>}m
10
+ OPTION_REGEX = %r{<option>\s*(.*?)\s*</option>}m
11
+
12
+ def self.clarification?(content)
13
+ CLARIFICATION_REGEX.match?(content)
14
+ end
15
+
16
+ # Returns { question: String, options: [String], preamble: String }
17
+ # or nil if no clarification tags are found.
18
+ def self.parse(content)
19
+ match = CLARIFICATION_REGEX.match(content)
20
+ return nil unless match
21
+
22
+ inner = match[1]
23
+ question = QUESTION_REGEX.match(inner)&.[](1)&.strip
24
+ options = inner.scan(OPTION_REGEX).flatten.map(&:strip)
25
+
26
+ return nil unless question && options.size >= 2
27
+
28
+ preamble = content[0...match.begin(0)].strip
29
+
30
+ { question: question, options: options, preamble: preamble }
31
+ end
32
+
33
+ def self.strip_clarification(content)
34
+ content.sub(CLARIFICATION_REGEX, "").strip
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "unicode/display_width"
4
+
5
+ module RubyCoded
6
+ module Chat
7
+ class Renderer
8
+ # Core chat-panel rendering: message formatting, scroll management,
9
+ # and the main chat display area.
10
+ module ChatPanel
11
+ private
12
+
13
+ def init_render_cache
14
+ @cached_formatted_text = nil
15
+ @cached_format_gen = -1
16
+ end
17
+
18
+ def cached_formatted_text(messages)
19
+ gen = @state.message_generation
20
+ if gen != @cached_format_gen
21
+ @cached_formatted_text = format_messages_text(messages)
22
+ @cached_format_gen = gen
23
+ end
24
+ @cached_formatted_text
25
+ end
26
+
27
+ def render_chat_panel(frame, area)
28
+ init_render_cache if @cached_format_gen.nil?
29
+ messages = @state.messages_snapshot
30
+
31
+ if @state.streaming? && thinking_in_progress?(messages)
32
+ render_chat_with_thinking(frame, area, messages)
33
+ else
34
+ render_chat_standard(frame, area, messages)
35
+ end
36
+ end
37
+
38
+ def render_chat_standard(frame, area, messages)
39
+ text = messages.empty? ? cover_banner : cached_formatted_text(messages)
40
+ render_text_panel(frame, area, text, !messages.empty?)
41
+ end
42
+
43
+ def render_messages_in_area(frame, area, messages)
44
+ text = messages.empty? ? cover_banner : format_messages_text(messages)
45
+ render_text_panel(frame, area, text, !messages.empty?)
46
+ end
47
+
48
+ def render_text_panel(frame, area, text, scrollable)
49
+ scroll_y = scrollable ? chat_scroll_y(area, text) : 0
50
+
51
+ widget = @tui.paragraph(
52
+ text: text,
53
+ wrap: scrollable,
54
+ scroll: [scroll_y, 0],
55
+ block: @tui.block(title: chat_panel_title, borders: [:all])
56
+ )
57
+ frame.render_widget(widget, area)
58
+ end
59
+
60
+ def chat_scroll_y(area, text)
61
+ inner_height = [area.height - 2, 0].max
62
+ inner_width = [area.width - 2, 0].max
63
+ total_lines = count_wrapped_lines(text, inner_width)
64
+ @state.update_scroll_metrics(total_lines: total_lines, visible_height: inner_height)
65
+ compute_scroll_y(total_lines, inner_height)
66
+ end
67
+
68
+ def chat_panel_title
69
+ title = @state.model.to_s
70
+ title += " [agent]" if agent_mode_active?
71
+ title += " [plan]" if @state.respond_to?(:plan_mode_active?) && @state.plan_mode_active?
72
+ title
73
+ end
74
+
75
+ def agent_mode_active?
76
+ @state.respond_to?(:agentic_mode?) && @state.agentic_mode?
77
+ end
78
+
79
+ def chat_panel_text
80
+ messages = @state.messages_snapshot
81
+ messages.empty? ? cover_banner : cached_formatted_text(messages)
82
+ end
83
+
84
+ def format_messages_text(messages)
85
+ messages.filter_map { |m| format_message(m) }.join("\n")
86
+ end
87
+
88
+ def chat_messages_text
89
+ cached_formatted_text(@state.messages_snapshot)
90
+ end
91
+
92
+ def format_message(msg)
93
+ case msg[:role]
94
+ when :tool_call, :tool_pending, :tool_result then nil
95
+ when :system then "--- #{msg[:content]}"
96
+ when :user then "> #{msg[:content]}"
97
+ when :assistant then format_assistant_message(msg[:content])
98
+ else "#{msg[:role]}: #{msg[:content]}"
99
+ end
100
+ end
101
+
102
+ def format_assistant_message(content)
103
+ result = strip_think_tags(content)
104
+ result.empty? ? nil : result
105
+ end
106
+
107
+ def compute_scroll_y(total_lines, visible_height)
108
+ overflow = total_lines - visible_height
109
+ return 0 if overflow <= 0
110
+
111
+ [overflow - @state.scroll_offset, 0].max
112
+ end
113
+
114
+ def count_wrapped_lines(text, width)
115
+ return 1 if width <= 0 || text.empty?
116
+
117
+ text.split("\n", -1).sum do |line|
118
+ line.empty? ? 1 : (display_width(line).to_f / width).ceil
119
+ end
120
+ end
121
+
122
+ def display_width(line)
123
+ Unicode::DisplayWidth.of(line)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../initializer/cover"
4
+
5
+ module RubyCoded
6
+ module Chat
7
+ class Renderer
8
+ # Renders the input prompt panel and cursor at the bottom of the chat UI.
9
+ module ChatPanelInput
10
+ INPUT_PREFIX = "ruby_coded> "
11
+
12
+ private
13
+
14
+ def render_input_panel(frame, area)
15
+ prefix_len, offset, text = prepare_input_text(area)
16
+
17
+ widget = @tui.paragraph(
18
+ text: text,
19
+ block: @tui.block(borders: [:all])
20
+ )
21
+ frame.render_widget(widget, area)
22
+ render_input_cursor(frame, area, prefix_len, offset) unless input_locked?
23
+ end
24
+
25
+ def prepare_input_text(area)
26
+ inner_width = [area.width - 2, 0].max
27
+ prefix_len = INPUT_PREFIX.length
28
+ text_visible_width = [inner_width - prefix_len, 0].max
29
+
30
+ @state.update_input_visible_width(text_visible_width)
31
+ @state.update_input_scroll_offset
32
+
33
+ offset = @state.input_scroll_offset
34
+ visible_slice = @state.input_buffer[offset, text_visible_width] || ""
35
+ display_prefix = offset.positive? ? "…#{INPUT_PREFIX[1..]}" : INPUT_PREFIX
36
+
37
+ [prefix_len, offset, "#{display_prefix}#{visible_slice}"]
38
+ end
39
+
40
+ def input_locked?
41
+ @state.streaming? || @state.model_select? || @state.plan_clarification?
42
+ end
43
+
44
+ def render_input_cursor(frame, area, prefix_len, scroll_offset)
45
+ cursor_x = area.x + 1 + prefix_len + (@state.cursor_position - scroll_offset)
46
+ cursor_y = area.y + 1
47
+ frame.set_cursor_position(cursor_x, cursor_y)
48
+ end
49
+
50
+ def cover_banner
51
+ Initializer::Cover::BANNER.sub("%<version>s", RubyCoded::VERSION)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end