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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +38 -0
  3. data/.rubocop.yml +4 -0
  4. data/CHANGELOG.md +52 -0
  5. data/README.md +101 -23
  6. data/USAGE.md +1 -23
  7. data/config/locales/en.yml +208 -1
  8. data/config/system_prompt.md +6 -1
  9. data/config/tools/bash.json +1 -1
  10. data/config/tools/fetch.json +22 -0
  11. data/config/tools/websearch.json +22 -0
  12. data/docs/images/demo.png +0 -0
  13. data/lib/rubycode/adapters/base.rb +92 -2
  14. data/lib/rubycode/adapters/concerns/debugging.rb +32 -0
  15. data/lib/rubycode/adapters/concerns/error_handling.rb +89 -0
  16. data/lib/rubycode/adapters/concerns/http_client.rb +67 -0
  17. data/lib/rubycode/adapters/deepseek.rb +97 -0
  18. data/lib/rubycode/adapters/gemini.rb +133 -0
  19. data/lib/rubycode/adapters/ollama.rb +114 -82
  20. data/lib/rubycode/adapters/openai.rb +97 -0
  21. data/lib/rubycode/adapters/openrouter.rb +102 -0
  22. data/lib/rubycode/agent_loop.rb +110 -18
  23. data/lib/rubycode/client/approval_handler.rb +14 -0
  24. data/lib/rubycode/client/display_formatter.rb +18 -10
  25. data/lib/rubycode/client/response_handler.rb +4 -23
  26. data/lib/rubycode/client.rb +9 -0
  27. data/lib/rubycode/config_manager.rb +81 -0
  28. data/lib/rubycode/configuration.rb +21 -10
  29. data/lib/rubycode/database.rb +19 -0
  30. data/lib/rubycode/errors.rb +12 -0
  31. data/lib/rubycode/models/api_key.rb +118 -0
  32. data/lib/rubycode/models/memory.rb +84 -10
  33. data/lib/rubycode/models.rb +1 -0
  34. data/lib/rubycode/pricing.rb +59 -0
  35. data/lib/rubycode/search_providers/base.rb +66 -0
  36. data/lib/rubycode/search_providers/brave_search.rb +60 -0
  37. data/lib/rubycode/search_providers/concerns/debugging.rb +37 -0
  38. data/lib/rubycode/search_providers/concerns/error_handling.rb +64 -0
  39. data/lib/rubycode/search_providers/concerns/http_client.rb +67 -0
  40. data/lib/rubycode/search_providers/duckduckgo_instant.rb +98 -0
  41. data/lib/rubycode/search_providers/exa_ai.rb +171 -0
  42. data/lib/rubycode/search_providers/multi_provider.rb +47 -0
  43. data/lib/rubycode/token_counter.rb +41 -0
  44. data/lib/rubycode/tools/bash.rb +38 -8
  45. data/lib/rubycode/tools/fetch.rb +120 -0
  46. data/lib/rubycode/tools/web_search.rb +122 -0
  47. data/lib/rubycode/tools.rb +5 -1
  48. data/lib/rubycode/value_objects.rb +8 -4
  49. data/lib/rubycode/version.rb +1 -1
  50. data/lib/rubycode/views/adapter/debug_delay.rb +20 -0
  51. data/lib/rubycode/views/adapter/debug_request.rb +33 -0
  52. data/lib/rubycode/views/adapter/debug_response.rb +31 -0
  53. data/lib/rubycode/views/agent_loop/token_summary.rb +54 -0
  54. data/lib/rubycode/views/agent_loop.rb +2 -0
  55. data/lib/rubycode/views/cli/api_key_missing.rb +37 -0
  56. data/lib/rubycode/views/cli/config_saved.rb +17 -0
  57. data/lib/rubycode/views/cli/configuration_table.rb +3 -3
  58. data/lib/rubycode/views/cli/first_time_setup.rb +17 -0
  59. data/lib/rubycode/views/cli/restart_message.rb +17 -0
  60. data/lib/rubycode/views/cli/setup_title.rb +17 -0
  61. data/lib/rubycode/views/cli.rb +5 -0
  62. data/lib/rubycode/views/formatter/fetch_summary.rb +54 -0
  63. data/lib/rubycode/views/formatter/web_search_summary.rb +53 -0
  64. data/lib/rubycode/views/formatter.rb +2 -0
  65. data/lib/rubycode/views/search_provider/debug_request.rb +30 -0
  66. data/lib/rubycode/views/search_provider/debug_response.rb +31 -0
  67. data/lib/rubycode/views/web_search_approval.rb +29 -0
  68. data/lib/rubycode/views.rb +5 -0
  69. data/lib/rubycode.rb +10 -0
  70. data/rubycode_cli.rb +228 -32
  71. 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
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyCode
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.4"
5
5
  end
@@ -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:, debug_mode:)
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
- ["Directory", directory],
17
+ ["Adapter", adapter.to_s.capitalize],
18
18
  ["Model", model],
19
- ["Debug Mode", debug_mode ? pastel.green("ON") : pastel.dim("OFF")]
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
@@ -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