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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +76 -0
- data/.github/workflows/release.yml +24 -0
- data/.rubocop_todo.yml +122 -0
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +140 -0
- data/Rakefile +12 -0
- data/exe/ruby_coded +6 -0
- data/lib/ruby_coded/auth/auth_manager.rb +145 -0
- data/lib/ruby_coded/auth/callback_servlet.rb +41 -0
- data/lib/ruby_coded/auth/credentials_store.rb +35 -0
- data/lib/ruby_coded/auth/oauth_callback_server.rb +38 -0
- data/lib/ruby_coded/auth/pkce.rb +19 -0
- data/lib/ruby_coded/auth/providers/anthropic.rb +32 -0
- data/lib/ruby_coded/auth/providers/openai.rb +55 -0
- data/lib/ruby_coded/chat/app/event_dispatch.rb +78 -0
- data/lib/ruby_coded/chat/app.rb +104 -0
- data/lib/ruby_coded/chat/command_handler/agent_commands.rb +53 -0
- data/lib/ruby_coded/chat/command_handler/history_commands.rb +38 -0
- data/lib/ruby_coded/chat/command_handler/model_commands.rb +91 -0
- data/lib/ruby_coded/chat/command_handler/plan_commands.rb +112 -0
- data/lib/ruby_coded/chat/command_handler/token_commands.rb +128 -0
- data/lib/ruby_coded/chat/command_handler/token_formatting.rb +26 -0
- data/lib/ruby_coded/chat/command_handler.rb +89 -0
- data/lib/ruby_coded/chat/help.txt +28 -0
- data/lib/ruby_coded/chat/input_handler/modal_inputs.rb +102 -0
- data/lib/ruby_coded/chat/input_handler/normal_mode_input.rb +116 -0
- data/lib/ruby_coded/chat/input_handler.rb +39 -0
- data/lib/ruby_coded/chat/llm_bridge/plan_mode.rb +73 -0
- data/lib/ruby_coded/chat/llm_bridge/streaming_retries.rb +86 -0
- data/lib/ruby_coded/chat/llm_bridge/tool_call_handling.rb +129 -0
- data/lib/ruby_coded/chat/llm_bridge.rb +131 -0
- data/lib/ruby_coded/chat/model_filter.rb +115 -0
- data/lib/ruby_coded/chat/plan_clarification_parser.rb +38 -0
- data/lib/ruby_coded/chat/renderer/chat_panel.rb +128 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_input.rb +56 -0
- data/lib/ruby_coded/chat/renderer/chat_panel_thinking.rb +124 -0
- data/lib/ruby_coded/chat/renderer/model_selector.rb +96 -0
- data/lib/ruby_coded/chat/renderer/plan_clarifier.rb +112 -0
- data/lib/ruby_coded/chat/renderer/plan_clarifier_layout.rb +42 -0
- data/lib/ruby_coded/chat/renderer/status_bar.rb +47 -0
- data/lib/ruby_coded/chat/renderer.rb +64 -0
- data/lib/ruby_coded/chat/state/message_assistant.rb +77 -0
- data/lib/ruby_coded/chat/state/message_token_tracking.rb +57 -0
- data/lib/ruby_coded/chat/state/messages.rb +70 -0
- data/lib/ruby_coded/chat/state/model_selection.rb +79 -0
- data/lib/ruby_coded/chat/state/plan_tracking.rb +140 -0
- data/lib/ruby_coded/chat/state/scrollable.rb +42 -0
- data/lib/ruby_coded/chat/state/token_cost.rb +128 -0
- data/lib/ruby_coded/chat/state/tool_confirmation.rb +129 -0
- data/lib/ruby_coded/chat/state.rb +205 -0
- data/lib/ruby_coded/config/user_config.rb +110 -0
- data/lib/ruby_coded/errors/auth_error.rb +12 -0
- data/lib/ruby_coded/initializer/cover.rb +29 -0
- data/lib/ruby_coded/initializer.rb +52 -0
- data/lib/ruby_coded/plugins/base.rb +44 -0
- data/lib/ruby_coded/plugins/command_completion/input_extension.rb +30 -0
- data/lib/ruby_coded/plugins/command_completion/plugin.rb +27 -0
- data/lib/ruby_coded/plugins/command_completion/renderer_extension.rb +54 -0
- data/lib/ruby_coded/plugins/command_completion/state_extension.rb +90 -0
- data/lib/ruby_coded/plugins/registry.rb +88 -0
- data/lib/ruby_coded/plugins.rb +21 -0
- data/lib/ruby_coded/strategies/api_key_strategy.rb +39 -0
- data/lib/ruby_coded/strategies/base.rb +37 -0
- data/lib/ruby_coded/strategies/oauth_strategy.rb +106 -0
- data/lib/ruby_coded/tools/agent_cancelled_error.rb +7 -0
- data/lib/ruby_coded/tools/agent_iteration_limit_error.rb +7 -0
- data/lib/ruby_coded/tools/base_tool.rb +50 -0
- data/lib/ruby_coded/tools/create_directory_tool.rb +34 -0
- data/lib/ruby_coded/tools/delete_path_tool.rb +50 -0
- data/lib/ruby_coded/tools/edit_file_tool.rb +40 -0
- data/lib/ruby_coded/tools/list_directory_tool.rb +53 -0
- data/lib/ruby_coded/tools/plan_system_prompt.rb +72 -0
- data/lib/ruby_coded/tools/read_file_tool.rb +54 -0
- data/lib/ruby_coded/tools/registry.rb +66 -0
- data/lib/ruby_coded/tools/run_command_tool.rb +75 -0
- data/lib/ruby_coded/tools/system_prompt.rb +32 -0
- data/lib/ruby_coded/tools/tool_rejected_error.rb +7 -0
- data/lib/ruby_coded/tools/write_file_tool.rb +31 -0
- data/lib/ruby_coded/version.rb +10 -0
- data/lib/ruby_coded.rb +16 -0
- data/sig/ruby_coded.rbs +4 -0
- 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
|