rubycode 0.1.3 → 0.1.4
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 +4 -4
- data/.env.example +38 -0
- data/.rubocop.yml +4 -0
- data/CHANGELOG.md +52 -0
- data/README.md +101 -23
- data/USAGE.md +1 -23
- data/config/locales/en.yml +208 -1
- data/config/system_prompt.md +6 -1
- data/config/tools/bash.json +1 -1
- data/config/tools/fetch.json +22 -0
- data/config/tools/websearch.json +22 -0
- data/docs/images/demo.png +0 -0
- data/lib/rubycode/adapters/base.rb +92 -2
- data/lib/rubycode/adapters/concerns/debugging.rb +32 -0
- data/lib/rubycode/adapters/concerns/error_handling.rb +89 -0
- data/lib/rubycode/adapters/concerns/http_client.rb +67 -0
- data/lib/rubycode/adapters/deepseek.rb +97 -0
- data/lib/rubycode/adapters/gemini.rb +133 -0
- data/lib/rubycode/adapters/ollama.rb +114 -82
- data/lib/rubycode/adapters/openai.rb +97 -0
- data/lib/rubycode/adapters/openrouter.rb +102 -0
- data/lib/rubycode/agent_loop.rb +110 -18
- data/lib/rubycode/client/approval_handler.rb +14 -0
- data/lib/rubycode/client/display_formatter.rb +18 -10
- data/lib/rubycode/client/response_handler.rb +4 -23
- data/lib/rubycode/client.rb +9 -0
- data/lib/rubycode/config_manager.rb +81 -0
- data/lib/rubycode/configuration.rb +21 -10
- data/lib/rubycode/database.rb +19 -0
- data/lib/rubycode/errors.rb +12 -0
- data/lib/rubycode/models/api_key.rb +118 -0
- data/lib/rubycode/models/memory.rb +84 -10
- data/lib/rubycode/models.rb +1 -0
- data/lib/rubycode/pricing.rb +59 -0
- data/lib/rubycode/search_providers/base.rb +66 -0
- data/lib/rubycode/search_providers/brave_search.rb +60 -0
- data/lib/rubycode/search_providers/concerns/debugging.rb +37 -0
- data/lib/rubycode/search_providers/concerns/error_handling.rb +64 -0
- data/lib/rubycode/search_providers/concerns/http_client.rb +67 -0
- data/lib/rubycode/search_providers/duckduckgo_instant.rb +98 -0
- data/lib/rubycode/search_providers/exa_ai.rb +171 -0
- data/lib/rubycode/search_providers/multi_provider.rb +47 -0
- data/lib/rubycode/token_counter.rb +41 -0
- data/lib/rubycode/tools/bash.rb +38 -8
- data/lib/rubycode/tools/fetch.rb +120 -0
- data/lib/rubycode/tools/web_search.rb +122 -0
- data/lib/rubycode/tools.rb +5 -1
- data/lib/rubycode/value_objects.rb +8 -4
- data/lib/rubycode/version.rb +1 -1
- data/lib/rubycode/views/adapter/debug_delay.rb +20 -0
- data/lib/rubycode/views/adapter/debug_request.rb +33 -0
- data/lib/rubycode/views/adapter/debug_response.rb +31 -0
- data/lib/rubycode/views/agent_loop/token_summary.rb +54 -0
- data/lib/rubycode/views/agent_loop.rb +2 -0
- data/lib/rubycode/views/cli/api_key_missing.rb +37 -0
- data/lib/rubycode/views/cli/config_saved.rb +17 -0
- data/lib/rubycode/views/cli/configuration_table.rb +3 -3
- data/lib/rubycode/views/cli/first_time_setup.rb +17 -0
- data/lib/rubycode/views/cli/restart_message.rb +17 -0
- data/lib/rubycode/views/cli/setup_title.rb +17 -0
- data/lib/rubycode/views/cli.rb +5 -0
- data/lib/rubycode/views/formatter/fetch_summary.rb +54 -0
- data/lib/rubycode/views/formatter/web_search_summary.rb +53 -0
- data/lib/rubycode/views/formatter.rb +2 -0
- data/lib/rubycode/views/search_provider/debug_request.rb +30 -0
- data/lib/rubycode/views/search_provider/debug_response.rb +31 -0
- data/lib/rubycode/views/web_search_approval.rb +29 -0
- data/lib/rubycode/views.rb +5 -0
- data/lib/rubycode.rb +10 -0
- data/rubycode_cli.rb +228 -32
- metadata +81 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "nokogiri"
|
|
6
|
+
|
|
7
|
+
module RubyCode
|
|
8
|
+
module Tools
|
|
9
|
+
# Tool for fetching HTML content from URLs
|
|
10
|
+
class Fetch < Base
|
|
11
|
+
MAX_TEXT_SIZE = 50 * 1024 # 50KB limit for text extraction
|
|
12
|
+
REQUEST_TIMEOUT = 30
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def perform(params)
|
|
17
|
+
url = params["url"]
|
|
18
|
+
extract_text = params["extract_text"] || false
|
|
19
|
+
|
|
20
|
+
validate_url!(url)
|
|
21
|
+
|
|
22
|
+
html = fetch_url(url)
|
|
23
|
+
output = extract_text ? extract_text_content(html) : html
|
|
24
|
+
|
|
25
|
+
build_tool_result(url, output, extract_text)
|
|
26
|
+
rescue URLError, HTTPError, NetworkError
|
|
27
|
+
raise # Re-raise specific errors without wrapping
|
|
28
|
+
rescue URI::InvalidURIError => e
|
|
29
|
+
raise URLError, "Invalid URL: #{e.message}"
|
|
30
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
|
|
31
|
+
raise NetworkError, "Network error: #{e.message}"
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
raise ToolError, "Fetch failed: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_tool_result(url, output, extract_text)
|
|
37
|
+
ToolResult.new(
|
|
38
|
+
content: output,
|
|
39
|
+
metadata: {
|
|
40
|
+
url: url,
|
|
41
|
+
size_bytes: output.bytesize,
|
|
42
|
+
extract_text: extract_text
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_url!(url)
|
|
48
|
+
uri = URI.parse(url)
|
|
49
|
+
raise URLError, "URL must have http or https scheme" unless %w[http https].include?(uri.scheme)
|
|
50
|
+
raise URLError, "URL must have a hostname" if uri.hostname.nil? || uri.hostname.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def fetch_url(url)
|
|
54
|
+
uri = URI.parse(url)
|
|
55
|
+
response = make_http_request(uri)
|
|
56
|
+
handle_response(response, uri, url)
|
|
57
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
58
|
+
raise NetworkError, "Request timed out: #{e.message}"
|
|
59
|
+
rescue SocketError, Errno::ECONNREFUSED => e
|
|
60
|
+
raise NetworkError, "Connection failed: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def make_http_request(uri)
|
|
64
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
65
|
+
read_timeout: REQUEST_TIMEOUT, open_timeout: 10) do |http|
|
|
66
|
+
request = Net::HTTP::Get.new(uri)
|
|
67
|
+
request["User-Agent"] = "RubyCode/#{RubyCode::VERSION}"
|
|
68
|
+
http.request(request)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def handle_response(response, uri, url)
|
|
73
|
+
case response
|
|
74
|
+
when Net::HTTPSuccess
|
|
75
|
+
response.body.force_encoding("UTF-8")
|
|
76
|
+
when Net::HTTPRedirection
|
|
77
|
+
handle_redirect(response, uri, url)
|
|
78
|
+
else
|
|
79
|
+
raise HTTPError, "HTTP #{response.code}: #{response.message}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def handle_redirect(response, uri, url)
|
|
84
|
+
location = response["location"]
|
|
85
|
+
raise HTTPError, "Redirect loop detected" if location == url
|
|
86
|
+
|
|
87
|
+
redirect_uri = build_redirect_uri(location, uri)
|
|
88
|
+
fetch_url(redirect_uri.to_s)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_redirect_uri(location, base_uri)
|
|
92
|
+
redirect_uri = URI.parse(location)
|
|
93
|
+
redirect_uri.absolute? ? redirect_uri : base_uri + location
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def extract_text_content(html)
|
|
97
|
+
doc = Nokogiri::HTML(html)
|
|
98
|
+
|
|
99
|
+
# Remove unwanted elements
|
|
100
|
+
doc.css("script, style, nav, footer").each(&:remove)
|
|
101
|
+
|
|
102
|
+
# Extract text from body
|
|
103
|
+
body = doc.at_css("body")
|
|
104
|
+
return "" unless body
|
|
105
|
+
|
|
106
|
+
text = body.text
|
|
107
|
+
.gsub(/\s+/, " ") # Normalize whitespace
|
|
108
|
+
.gsub(/\n\s*\n+/, "\n\n") # Normalize newlines
|
|
109
|
+
.strip
|
|
110
|
+
|
|
111
|
+
# Limit size
|
|
112
|
+
if text.bytesize > MAX_TEXT_SIZE
|
|
113
|
+
text = text[0...MAX_TEXT_SIZE] + "\n[Content truncated at #{MAX_TEXT_SIZE} bytes]"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
text
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "../search_providers/multi_provider"
|
|
6
|
+
require_relative "../search_providers/exa_ai"
|
|
7
|
+
require_relative "../search_providers/duckduckgo_instant"
|
|
8
|
+
require_relative "../search_providers/brave_search"
|
|
9
|
+
|
|
10
|
+
module RubyCode
|
|
11
|
+
module Tools
|
|
12
|
+
# Tool for searching the web using multiple search providers
|
|
13
|
+
class WebSearch < Base
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def perform(params)
|
|
17
|
+
query = params["query"]
|
|
18
|
+
max_results = params["max_results"] || 5
|
|
19
|
+
|
|
20
|
+
request_approval(query, max_results)
|
|
21
|
+
results = search_web(query, max_results)
|
|
22
|
+
format_results(results)
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
raise ToolError, "Search failed: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def request_approval(query, max_results)
|
|
28
|
+
approval_handler = context[:approval_handler]
|
|
29
|
+
return if approval_handler.request_web_search_approval(query, max_results)
|
|
30
|
+
|
|
31
|
+
raise ToolError, I18n.t("rubycode.errors.user_cancelled_search")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def search_web(query, max_results)
|
|
35
|
+
results = fetch_and_parse_results(query, max_results)
|
|
36
|
+
verified_results = verify_results(results, max_results)
|
|
37
|
+
verified_results.first(max_results)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def fetch_and_parse_results(query, max_results)
|
|
41
|
+
@multi_provider = SearchProviders::MultiProvider.new
|
|
42
|
+
configure_search_providers
|
|
43
|
+
@multi_provider.search(query, max_results: max_results * 2) # Get more to filter
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
raise ToolError, "All search providers failed: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def configure_search_providers
|
|
49
|
+
add_exa_provider
|
|
50
|
+
add_duckduckgo_provider
|
|
51
|
+
add_brave_provider
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def add_exa_provider
|
|
55
|
+
exa_api_key = Models::ApiKey.get_key(adapter: :exa) || ENV.fetch("EXA_API_KEY", nil)
|
|
56
|
+
return unless exa_api_key && !exa_api_key.empty?
|
|
57
|
+
|
|
58
|
+
@multi_provider.add_provider(SearchProviders::ExaAi.new(api_key: exa_api_key))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add_duckduckgo_provider
|
|
62
|
+
@multi_provider.add_provider(SearchProviders::DuckduckgoInstant.new)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def add_brave_provider
|
|
66
|
+
brave_api_key = ENV.fetch("BRAVE_API_KEY", nil)
|
|
67
|
+
return unless brave_api_key && !brave_api_key.empty?
|
|
68
|
+
|
|
69
|
+
@multi_provider.add_provider(SearchProviders::BraveSearch.new(api_key: brave_api_key))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def verify_results(results, max_results)
|
|
73
|
+
verified = []
|
|
74
|
+
|
|
75
|
+
# Verify more than needed to account for dead links
|
|
76
|
+
results.first(max_results * 2).each do |result|
|
|
77
|
+
verified << result if url_exists?(result[:url])
|
|
78
|
+
break if verified.size >= max_results
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
verified
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def url_exists?(url)
|
|
85
|
+
return false if url.nil? || url.empty?
|
|
86
|
+
|
|
87
|
+
uri = URI.parse(url)
|
|
88
|
+
|
|
89
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
90
|
+
read_timeout: 5, open_timeout: 5) do |http|
|
|
91
|
+
request = Net::HTTP::Head.new(uri.request_uri)
|
|
92
|
+
response = http.request(request)
|
|
93
|
+
response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
|
|
94
|
+
end
|
|
95
|
+
rescue StandardError
|
|
96
|
+
false # Consider URL dead if verification fails
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def format_results(results)
|
|
100
|
+
return ToolResult.new(content: "No results found") if results.empty?
|
|
101
|
+
|
|
102
|
+
formatted = results.map.with_index(1) do |result, idx|
|
|
103
|
+
[
|
|
104
|
+
"#{idx}. #{result[:title]}",
|
|
105
|
+
" URL: #{result[:url]}",
|
|
106
|
+
" #{result[:snippet]}",
|
|
107
|
+
""
|
|
108
|
+
].join("\n")
|
|
109
|
+
end.join("\n")
|
|
110
|
+
|
|
111
|
+
ToolResult.new(
|
|
112
|
+
content: formatted.strip,
|
|
113
|
+
metadata: {
|
|
114
|
+
result_count: results.size,
|
|
115
|
+
results: results,
|
|
116
|
+
provider: @multi_provider&.last_used_provider || "Search"
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
data/lib/rubycode/tools.rb
CHANGED
|
@@ -7,6 +7,8 @@ require_relative "tools/search"
|
|
|
7
7
|
require_relative "tools/write"
|
|
8
8
|
require_relative "tools/update"
|
|
9
9
|
require_relative "tools/done"
|
|
10
|
+
require_relative "tools/web_search"
|
|
11
|
+
require_relative "tools/fetch"
|
|
10
12
|
|
|
11
13
|
module RubyCode
|
|
12
14
|
# Collection of available tools for the AI agent
|
|
@@ -18,7 +20,9 @@ module RubyCode
|
|
|
18
20
|
Search,
|
|
19
21
|
Write,
|
|
20
22
|
Update,
|
|
21
|
-
Done
|
|
23
|
+
Done,
|
|
24
|
+
WebSearch,
|
|
25
|
+
Fetch
|
|
22
26
|
].freeze
|
|
23
27
|
|
|
24
28
|
def self.definitions
|
|
@@ -3,22 +3,26 @@
|
|
|
3
3
|
module RubyCode
|
|
4
4
|
# Represents a conversation message
|
|
5
5
|
class Message
|
|
6
|
-
attr_reader :role, :content, :timestamp
|
|
6
|
+
attr_reader :role, :content, :timestamp, :tool_calls
|
|
7
7
|
|
|
8
|
-
def initialize(role:, content:)
|
|
8
|
+
def initialize(role:, content:, tool_calls: nil)
|
|
9
9
|
@role = role
|
|
10
10
|
@content = content
|
|
11
|
+
@tool_calls = tool_calls
|
|
11
12
|
@timestamp = Time.now
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def to_h
|
|
15
|
-
{ role: role, content: content }
|
|
16
|
+
hash = { role: role, content: content }
|
|
17
|
+
hash[:tool_calls] = tool_calls if tool_calls && !tool_calls.empty?
|
|
18
|
+
hash
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
def ==(other)
|
|
19
22
|
other.is_a?(Message) &&
|
|
20
23
|
role == other.role &&
|
|
21
|
-
content == other.content
|
|
24
|
+
content == other.content &&
|
|
25
|
+
tool_calls == other.tool_calls
|
|
22
26
|
end
|
|
23
27
|
end
|
|
24
28
|
|
data/lib/rubycode/version.rb
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
module Views
|
|
5
|
+
module Adapter
|
|
6
|
+
# Displays rate limit delay debug information
|
|
7
|
+
class DebugDelay
|
|
8
|
+
def self.build(adapter_name:, delay:)
|
|
9
|
+
pastel = Pastel.new
|
|
10
|
+
|
|
11
|
+
[
|
|
12
|
+
"",
|
|
13
|
+
pastel.yellow("⏱️ Rate limit delay: #{delay}s (#{adapter_name})"),
|
|
14
|
+
""
|
|
15
|
+
].join("\n")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RubyCode
|
|
7
|
+
module Views
|
|
8
|
+
module Adapter
|
|
9
|
+
# Builds debug request text for adapter API calls
|
|
10
|
+
class DebugRequest
|
|
11
|
+
def self.build(adapter_name:, url:, model:, payload:)
|
|
12
|
+
pastel = Pastel.new
|
|
13
|
+
content = build_content_lines(pastel, adapter_name, url, model, payload)
|
|
14
|
+
content.join("\n")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.build_content_lines(pastel, adapter_name, url, model, payload)
|
|
18
|
+
[
|
|
19
|
+
"",
|
|
20
|
+
pastel.yellow.bold("=== DEBUG REQUEST ==="),
|
|
21
|
+
"#{pastel.bold("Adapter:")} #{adapter_name}",
|
|
22
|
+
"#{pastel.bold("URL:")} #{url}",
|
|
23
|
+
"#{pastel.bold("Model:")} #{model}",
|
|
24
|
+
"",
|
|
25
|
+
pastel.bold("Payload:"),
|
|
26
|
+
JSON.pretty_generate(payload),
|
|
27
|
+
pastel.yellow.bold("=" * 21)
|
|
28
|
+
]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RubyCode
|
|
7
|
+
module Views
|
|
8
|
+
module Adapter
|
|
9
|
+
# Builds debug response text for adapter API responses
|
|
10
|
+
class DebugResponse
|
|
11
|
+
def self.build(adapter_name:, body:)
|
|
12
|
+
pastel = Pastel.new
|
|
13
|
+
content = build_content_lines(pastel, adapter_name, body)
|
|
14
|
+
content.join("\n")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.build_content_lines(pastel, adapter_name, body)
|
|
18
|
+
[
|
|
19
|
+
"",
|
|
20
|
+
pastel.green.bold("=== DEBUG RESPONSE ==="),
|
|
21
|
+
"#{pastel.bold("Adapter:")} #{adapter_name}",
|
|
22
|
+
"",
|
|
23
|
+
pastel.bold("Response:"),
|
|
24
|
+
JSON.pretty_generate(body),
|
|
25
|
+
pastel.green.bold("=" * 22)
|
|
26
|
+
]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
module Views
|
|
5
|
+
module AgentLoop
|
|
6
|
+
# Displays token usage and cost information
|
|
7
|
+
class TokenSummary
|
|
8
|
+
def self.build(tokens:, adapter:, model:, cumulative: nil)
|
|
9
|
+
pastel = Pastel.new
|
|
10
|
+
cost = Pricing.calculate_cost(adapter: adapter, model: model, tokens: tokens)
|
|
11
|
+
|
|
12
|
+
lines = build_token_lines(tokens, cost, pastel)
|
|
13
|
+
lines.concat(build_cumulative_lines(cumulative, adapter, model, pastel)) if cumulative
|
|
14
|
+
|
|
15
|
+
lines.join("\n")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.build_token_lines(tokens, cost, pastel)
|
|
19
|
+
lines = [""]
|
|
20
|
+
lines << build_tokens_line(tokens, pastel)
|
|
21
|
+
lines << build_thinking_line(tokens, pastel) if tokens.thinking&.positive?
|
|
22
|
+
lines << build_cached_line(tokens, pastel) if tokens.cached&.positive?
|
|
23
|
+
lines << build_cost_line(cost, pastel)
|
|
24
|
+
lines
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.build_tokens_line(tokens, pastel)
|
|
28
|
+
" #{pastel.dim("Tokens:")} " \
|
|
29
|
+
"#{pastel.cyan(tokens.input.to_s)} in / " \
|
|
30
|
+
"#{pastel.cyan(tokens.output.to_s)} out"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.build_thinking_line(tokens, pastel)
|
|
34
|
+
" #{pastel.dim("Thinking:")} #{pastel.yellow(tokens.thinking.to_s)} tokens"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.build_cached_line(tokens, pastel)
|
|
38
|
+
" #{pastel.dim("Cached:")} #{pastel.green(tokens.cached.to_s)} tokens (cost saved)"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.build_cost_line(cost, pastel)
|
|
42
|
+
" #{pastel.dim("Cost:")} #{pastel.bold(Pricing.format_cost(cost))}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.build_cumulative_lines(cumulative, adapter, model, pastel)
|
|
46
|
+
total_cost = Pricing.calculate_cost(adapter: adapter, model: model, tokens: cumulative)
|
|
47
|
+
[" #{pastel.dim("Session total:")} " \
|
|
48
|
+
"#{pastel.cyan(cumulative.total.to_s)} tokens / " \
|
|
49
|
+
"#{pastel.bold(Pricing.format_cost(total_cost))}"]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -6,3 +6,5 @@ require_relative "agent_loop/response_received"
|
|
|
6
6
|
require_relative "agent_loop/iteration_header"
|
|
7
7
|
require_relative "agent_loop/iteration_footer"
|
|
8
8
|
require_relative "agent_loop/tool_error"
|
|
9
|
+
require_relative "agent_loop/adapter_error"
|
|
10
|
+
require_relative "agent_loop/retry_status"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-box"
|
|
4
|
+
require "pastel"
|
|
5
|
+
|
|
6
|
+
module RubyCode
|
|
7
|
+
module Views
|
|
8
|
+
module Cli
|
|
9
|
+
# Builds API key missing warning
|
|
10
|
+
class ApiKeyMissing
|
|
11
|
+
def self.build(adapter:)
|
|
12
|
+
Pastel.new
|
|
13
|
+
adapter_upper = adapter.to_s.upcase
|
|
14
|
+
adapter_info = I18n.t("rubycode.adapters.#{adapter}")
|
|
15
|
+
|
|
16
|
+
warning_box = TTY::Box.frame(
|
|
17
|
+
title: { top_left: " ⚠️ #{I18n.t("rubycode.setup.api_key_missing", adapter: adapter_upper)} " },
|
|
18
|
+
border: :thick,
|
|
19
|
+
padding: 1,
|
|
20
|
+
style: {
|
|
21
|
+
fg: :yellow,
|
|
22
|
+
border: {
|
|
23
|
+
fg: :yellow
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
) do
|
|
27
|
+
I18n.t("rubycode.setup.api_key_help",
|
|
28
|
+
url: adapter_info[:api_key_url],
|
|
29
|
+
env_var: "#{adapter_upper}_API_KEY")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
"\n#{warning_box}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module RubyCode
|
|
6
|
+
module Views
|
|
7
|
+
module Cli
|
|
8
|
+
# Builds configuration saved message
|
|
9
|
+
class ConfigSaved
|
|
10
|
+
def self.build(path: "~/.rubycode/config.yml")
|
|
11
|
+
pastel = Pastel.new
|
|
12
|
+
"\n#{pastel.green("✓")} #{I18n.t("rubycode.setup.config_saved", path: path)}\n"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -8,15 +8,15 @@ module RubyCode
|
|
|
8
8
|
module Cli
|
|
9
9
|
# Builds configuration table display
|
|
10
10
|
class ConfigurationTable
|
|
11
|
-
def self.build(directory:, model:,
|
|
11
|
+
def self.build(directory:, model:, adapter: :ollama)
|
|
12
12
|
pastel = Pastel.new
|
|
13
13
|
|
|
14
14
|
table = TTY::Table.new(
|
|
15
15
|
header: [pastel.bold("Setting"), pastel.bold("Value")],
|
|
16
16
|
rows: [
|
|
17
|
-
["
|
|
17
|
+
["Adapter", adapter.to_s.capitalize],
|
|
18
18
|
["Model", model],
|
|
19
|
-
["
|
|
19
|
+
["Directory", directory]
|
|
20
20
|
]
|
|
21
21
|
)
|
|
22
22
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module RubyCode
|
|
6
|
+
module Views
|
|
7
|
+
module Cli
|
|
8
|
+
# Builds first time setup message
|
|
9
|
+
class FirstTimeSetup
|
|
10
|
+
def self.build
|
|
11
|
+
pastel = Pastel.new
|
|
12
|
+
"\n#{pastel.green(I18n.t("rubycode.setup.first_time"))}\n"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module RubyCode
|
|
6
|
+
module Views
|
|
7
|
+
module Cli
|
|
8
|
+
# Builds restart message
|
|
9
|
+
class RestartMessage
|
|
10
|
+
def self.build
|
|
11
|
+
pastel = Pastel.new
|
|
12
|
+
"\n#{pastel.yellow(I18n.t("rubycode.setup.restart_message"))}\n"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module RubyCode
|
|
6
|
+
module Views
|
|
7
|
+
module Cli
|
|
8
|
+
# Builds setup wizard title
|
|
9
|
+
class SetupTitle
|
|
10
|
+
def self.build
|
|
11
|
+
pastel = Pastel.new
|
|
12
|
+
"\n#{pastel.bold(I18n.t("rubycode.setup.title"))}\n\n"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/rubycode/views/cli.rb
CHANGED
|
@@ -9,3 +9,8 @@ require_relative "cli/memory_cleared_message"
|
|
|
9
9
|
require_relative "cli/response_box"
|
|
10
10
|
require_relative "cli/interrupt_message"
|
|
11
11
|
require_relative "cli/error_display"
|
|
12
|
+
require_relative "cli/setup_title"
|
|
13
|
+
require_relative "cli/first_time_setup"
|
|
14
|
+
require_relative "cli/api_key_missing"
|
|
15
|
+
require_relative "cli/config_saved"
|
|
16
|
+
require_relative "cli/restart_message"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module RubyCode
|
|
6
|
+
module Views
|
|
7
|
+
module Formatter
|
|
8
|
+
# Builds fetch result summary display
|
|
9
|
+
class FetchSummary
|
|
10
|
+
def self.build(result:)
|
|
11
|
+
pastel = Pastel.new
|
|
12
|
+
metadata = result.metadata || {}
|
|
13
|
+
content = result.content || ""
|
|
14
|
+
|
|
15
|
+
lines = build_header_lines(pastel, metadata, content)
|
|
16
|
+
lines.concat(build_preview_lines(pastel, content))
|
|
17
|
+
lines << ""
|
|
18
|
+
|
|
19
|
+
lines.join("\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.build_header_lines(pastel, metadata, content)
|
|
23
|
+
url = metadata[:url] || "Unknown URL"
|
|
24
|
+
size_kb = (content.bytesize / 1024.0).round(1)
|
|
25
|
+
size_bytes = content.bytesize
|
|
26
|
+
|
|
27
|
+
[
|
|
28
|
+
"",
|
|
29
|
+
" #{pastel.cyan("📥")} Fetched from: #{pastel.bold(url)}",
|
|
30
|
+
" #{pastel.dim("Status:")} Success",
|
|
31
|
+
" #{pastel.dim("Size:")} #{size_kb} KB (#{size_bytes} bytes)"
|
|
32
|
+
]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.build_preview_lines(pastel, content)
|
|
36
|
+
first_line = content.lines.first&.strip || ""
|
|
37
|
+
return [] unless first_line.length.positive?
|
|
38
|
+
|
|
39
|
+
[
|
|
40
|
+
"",
|
|
41
|
+
pastel.dim(" Preview:"),
|
|
42
|
+
pastel.dim(" #{truncate(first_line, 100)}")
|
|
43
|
+
]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.truncate(text, max_length)
|
|
47
|
+
return text if text.length <= max_length
|
|
48
|
+
|
|
49
|
+
"#{text[0...(max_length - 3)]}..."
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|