ruby_rich 0.4.0 → 0.4.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c81d8c96218da776dce71a58d8c6ab5065c106a0a6818ea7d73f16fb877e4e5c
4
- data.tar.gz: f01f062343bcc4187861d2a984db97d624d2d810fd90f5f5720b446ad16fecaa
3
+ metadata.gz: a6389b429fa73b0aa3057ed437aa50e051bf1e9b7775a2f5ee4d0860fe1f2dbe
4
+ data.tar.gz: a0a2030db498d207fa27e4ba370da391afe654b2ba8c1998973a644c3ee324f3
5
5
  SHA512:
6
- metadata.gz: c43c87d6fe89f77962a9a4ddfd5148b5ff97c1c81e5435ff39eac99ecdfa36efb270818db5aef4869b6d4feb947c7b859721d8f08aafea82a2fb582b4d0ca265
7
- data.tar.gz: 6aa8944f0096c5af0ed9172a2e6fc85a97f97662b3ce7f54a3d55cf035686fba3a4ab98ed70f52411b3a2dfa320d2bc943e47ed3cbed7075965c5e5147ae0794
6
+ metadata.gz: bf56a855f9292e8eb03cbe2e5d71b0bef6be0de091b91f070c00a7cee14b989e50fc7ae98455f506e6667b2f3e846dc7e6bd0ff73653fc04f84fcd6fb3039a54
7
+ data.tar.gz: 0566e96e17f22b5499d867eebfb18d5519cda6336c4bc26531c0212eb763dd4df7aa90a362388bc31d837c0fbe2b3243873e341eb76a4b2d63135a936e79cbd6
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+
5
+ module RubyRich
6
+ class AgentShell < AppShell
7
+ attr_reader :mode
8
+
9
+ def initialize(**options)
10
+ @callbacks = {}
11
+ @state_mutex = Mutex.new
12
+ @id_mutex = Mutex.new
13
+ @pending_actions = Queue.new
14
+ @entry_sequence = 0
15
+ @state = :initialized
16
+ @ui_thread = nil
17
+ @mode = :chat
18
+ super
19
+ @stop_on_ctrl_c = false
20
+ @composer.instance_variable_set(:@on_interrupt, method(:handle_interrupt))
21
+ @composer.instance_variable_set(:@on_eof, method(:handle_eof))
22
+ attach_agent_controls
23
+ end
24
+
25
+ def on_submit(&block)
26
+ @callbacks[:submit] = block
27
+ self
28
+ end
29
+
30
+ def on_interrupt(&block)
31
+ @callbacks[:interrupt] = block
32
+ self
33
+ end
34
+
35
+ def on_mode_toggle(&block)
36
+ @callbacks[:mode_toggle] = block
37
+ self
38
+ end
39
+
40
+ def on_command(&block)
41
+ @callbacks[:command] = block
42
+ self
43
+ end
44
+
45
+ def start(refresh_rate: 24, mouse: true, alt_screen: false)
46
+ @state_mutex.synchronize { @state = :starting }
47
+ Live.start(@layout, refresh_rate: refresh_rate, mouse: mouse, alt_screen: alt_screen, autowrap: false) do |live|
48
+ @state_mutex.synchronize do
49
+ @live = live
50
+ @ui_thread = Thread.current
51
+ @state = :running
52
+ end
53
+ drain_pending_actions(live)
54
+ live.listening = true
55
+ end
56
+ ensure
57
+ @state_mutex.synchronize do
58
+ @live = nil
59
+ @ui_thread = nil
60
+ @state = :stopped
61
+ end
62
+ end
63
+
64
+ def stop
65
+ dispatch_after_start_failure = false
66
+ @state_mutex.synchronize do
67
+ return false if @state == :stopped
68
+
69
+ if @live
70
+ return @live.stop if Thread.current == @ui_thread
71
+
72
+ return @live.post { |live| live.stop } || false
73
+ end
74
+
75
+ @state = :stopped
76
+ dispatch_after_start_failure = true
77
+ end
78
+ dispatch_after_start_failure
79
+ end
80
+
81
+ def add_user_message(text)
82
+ add_message(:user, text)
83
+ end
84
+
85
+ def add_assistant_message(text, streaming: false)
86
+ add_message(:assistant, text, streaming: streaming)
87
+ end
88
+
89
+ def add_markdown(content, streaming: false)
90
+ add_message(:markdown, content, streaming: streaming)
91
+ end
92
+
93
+ def add_system_message(text)
94
+ add_message(:system, text)
95
+ end
96
+
97
+ def add_error_message(text)
98
+ add_message(:error, text)
99
+ end
100
+
101
+ def add_diff(title: nil, content:, language: "diff")
102
+ text = title ? "#{title}\n#{content}" : content
103
+ add_message(:diff, text, language: language)
104
+ end
105
+
106
+ def append_to_message(id, delta)
107
+ dispatch { @transcript.append_block(id, delta).tap { @viewport.scroll_to_bottom } }
108
+ end
109
+
110
+ def replace_message(id, text)
111
+ dispatch { @transcript.replace_block(id, text).tap { @viewport.scroll_to_bottom } }
112
+ end
113
+
114
+ def remove_entry(id)
115
+ dispatch { @transcript.remove_block(id).tap { @viewport.scroll_to_bottom } }
116
+ end
117
+
118
+ def start_tool_call(name:, input: nil, status: :running)
119
+ id = reserve_id(:tool)
120
+ return nil unless id
121
+
122
+ ok = dispatch do
123
+ @transcript.add_tool(name, status: status, result: tool_body(input: input), collapsed: false, id: id)
124
+ @viewport.scroll_to_bottom
125
+ id
126
+ end
127
+ ok ? id : nil
128
+ end
129
+
130
+ def update_tool_call(id, status: nil, output: nil, input: nil)
131
+ options = {}
132
+ options[:status] = status if status
133
+ text = output.nil? && input.nil? ? nil : tool_body(input: input, output: output)
134
+ dispatch do
135
+ block = @transcript.find_block(id)
136
+ next false unless block
137
+
138
+ text ||= block[:text]
139
+ @transcript.replace_block(id, text, **options).tap { @viewport.scroll_to_bottom }
140
+ end
141
+ end
142
+
143
+ def finish_tool_call(id, status: :done, output: nil)
144
+ update_tool_call(id, status: status, output: output)
145
+ end
146
+
147
+ def update_tasks(tasks)
148
+ dispatch { @sidebar.set_tasks(tasks) }
149
+ end
150
+
151
+ def update_status(text)
152
+ dispatch { @status = text.to_s }
153
+ end
154
+
155
+ def show_token_usage(input: nil, output: nil, total: nil, **extra)
156
+ dispatch { @token_usage = { input: input, output: output, total: total }.merge(extra).compact }
157
+ end
158
+
159
+ def stopped?
160
+ @state_mutex.synchronize { @state == :stopped }
161
+ end
162
+
163
+ private
164
+
165
+ def add_message(type, text, **options)
166
+ id = reserve_id(type)
167
+ return nil unless id
168
+
169
+ ok = dispatch do
170
+ @transcript.add_block(type, text, **options, id: id)
171
+ @viewport.scroll_to_bottom
172
+ id
173
+ end
174
+ ok ? id : nil
175
+ end
176
+
177
+ def reserve_id(type)
178
+ @state_mutex.synchronize { return nil if @state == :stopped }
179
+
180
+ @id_mutex.synchronize do
181
+ @entry_sequence += 1
182
+ "#{type}-#{@entry_sequence}"
183
+ end
184
+ end
185
+
186
+ def dispatch
187
+ state, live, ui_thread = @state_mutex.synchronize { [@state, @live, @ui_thread] }
188
+ return false if state == :stopped
189
+
190
+ if live
191
+ return yield if Thread.current == ui_thread
192
+
193
+ return live.post { yield }
194
+ end
195
+
196
+ if state == :starting
197
+ @pending_actions << proc { yield }
198
+ return true
199
+ end
200
+
201
+ yield
202
+ end
203
+
204
+ def drain_pending_actions(live)
205
+ @pending_actions.pop(true).call(live) until @pending_actions.empty?
206
+ rescue ThreadError
207
+ nil
208
+ end
209
+
210
+ def attach_agent_controls
211
+ @layout.key(:ctrl_c, 2_000) do |_event, live|
212
+ handle_interrupt(live, self)
213
+ false
214
+ end
215
+
216
+ @layout.key(:ctrl_m, 2_000) do |_event, _live|
217
+ toggle_mode
218
+ false
219
+ end
220
+ end
221
+
222
+ def handle_interrupt(_live = nil, _source = nil)
223
+ @callbacks[:interrupt]&.call(input_was_empty: @composer.value.to_s.empty?)
224
+ end
225
+
226
+ def handle_eof(live = nil, _source = nil)
227
+ @callbacks[:eof]&.call if @callbacks[:eof]
228
+ live&.stop
229
+ end
230
+
231
+ def handle_submit(value, live, attachments = [])
232
+ text = value.to_s
233
+ if text.start_with?("/")
234
+ command = text.split(/\s+/, 2).first
235
+ @callbacks[:command]&.call(command)
236
+ end
237
+
238
+ @callbacks[:submit]&.call(text, attachments)
239
+ end
240
+
241
+ def toggle_mode
242
+ @mode = @mode == :chat ? :agent : :chat
243
+ @callbacks[:mode_toggle]&.call(@mode)
244
+ @status = "mode · #{@mode}"
245
+ end
246
+
247
+ def tool_body(input: nil, output: nil)
248
+ parts = []
249
+ parts << "input:\n#{input}" unless input.nil? || input.to_s.empty?
250
+ parts << "output:\n#{output}" unless output.nil? || output.to_s.empty?
251
+ parts.join("\n")
252
+ end
253
+ end
254
+ end
@@ -62,10 +62,14 @@ module RubyRich
62
62
  }
63
63
 
64
64
  def self.reset
65
+ return "" unless color_enabled?
66
+
65
67
  ANSI_CODES[:reset]
66
68
  end
67
69
 
68
70
  def self.color(color, bright=false)
71
+ return "" unless color_enabled?
72
+
69
73
  if bright
70
74
  "\e[#{ANSI_CODES[:bright_color][color]}m"
71
75
  else
@@ -74,6 +78,8 @@ module RubyRich
74
78
  end
75
79
 
76
80
  def self.background(color, bright=false)
81
+ return "" unless color_enabled?
82
+
77
83
  if bright
78
84
  "\e[#{ANSI_CODES[:bright_background][color]}m"
79
85
  else
@@ -82,14 +88,26 @@ module RubyRich
82
88
  end
83
89
 
84
90
  def self.bold
91
+ return "" unless color_enabled?
92
+
85
93
  "\e[#{ANSI_CODES[:bold]}m"
86
94
  end
87
95
 
96
+ def self.faint
97
+ return "" unless color_enabled?
98
+
99
+ "\e[#{ANSI_CODES[:faint]}m"
100
+ end
101
+
88
102
  def self.italic
103
+ return "" unless color_enabled?
104
+
89
105
  "\e[#{ANSI_CODES[:italic]}m"
90
106
  end
91
107
 
92
108
  def self.underline(style=nil)
109
+ return "" unless color_enabled?
110
+
93
111
  case style
94
112
  when nil
95
113
  return "\e[#{ANSI_CODES[:underline]}m"
@@ -105,38 +123,56 @@ module RubyRich
105
123
  end
106
124
 
107
125
  def self.blink
126
+ return "" unless color_enabled?
127
+
108
128
  "\e[#{ANSI_CODES[:blink]}m"
109
129
  end
110
130
 
111
131
  def self.rapid_blink
132
+ return "" unless color_enabled?
133
+
112
134
  "\e[#{ANSI_CODES[:rapid_blink]}m"
113
135
  end
114
136
 
115
137
  def self.inverse
138
+ return "" unless color_enabled?
139
+
116
140
  "\e[#{ANSI_CODES[:inverse]}m"
117
141
  end
118
142
 
119
143
  def self.fraktur
144
+ return "" unless color_enabled?
145
+
120
146
  "\e[#{ANSI_CODES[:fraktur]}m"
121
147
  end
122
148
 
123
149
  def self.invisible
150
+ return "" unless color_enabled?
151
+
124
152
  "\e[#{ANSI_CODES[:invisible]}m"
125
153
  end
126
154
 
127
155
  def self.strikethrough
156
+ return "" unless color_enabled?
157
+
128
158
  "\e[#{ANSI_CODES[:strikethrough]}m"
129
159
  end
130
160
 
131
161
  def self.overline
162
+ return "" unless color_enabled?
163
+
132
164
  "\e[#{ANSI_CODES[:overline]}m"
133
165
  end
134
166
 
135
167
  def self.no_blink
168
+ return "" unless color_enabled?
169
+
136
170
  "\e[#{ANSI_CODES[:no_blink]}m"
137
171
  end
138
172
 
139
173
  def self.no_inverse
174
+ return "" unless color_enabled?
175
+
140
176
  "\e[#{ANSI_CODES[:no_inverse]}m"
141
177
  end
142
178
 
@@ -151,6 +187,8 @@ module RubyRich
151
187
  strikethrough: false,
152
188
  overline: false
153
189
  )
190
+ return "" unless color_enabled?
191
+
154
192
  code = if font_bright
155
193
  "\e[#{ANSI_CODES[:bright_color][font_color]}"
156
194
  else
@@ -191,5 +229,13 @@ module RubyRich
191
229
  end
192
230
  return code+"m"
193
231
  end
232
+
233
+ def self.color_enabled?
234
+ ENV["NO_COLOR"].nil? && ENV["TERM"] != "dumb" && @color_enabled != false
235
+ end
236
+
237
+ def self.color_enabled=(enabled)
238
+ @color_enabled = enabled
239
+ end
194
240
  end
195
241
  end