jules-ruby 0.0.67

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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/.jules/bolt.md +4 -0
  3. data/.rubocop.yml +51 -0
  4. data/AGENTS.md +250 -0
  5. data/CHANGELOG.md +20 -0
  6. data/CONTRIBUTING.md +82 -0
  7. data/LICENSE +21 -0
  8. data/README.md +330 -0
  9. data/Rakefile +70 -0
  10. data/SECURITY.md +41 -0
  11. data/assets/banner.png +0 -0
  12. data/bin/jules-ruby +7 -0
  13. data/jules-ruby.gemspec +43 -0
  14. data/lib/jules-ruby/cli/activities.rb +142 -0
  15. data/lib/jules-ruby/cli/banner.rb +113 -0
  16. data/lib/jules-ruby/cli/base.rb +38 -0
  17. data/lib/jules-ruby/cli/interactive/activity_renderer.rb +81 -0
  18. data/lib/jules-ruby/cli/interactive/session_creator.rb +112 -0
  19. data/lib/jules-ruby/cli/interactive/session_manager.rb +285 -0
  20. data/lib/jules-ruby/cli/interactive/source_manager.rb +65 -0
  21. data/lib/jules-ruby/cli/interactive.rb +48 -0
  22. data/lib/jules-ruby/cli/prompts.rb +184 -0
  23. data/lib/jules-ruby/cli/sessions.rb +185 -0
  24. data/lib/jules-ruby/cli/sources.rb +72 -0
  25. data/lib/jules-ruby/cli.rb +127 -0
  26. data/lib/jules-ruby/client.rb +130 -0
  27. data/lib/jules-ruby/configuration.rb +20 -0
  28. data/lib/jules-ruby/errors.rb +35 -0
  29. data/lib/jules-ruby/models/activity.rb +137 -0
  30. data/lib/jules-ruby/models/artifact.rb +78 -0
  31. data/lib/jules-ruby/models/github_branch.rb +17 -0
  32. data/lib/jules-ruby/models/github_repo.rb +31 -0
  33. data/lib/jules-ruby/models/plan.rb +23 -0
  34. data/lib/jules-ruby/models/plan_step.rb +25 -0
  35. data/lib/jules-ruby/models/pull_request.rb +23 -0
  36. data/lib/jules-ruby/models/session.rb +111 -0
  37. data/lib/jules-ruby/models/source.rb +23 -0
  38. data/lib/jules-ruby/models/source_context.rb +35 -0
  39. data/lib/jules-ruby/resources/activities.rb +76 -0
  40. data/lib/jules-ruby/resources/base.rb +27 -0
  41. data/lib/jules-ruby/resources/sessions.rb +125 -0
  42. data/lib/jules-ruby/resources/sources.rb +61 -0
  43. data/lib/jules-ruby/version.rb +5 -0
  44. data/lib/jules-ruby.rb +43 -0
  45. data/mise.toml +2 -0
  46. metadata +232 -0
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JulesRuby
4
+ # ASCII art banner for the CLI
5
+ module Banner
6
+ # Octopus ASCII art - matching Jules logo
7
+ OCTOPUS = [
8
+ ' ████████████████ ',
9
+ ' ████████████████████████ ',
10
+ ' ████████████████████████████ ',
11
+ ' ██████████████████████████████ ',
12
+ ' ████████████████████████████████ ',
13
+ ' ████████████████████████████████ ',
14
+ ' ████████████████████████████████ ',
15
+ ' ████████ ████████████ ████████ ',
16
+ ' ████████ ████████████ ████████ ',
17
+ ' ████████████████████████████████ ',
18
+ ' ██████████████████████████████ ',
19
+ ' ████████████████████████████ ',
20
+ ' ██ ██████████████ ██ ',
21
+ ' ██ ████ ████ ██ ',
22
+ ' ██ ████ ████ ██ ',
23
+ ' ██ ████ ████ ██ ',
24
+ ' ██ ██ ████ ████ ██ ██ ',
25
+ ' ██ ██ ██ ██ ██ ██ ',
26
+ ' ██████ ██████ '
27
+ ].freeze
28
+
29
+ # Jules text banner
30
+ JULES_TEXT = [
31
+ ' ',
32
+ ' ',
33
+ ' ██╗██╗ ██╗██╗ ███████╗███████╗ ',
34
+ ' ██║██║ ██║██║ ██╔════╝██╔════╝ ',
35
+ ' ██║██║ ██║██║ █████╗ ███████╗ ',
36
+ ' ██ ██║██║ ██║██║ ██╔══╝ ╚════██║ ',
37
+ ' ╚█████╔╝╚██████╔╝███████╗███████╗███████║ ',
38
+ ' ╚════╝ ╚═════╝ ╚══════╝╚══════╝╚══════╝ ',
39
+ ' ',
40
+ ' ██████╗ ██╗ ██╗██████╗ ██╗ ██ ',
41
+ ' ██╔══██╗██║ ██║██╔══██╗╚██╗ ██╔╝ ',
42
+ ' ██████╔╝██║ ██║██████╔╝ ╚████╔╝ ',
43
+ ' ██╔══██╗██║ ██║██╔══██╗ ╚██╔╝ ',
44
+ ' ██║ ██║╚██████╔╝██████╔╝ ██║ ',
45
+ ' ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ',
46
+ ' ',
47
+ ' '
48
+ ].freeze
49
+
50
+ class << self
51
+ def hsl_to_rgb(h, s, l)
52
+ s /= 100.0
53
+ l /= 100.0
54
+
55
+ c = (1 - ((2 * l) - 1).abs) * s
56
+ x = c * (1 - (((h / 60.0) % 2) - 1).abs)
57
+ m = l - (c / 2)
58
+
59
+ r, g, b = case h
60
+ when 0...60 then [c, x, 0]
61
+ when 60...120 then [x, c, 0]
62
+ when 120...180 then [0, c, x]
63
+ when 180...240 then [0, x, c]
64
+ when 240...300 then [x, 0, c]
65
+ else [c, 0, x]
66
+ end
67
+
68
+ [((r + m) * 255).round, ((g + m) * 255).round, ((b + m) * 255).round]
69
+ end
70
+
71
+ def print_banner
72
+ num_lines = [OCTOPUS.length, JULES_TEXT.length].max
73
+ octopus_width = OCTOPUS.map(&:length).max
74
+ jules_width = JULES_TEXT.first&.length || 0
75
+
76
+ num_lines.times do |row|
77
+ octopus_line = OCTOPUS[row] || ''
78
+ jules_line = JULES_TEXT[row] || ''
79
+
80
+ # Pad octopus line to consistent width
81
+ octopus_line = octopus_line.ljust(octopus_width)
82
+
83
+ # Print octopus with purple gradient
84
+ octopus_progress = row.to_f / num_lines
85
+ oct_h = 255 + (10 * octopus_progress)
86
+ oct_s = 80 - (5 * octopus_progress)
87
+ oct_l = 65 - (15 * octopus_progress)
88
+ oct_r, oct_g, oct_b = hsl_to_rgb(oct_h, oct_s, oct_l)
89
+
90
+ octopus_line.each_char do |char|
91
+ print "\e[38;2;#{oct_r};#{oct_g};#{oct_b}m#{char}\e[0m"
92
+ end
93
+
94
+ # Print jules text with red-to-purple horizontal gradient
95
+ jules_line.each_char.with_index do |char, col|
96
+ progress = col.to_f / jules_width
97
+
98
+ # Red (hsl 348) to Purple (hsl 280)
99
+ h = 348 + ((280 - 348) * progress)
100
+ s = 83 + ((70 - 83) * progress)
101
+ l = 47 + ((45 - 47) * progress)
102
+
103
+ r, g, b = hsl_to_rgb(h, s, l)
104
+ print "\e[38;2;#{r};#{g};#{b}m#{char}\e[0m"
105
+ end
106
+
107
+ puts
108
+ end
109
+ puts
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'json'
5
+ require 'time'
6
+
7
+ module JulesRuby
8
+ module Commands
9
+ # Base class for CLI subcommands with shared helper methods
10
+ class Base < Thor
11
+ # Helper to define the common format option
12
+ def self.format_option
13
+ method_option :format, type: :string, default: 'table', enum: %w[table json], desc: 'Output format'
14
+ end
15
+
16
+ no_commands do
17
+ def client
18
+ @client ||= JulesRuby::Client.new
19
+ end
20
+
21
+ def error_exit(error)
22
+ if options[:format] == 'json'
23
+ puts JSON.generate({ error: error.message })
24
+ else
25
+ warn "Error: #{error.message}"
26
+ end
27
+ exit 1
28
+ end
29
+
30
+ def truncate(str, length)
31
+ return '' unless str
32
+
33
+ str.length > length ? "#{str[0...(length - 3)]}..." : str
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../prompts'
4
+
5
+ module JulesRuby
6
+ class Interactive
7
+ # Renders activity items in the interactive CLI
8
+ class ActivityRenderer
9
+ def render(activity)
10
+ time = Prompts.time_ago_in_words(activity.create_time)
11
+ type_str = activity.type.to_s.gsub('_', ' ').capitalize
12
+
13
+ puts
14
+ puts Prompts.rgb_color(" ┌─ #{type_str} (#{time})", :muted)
15
+
16
+ display_activity_box_content(activity)
17
+
18
+ puts Prompts.rgb_color(' └─', :muted)
19
+ end
20
+
21
+ private
22
+
23
+ def display_activity_box_content(activity)
24
+ case activity.type
25
+ when :agent_messaged, :user_messaged
26
+ display_message_box(activity)
27
+ when :plan_generated
28
+ display_plan_box(activity)
29
+ when :progress_updated
30
+ display_progress_box(activity)
31
+ when :session_failed
32
+ display_failure_box(activity)
33
+ when :session_completed
34
+ display_completion_box
35
+ end
36
+ end
37
+
38
+ def display_message_box(activity)
39
+ return unless activity.message
40
+
41
+ puts Prompts.rgb_color(' │', :muted)
42
+ wrap_text(activity.message, 72).each_line do |line|
43
+ puts "#{Prompts.rgb_color(' │', :muted)} #{Prompts.rgb_color(line.chomp, :purple)}"
44
+ end
45
+ end
46
+
47
+ def display_plan_box(activity)
48
+ return unless activity.plan&.steps
49
+
50
+ puts Prompts.rgb_color(' │', :muted)
51
+ activity.plan.steps.each_with_index do |step, i|
52
+ puts "#{Prompts.rgb_color(' │', :muted)} #{Prompts.rgb_color("#{i + 1}. #{step.title}", :purple)}"
53
+ end
54
+ end
55
+
56
+ def display_progress_box(activity)
57
+ puts "#{Prompts.rgb_color(' │', :muted)} #{Prompts.rgb_color(activity.progress_title, :purple)}"
58
+ return unless activity.progress_description
59
+
60
+ puts "#{Prompts.rgb_color(' │', :muted)} #{Prompts.rgb_color(activity.progress_description, :purple)}"
61
+ end
62
+
63
+ def display_failure_box(activity)
64
+ puts Prompts.rgb_color(' │', :muted)
65
+ wrap_text(activity.failure_reason, 72).each_line do |line|
66
+ puts "#{Prompts.rgb_color(' │', :muted)} #{Prompts.rgb_color(line.chomp, :purple)}"
67
+ end
68
+ end
69
+
70
+ def display_completion_box
71
+ puts "#{Prompts.rgb_color(' │', :muted)} #{Prompts.rgb_color('Session completed successfully', :purple)}"
72
+ end
73
+
74
+ def wrap_text(text, width = 76)
75
+ return '' unless text
76
+
77
+ text.gsub(/(.{1,#{width}})(\s+|$)/, "\\1\n").strip
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../prompts'
4
+
5
+ module JulesRuby
6
+ class Interactive
7
+ # Wizard for creating new sessions
8
+ class SessionCreator
9
+ def initialize(client, prompt)
10
+ @client = client
11
+ @prompt = prompt
12
+ end
13
+
14
+ def run
15
+ Prompts.clear_screen
16
+ Prompts.print_banner
17
+
18
+ source = select_source
19
+ return unless source
20
+
21
+ branch = @prompt.ask(Prompts.rgb_color('Starting branch:', :lavender), default: 'main')
22
+ task_prompt = ask_for_prompt
23
+ title = @prompt.ask(Prompts.rgb_color('Session title (optional):', :lavender))
24
+ auto_pr = @prompt.yes?(Prompts.rgb_color('Auto-create PR when done?', :lavender), default: true)
25
+
26
+ display_session_summary(source, branch, task_prompt, title, auto_pr)
27
+
28
+ return unless @prompt.yes?(Prompts.rgb_color('Create this session?', :lavender), default: true)
29
+
30
+ create_and_display_session(source, branch, task_prompt, title, auto_pr)
31
+ end
32
+
33
+ private
34
+
35
+ def select_source
36
+ sources = Prompts.with_spinner('Loading sources...') do
37
+ @client.sources.all
38
+ end
39
+
40
+ if sources.empty?
41
+ @prompt.error(Prompts.rgb_color('No sources found. Please connect a repository first.', :purple))
42
+ @prompt.keypress(Prompts.rgb_color('Press any key to continue...', :dim))
43
+ return nil
44
+ end
45
+
46
+ source_choices = sources.map { |s| Prompts.format_source_choice(s) }
47
+ @prompt.select(
48
+ Prompts.rgb_color('Select a repository:', :lavender),
49
+ source_choices,
50
+ filter: true,
51
+ per_page: 15
52
+ )
53
+ end
54
+
55
+ def ask_for_prompt
56
+ puts
57
+ @prompt.ask(Prompts.rgb_color('What would you like Jules to do?', :lavender)) do |q|
58
+ q.required true
59
+ q.validate(/\S/, 'Prompt cannot be empty')
60
+ end
61
+ end
62
+
63
+ def display_session_summary(source, branch, task_prompt, title, auto_pr)
64
+ puts
65
+ puts " 📋 #{Prompts.highlight('Session Summary')}"
66
+ puts " #{Prompts.divider}"
67
+ display_summary_field('📦 Repository:', source.github_repo&.full_name)
68
+ display_summary_field('🌿 Branch:', branch)
69
+ display_summary_field('📝 Prompt:', truncate(task_prompt, 50))
70
+ display_summary_field('🏷️ Title:', title || '(auto-generated)')
71
+ display_summary_field('🔄 Auto PR:', auto_pr ? 'Yes' : 'No')
72
+ puts
73
+ end
74
+
75
+ def display_summary_field(label, value)
76
+ puts " #{Prompts.rgb_color(label, :lavender)} #{Prompts.rgb_color(value, :purple)}"
77
+ end
78
+
79
+ def create_and_display_session(source, branch, task_prompt, title, auto_pr)
80
+ session = Prompts.with_spinner('Creating session...') do
81
+ source_context = {
82
+ 'source' => source.name,
83
+ 'githubRepoContext' => { 'startingBranch' => branch }
84
+ }
85
+
86
+ params = {
87
+ prompt: task_prompt,
88
+ source_context: source_context
89
+ }
90
+ params[:title] = title if title && !title.empty?
91
+ params[:automation_mode] = 'AUTO_CREATE_PR' if auto_pr
92
+
93
+ @client.sessions.create(**params)
94
+ end
95
+
96
+ puts
97
+ puts Prompts.rgb_color(" ✅ Session created: #{session.name}", :purple)
98
+ puts Prompts.rgb_color(" 🔗 URL: #{session.url}", :purple)
99
+ puts Prompts.rgb_color(" 📊 State: #{session.state}", :purple)
100
+ puts
101
+
102
+ @prompt.keypress(Prompts.rgb_color('Press any key to continue...', :dim))
103
+ end
104
+
105
+ def truncate(str, length)
106
+ return '' unless str
107
+
108
+ str.length > length ? "#{str[0...(length - 3)]}..." : str
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../prompts'
4
+ require_relative 'activity_renderer'
5
+ require_relative 'session_creator'
6
+
7
+ module JulesRuby
8
+ class Interactive
9
+ # Manages viewing and interacting with sessions
10
+ class SessionManager
11
+ # States that should trigger auto-refresh
12
+ AUTO_REFRESH_STATES = %w[PLANNING IN_PROGRESS QUEUED].freeze
13
+ AUTO_REFRESH_INTERVAL = 60 # seconds
14
+
15
+ def initialize(client, prompt)
16
+ @client = client
17
+ @prompt = prompt
18
+ @activity_renderer = ActivityRenderer.new
19
+ end
20
+
21
+ def run
22
+ loop do
23
+ Prompts.clear_screen
24
+ Prompts.print_banner
25
+
26
+ sessions = fetch_sessions
27
+ return if sessions.empty?
28
+
29
+ session = select_session(sessions)
30
+ break if session == :back
31
+
32
+ if session == :create
33
+ SessionCreator.new(@client, @prompt).run
34
+ else
35
+ session_detail(session)
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def fetch_sessions
43
+ sessions = Prompts.with_spinner('Loading sessions...') { @client.sessions.all }
44
+ if sessions.empty?
45
+ @prompt.warn(Prompts.rgb_color('No sessions found.', :purple))
46
+ @prompt.keypress(Prompts.rgb_color('Press any key to continue...', :dim))
47
+ end
48
+ sessions
49
+ end
50
+
51
+ def select_session(sessions)
52
+ choices = sessions.map { |s| Prompts.format_session_choice(s) }
53
+ choices.unshift({ name: "➕ #{Prompts.rgb_color('Create new session', :purple)}", value: :create })
54
+ choices << { name: "← #{Prompts.rgb_color('Back to main menu', :purple)}", value: :back }
55
+
56
+ @prompt.select(
57
+ Prompts.rgb_color('Select a session to view:', :lavender),
58
+ choices,
59
+ per_page: 15,
60
+ cycle: true
61
+ )
62
+ end
63
+
64
+ def session_detail(session)
65
+ needs_activity_fetch = true
66
+
67
+ loop do
68
+ Prompts.clear_screen
69
+ Prompts.print_banner
70
+ display_session_header(session)
71
+
72
+ if needs_activity_fetch
73
+ latest_activity = fetch_latest_activity(session)
74
+ needs_activity_fetch = false
75
+ end
76
+
77
+ display_latest_activity(latest_activity)
78
+ puts
79
+
80
+ choices = get_session_choices(session)
81
+ action = select_with_auto_refresh(choices, session)
82
+ result = handle_session_action(action, session)
83
+
84
+ break if result == :back
85
+ break if result == :deleted
86
+
87
+ session, needs_activity_fetch = update_session_state(session, result, needs_activity_fetch)
88
+ end
89
+ end
90
+
91
+ def display_session_header(session)
92
+ display_title_line(session)
93
+ puts " #{Prompts.divider}"
94
+ puts
95
+ display_header_field('ID:', session.id)
96
+ display_header_field('State:', Prompts.state_label(session.state))
97
+ display_header_field('Prompt:', truncate(session.prompt, 60))
98
+ display_header_field('URL:', session.url) if session.url
99
+ display_header_field('Created:', Prompts.format_datetime(session.create_time))
100
+ display_header_field('Updated:', Prompts.format_datetime(session.update_time))
101
+ end
102
+
103
+ def display_title_line(session)
104
+ puts " 📋 #{Prompts.highlight(session.title || 'Session Details')} #{Prompts.state_emoji(session.state)}"
105
+ end
106
+
107
+ def display_header_field(label, value)
108
+ puts " #{Prompts.rgb_color(label, :lavender)} #{Prompts.rgb_color(value, :purple)}"
109
+ end
110
+
111
+ def fetch_latest_activity(session)
112
+ activities = Prompts.with_spinner('Loading latest activity...') do
113
+ @client.activities.all(session.name)
114
+ end
115
+ activities&.last
116
+ rescue StandardError
117
+ nil
118
+ end
119
+
120
+ def display_latest_activity(latest_activity)
121
+ return unless latest_activity
122
+
123
+ puts
124
+ puts " 📍 #{Prompts.highlight('Latest Activity')}"
125
+ puts " #{Prompts.divider}"
126
+ time = Prompts.time_ago_in_words(latest_activity.create_time)
127
+ type_str = latest_activity.type.to_s.gsub('_', ' ').capitalize
128
+ puts Prompts.rgb_color(" #{type_str} (#{time})", :purple)
129
+ puts
130
+ display_activity_content(latest_activity)
131
+ end
132
+
133
+ def display_activity_content(activity)
134
+ # Reuse ActivityRenderer logic? Or just minimal display for header?
135
+ # The original code had simplified display.
136
+ # "display_plan_simple" etc.
137
+ # I'll adapt the original simple display logic here or use ActivityRenderer.
138
+ # The original code had specific `display_activity_content` separate from `display_activity_box_content`.
139
+
140
+ case activity.type
141
+ when :agent_messaged, :user_messaged
142
+ wrap_text(activity.message, 76).each_line { |line| puts Prompts.rgb_color(" #{line}", :purple) }
143
+ when :plan_generated
144
+ display_plan_simple(activity)
145
+ when :progress_updated
146
+ puts Prompts.rgb_color(" #{activity.progress_title}", :purple)
147
+ puts Prompts.rgb_color(" #{activity.progress_description}", :purple) if activity.progress_description
148
+ when :session_failed
149
+ wrap_text(activity.failure_reason, 76).each_line { |line| puts Prompts.rgb_color(" #{line}", :purple) }
150
+ when :session_completed
151
+ puts Prompts.rgb_color(' Session completed successfully', :purple)
152
+ end
153
+ end
154
+
155
+ def display_plan_simple(activity)
156
+ return unless activity.plan&.steps
157
+
158
+ activity.plan.steps.each_with_index do |step, i|
159
+ puts Prompts.rgb_color(" #{i + 1}. #{step.title}", :purple)
160
+ end
161
+ end
162
+
163
+ def get_session_choices(session)
164
+ choices = []
165
+ if session.awaiting_plan_approval?
166
+ choices << { name: "✅ #{Prompts.rgb_color('Approve Plan', :purple)}", value: :approve }
167
+ end
168
+ choices << { name: "💬 #{Prompts.rgb_color('Send Message', :purple)}", value: :message }
169
+ choices << { name: "📜 #{Prompts.rgb_color('View Activities', :purple)}", value: :activities }
170
+ choices << { name: "🌐 #{Prompts.rgb_color('Open in Browser', :purple)}", value: :open_url } if session.url
171
+ choices << { name: "🗑️ #{Prompts.rgb_color('Delete Session', :purple)}", value: :delete }
172
+ choices << { name: "🔄 #{Prompts.rgb_color('Refresh', :purple)}", value: :refresh }
173
+ choices << { name: "← #{Prompts.rgb_color('Back', :purple)}", value: :back }
174
+ choices
175
+ end
176
+
177
+ def select_with_auto_refresh(choices, session)
178
+ if AUTO_REFRESH_STATES.include?(session.state)
179
+ puts Prompts.rgb_color(" ⏱️ Auto-refresh in #{AUTO_REFRESH_INTERVAL}s (press any key for menu)", :purple)
180
+ puts
181
+ key = @prompt.keypress(timeout: AUTO_REFRESH_INTERVAL)
182
+ return :refresh unless key
183
+ end
184
+
185
+ @prompt.select(Prompts.rgb_color('Action:', :lavender), choices, cycle: true)
186
+ end
187
+
188
+ def handle_session_action(action, session)
189
+ case action
190
+ when :approve then approve_plan(session)
191
+ when :message then send_message(session)
192
+ when :activities
193
+ view_activities(session)
194
+ :refresh
195
+ when :open_url
196
+ system('open', session.url) if session.url
197
+ nil
198
+ when :delete then delete_session?(session) ? :deleted : nil
199
+ when :refresh then refresh_session(session)
200
+ when :back then :back
201
+ end
202
+ end
203
+
204
+ def approve_plan(session)
205
+ Prompts.with_spinner('Approving plan...') do
206
+ @client.sessions.approve_plan(session.name)
207
+ end
208
+ puts Prompts.rgb_color("\n ✅ Plan approved!", :purple)
209
+ @prompt.keypress(Prompts.rgb_color('Press any key to continue...', :dim))
210
+ @client.sessions.find(session.name)
211
+ end
212
+
213
+ def send_message(session)
214
+ msg = @prompt.ask(Prompts.rgb_color('Message to send:', :lavender))
215
+ return unless msg && !msg.empty?
216
+
217
+ Prompts.with_spinner('Sending message...') do
218
+ @client.sessions.send_message(session.name, prompt: msg)
219
+ end
220
+ puts Prompts.rgb_color("\n ✅ Message sent!", :purple)
221
+ @prompt.keypress(Prompts.rgb_color('Press any key to continue...', :dim))
222
+ @client.sessions.find(session.name)
223
+ end
224
+
225
+ def view_activities(session)
226
+ Prompts.clear_screen
227
+ Prompts.print_banner
228
+
229
+ activities = Prompts.with_spinner('Loading activities...') { @client.activities.all(session.name) }
230
+
231
+ if activities.empty?
232
+ @prompt.warn(Prompts.rgb_color('No activities found.', :purple))
233
+ @prompt.keypress(Prompts.rgb_color('Press any key to continue...', :dim))
234
+ return
235
+ end
236
+
237
+ activities.each { |activity| @activity_renderer.render(activity) }
238
+ puts
239
+ @prompt.keypress(Prompts.rgb_color('Press any key to continue...', :dim))
240
+ end
241
+
242
+ def delete_session?(session)
243
+ return false unless @prompt.yes?(
244
+ Prompts.rgb_color("Are you sure you want to delete session #{session.id}?", :lavender), default: false
245
+ )
246
+
247
+ Prompts.with_spinner('Deleting session...') do
248
+ @client.sessions.destroy(session.name)
249
+ end
250
+ puts Prompts.rgb_color("\n ✅ Session deleted!", :purple)
251
+ @prompt.keypress(Prompts.rgb_color('Press any key to continue...', :dim))
252
+ true
253
+ end
254
+
255
+ def refresh_session(session)
256
+ Prompts.with_spinner('Refreshing...') do
257
+ @client.sessions.find(session.name)
258
+ end
259
+ end
260
+
261
+ def update_session_state(session, result, needs_activity_fetch)
262
+ if result.is_a?(JulesRuby::Models::Session)
263
+ [result, true]
264
+ elsif result == :refresh
265
+ [session, true]
266
+ else
267
+ [session, needs_activity_fetch]
268
+ end
269
+ end
270
+
271
+ def wrap_text(text, width = 76)
272
+ return '' unless text
273
+
274
+ text.gsub(/(.{1,#{width}})(\s+|$)/, "\\1\n").strip
275
+ end
276
+
277
+ def truncate(text, length)
278
+ return '' unless text
279
+ return text if text.length <= length
280
+
281
+ "#{text.slice(0, length)}..."
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../prompts'
4
+
5
+ module JulesRuby
6
+ class Interactive
7
+ # Manages viewing sources
8
+ class SourceManager
9
+ def initialize(client, prompt)
10
+ @client = client
11
+ @prompt = prompt
12
+ end
13
+
14
+ def run
15
+ Prompts.clear_screen
16
+ Prompts.print_banner
17
+
18
+ sources = fetch_sources
19
+ return if sources.empty?
20
+
21
+ source = select_source_to_view(sources)
22
+ return if source == :back
23
+
24
+ display_source_details(source)
25
+ end
26
+
27
+ private
28
+
29
+ def fetch_sources
30
+ sources = Prompts.with_spinner('Loading sources...') { @client.sources.all }
31
+ if sources.empty?
32
+ @prompt.warn(Prompts.rgb_color('No sources found.', :purple))
33
+ @prompt.keypress(Prompts.rgb_color('Press any key to continue...', :dim))
34
+ end
35
+ sources
36
+ end
37
+
38
+ def select_source_to_view(sources)
39
+ choices = sources.map { |s| Prompts.format_source_choice(s) }
40
+ choices << { name: "← #{Prompts.rgb_color('Back to main menu', :purple)}", value: :back }
41
+
42
+ @prompt.select(
43
+ Prompts.rgb_color('Select a source to view:', :lavender),
44
+ choices,
45
+ filter: true,
46
+ per_page: 15,
47
+ cycle: true
48
+ )
49
+ end
50
+
51
+ def display_source_details(source)
52
+ puts
53
+ puts " #{Prompts.rgb_color('Name:', :lavender)} #{Prompts.rgb_color(source.name, :purple)}"
54
+ puts " #{Prompts.rgb_color('ID:', :lavender)} #{Prompts.rgb_color(source.id, :purple)}"
55
+
56
+ repo_label = Prompts.rgb_color('Repository:', :lavender)
57
+ repo_val = Prompts.rgb_color(source.github_repo&.full_name, :purple)
58
+ puts " #{repo_label} #{repo_val}"
59
+ puts
60
+
61
+ @prompt.keypress(Prompts.rgb_color('Press any key to continue...', :dim))
62
+ end
63
+ end
64
+ end
65
+ end