anima-core 0.1.0 → 0.2.0

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.
@@ -11,29 +11,32 @@ module TUI
11
11
  ROLE_ASSISTANT = "assistant"
12
12
  ROLE_LABELS = {ROLE_USER => "You", ROLE_ASSISTANT => "Anima"}.freeze
13
13
 
14
- attr_reader :input, :message_collector, :session
14
+ SCROLL_STEP = 1
15
+ MOUSE_SCROLL_STEP = 2
15
16
 
16
- def initialize(message_collector: nil, persister: nil, session: nil, shell_session: nil)
17
- @message_collector = message_collector || Events::Subscribers::MessageCollector.new
17
+ attr_reader :input, :message_store, :scroll_offset, :session_info
18
+
19
+ # @param cable_client [TUI::CableClient] WebSocket client connected to the brain
20
+ # @param message_store [TUI::MessageStore, nil] injectable for testing
21
+ def initialize(cable_client:, message_store: nil)
22
+ @cable_client = cable_client
23
+ @message_store = message_store || MessageStore.new
18
24
  @input = ""
19
25
  @loading = false
20
- @client = nil
21
- @submit_thread = nil
22
-
23
- @session = session || Session.order(id: :desc).first || Session.create!
24
- load_session_messages
25
- @persister = persister || Events::Subscribers::Persister.new(@session)
26
- @shell_session = shell_session || ShellSession.new(session_id: @session.id)
27
-
28
- Events::Bus.subscribe(@message_collector)
29
- Events::Bus.subscribe(@persister)
26
+ @scroll_offset = 0
27
+ @auto_scroll = true
28
+ @visible_height = 0
29
+ @max_scroll = 0
30
+ @session_info = {id: cable_client.session_id, message_count: 0}
30
31
  end
31
32
 
32
33
  def messages
33
- @message_collector.messages
34
+ @message_store.messages
34
35
  end
35
36
 
36
37
  def render(frame, area, tui)
38
+ process_incoming_messages
39
+
37
40
  chat_area, input_area = tui.split(
38
41
  area,
39
42
  direction: :vertical,
@@ -47,7 +50,10 @@ module TUI
47
50
  render_input(frame, input_area, tui)
48
51
  end
49
52
 
53
+ # Scrolling bypasses the loading guard so users can read chat history during LLM calls
50
54
  def handle_event(event)
55
+ return handle_mouse_event(event) if event.mouse?
56
+ return handle_scroll_key(event) if scroll_key?(event)
51
57
  return false if @loading
52
58
 
53
59
  if event.enter?
@@ -64,23 +70,15 @@ module TUI
64
70
  end
65
71
  end
66
72
 
73
+ # Creates a new session through the WebSocket protocol.
74
+ # The brain creates the session, switches the channel stream, and sends
75
+ # a session_changed signal followed by (empty) history. The client-side
76
+ # state reset happens when session_changed is received.
67
77
  def new_session
68
- @submit_thread&.join
69
- @shell_session&.finalize
70
- @session = Session.create!
71
- @persister.session = @session
72
- @message_collector.clear
73
- @input = ""
74
- @loading = false
75
- @shell_session = ShellSession.new(session_id: @session.id)
76
- @registry = nil
78
+ @cable_client.create_session
77
79
  end
78
80
 
79
81
  def finalize
80
- @submit_thread&.join
81
- @shell_session&.finalize
82
- Events::Bus.unsubscribe(@message_collector)
83
- Events::Bus.unsubscribe(@persister)
84
82
  end
85
83
 
86
84
  def loading?
@@ -89,6 +87,63 @@ module TUI
89
87
 
90
88
  private
91
89
 
90
+ # Drains the WebSocket message queue and feeds events to the message store
91
+ def process_incoming_messages
92
+ @cable_client.drain_messages.each do |msg|
93
+ action = msg["action"]
94
+ type = msg["type"]
95
+
96
+ case action
97
+ when "session_changed"
98
+ handle_session_changed(msg)
99
+ when "sessions_list"
100
+ @sessions_list = msg["sessions"]
101
+ when "error"
102
+ # Silently ignored — no user-facing error display yet
103
+ else
104
+ case type
105
+ when "connection"
106
+ handle_connection_status(msg)
107
+ when "user_message"
108
+ @message_store.process_event(msg)
109
+ @session_info[:message_count] += 1
110
+ @loading = true
111
+ when "agent_message"
112
+ @message_store.process_event(msg)
113
+ @session_info[:message_count] += 1
114
+ @loading = false
115
+ else
116
+ @message_store.process_event(msg)
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ # Reacts to connection lifecycle changes from the WebSocket client.
123
+ # Clears stale state on (re)subscription so fresh history from the server
124
+ # replaces any messages displayed before the disconnect.
125
+ def handle_connection_status(msg)
126
+ case msg["status"]
127
+ when "subscribed"
128
+ @message_store.clear
129
+ @loading = false
130
+ @session_info[:message_count] = 0
131
+ when "disconnected", "failed"
132
+ @loading = false
133
+ end
134
+ end
135
+
136
+ def handle_session_changed(msg)
137
+ new_id = msg["session_id"]
138
+ @cable_client.update_session_id(new_id)
139
+ @message_store.clear
140
+ @session_info = {id: new_id, message_count: msg["message_count"] || 0}
141
+ @input = ""
142
+ @loading = false
143
+ @scroll_offset = 0
144
+ @auto_scroll = true
145
+ end
146
+
92
147
  def render_messages(frame, area, tui)
93
148
  lines = build_message_lines(tui)
94
149
 
@@ -104,9 +159,21 @@ module TUI
104
159
  ])
105
160
  end
106
161
 
162
+ inner_width = [area.width - 2, 1].max
163
+ @visible_height = [area.height - 2, 0].max
164
+
165
+ content_widget = tui.paragraph(text: lines, wrap: true, style: tui.style(fg: "white"))
166
+ content_height = content_widget.line_count(inner_width)
167
+
168
+ @max_scroll = [content_height - @visible_height, 0].max
169
+ @scroll_offset = @max_scroll if @auto_scroll
170
+ @scroll_offset = @scroll_offset.clamp(0, @max_scroll)
171
+
107
172
  widget = tui.paragraph(
108
173
  text: lines,
109
174
  wrap: true,
175
+ style: tui.style(fg: "white"),
176
+ scroll: [@scroll_offset, 0],
110
177
  block: tui.block(
111
178
  title: "Chat",
112
179
  borders: [:all],
@@ -115,6 +182,18 @@ module TUI
115
182
  )
116
183
  )
117
184
  frame.render_widget(widget, area)
185
+
186
+ if @max_scroll > 0
187
+ scrollbar = tui.scrollbar(
188
+ content_length: @max_scroll,
189
+ position: @scroll_offset,
190
+ orientation: :vertical_right,
191
+ thumb_style: {fg: "cyan"},
192
+ track_symbol: "│",
193
+ track_style: {fg: "dark_gray"}
194
+ )
195
+ frame.render_widget(scrollbar, area)
196
+ end
118
197
  end
119
198
 
120
199
  def build_message_lines(tui)
@@ -126,29 +205,38 @@ module TUI
126
205
  end
127
206
 
128
207
  label = ROLE_LABELS.fetch(msg[:role], msg[:role])
208
+ content_lines = msg[:content].to_s.split("\n", -1)
129
209
 
130
- [
131
- tui.line(spans: [
132
- tui.span(content: "#{label}: ", style: role_style),
133
- tui.span(content: msg[:content], style: tui.style(fg: "white"))
134
- ]),
135
- tui.line(spans: [tui.span(content: "", style: tui.style(fg: "white"))])
136
- ]
210
+ lines = [tui.line(spans: [
211
+ tui.span(content: "#{label}: ", style: role_style),
212
+ tui.span(content: content_lines.first.to_s)
213
+ ])]
214
+ content_lines.drop(1).each { |text| lines << tui.line(spans: [tui.span(content: text)]) }
215
+ lines << tui.line(spans: [tui.span(content: "")])
137
216
  end
138
217
  end
139
218
 
140
219
  def render_input(frame, area, tui)
141
- cursor = @loading ? "" : "\u2588"
142
- border_style = @loading ? {fg: "dark_gray"} : {fg: "green"}
143
- text_style = @loading ? tui.style(fg: "dark_gray") : tui.style(fg: "white")
220
+ disabled = @loading || !connected?
221
+ cursor = disabled ? "" : "\u2588"
222
+ border_style = disabled ? {fg: "dark_gray"} : {fg: "green"}
223
+ text_style = disabled ? tui.style(fg: "dark_gray") : tui.style(fg: "white")
224
+
225
+ title = if @loading
226
+ "Waiting..."
227
+ elsif !connected?
228
+ "Disconnected"
229
+ else
230
+ "Input"
231
+ end
144
232
 
145
233
  widget = tui.paragraph(
146
234
  text: tui.line(spans: [
147
235
  tui.span(content: "> #{@input}#{cursor}", style: text_style)
148
236
  ]),
149
237
  block: tui.block(
150
- title: @loading ? "Waiting..." : "Input",
151
- titles: @loading ? [] : [
238
+ title: title,
239
+ titles: disabled ? [] : [
152
240
  {content: "Enter send", position: :bottom, alignment: :center}
153
241
  ],
154
242
  borders: [:all],
@@ -162,42 +250,65 @@ module TUI
162
250
  def submit_message
163
251
  text = @input.strip
164
252
  return if text.empty?
253
+ return unless @cable_client.status == :subscribed
165
254
 
166
- Events::Bus.emit(Events::UserMessage.new(content: text, session_id: @session.id))
167
255
  @input = ""
168
- @loading = true
169
-
170
- @submit_thread = Thread.new do
171
- @client ||= LLM::Client.new
172
- @registry ||= build_tool_registry
173
- viewport_messages = @session.messages_for_llm
174
- response = @client.chat_with_tools(
175
- viewport_messages,
176
- registry: @registry,
177
- session_id: @session.id
178
- )
179
- Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
180
- rescue => e
181
- Events::Bus.emit(Events::AgentMessage.new(content: "Error: #{e.message}", session_id: @session.id))
182
- ensure
183
- @loading = false
184
- end
256
+ @cable_client.speak(text)
185
257
  end
186
258
 
187
- def build_tool_registry
188
- registry = Tools::Registry.new(context: {shell_session: @shell_session})
189
- registry.register(Tools::WebGet)
190
- registry.register(Tools::Bash)
191
- registry
259
+ # @return [Boolean] whether the event is an arrow or page key used for scrolling
260
+ def scroll_key?(event)
261
+ event.up? || event.down? || event.page_up? || event.page_down?
192
262
  end
193
263
 
194
- def load_session_messages
195
- @session.events.where(event_type: Events::Subscribers::MessageCollector::DISPLAYABLE_TYPES).each do |event|
196
- @message_collector.messages_push({
197
- role: Events::Subscribers::MessageCollector::ROLE_MAP.fetch(event.event_type),
198
- content: event.payload["content"].to_s
199
- })
264
+ # Dispatches scroll key events to {#scroll_up} or {#scroll_down}
265
+ # @return [true] always redraws after scrolling
266
+ def handle_scroll_key(event)
267
+ if event.up?
268
+ scroll_up(SCROLL_STEP)
269
+ elsif event.down?
270
+ scroll_down(SCROLL_STEP)
271
+ elsif event.page_up?
272
+ scroll_up(@visible_height)
273
+ elsif event.page_down?
274
+ scroll_down(@visible_height)
200
275
  end
276
+ true
277
+ end
278
+
279
+ # Handles mouse wheel scroll events; ignores other mouse events
280
+ # @return [Boolean] true if the event was a scroll wheel event
281
+ def handle_mouse_event(event)
282
+ if event.scroll_up?
283
+ scroll_up(MOUSE_SCROLL_STEP)
284
+ true
285
+ elsif event.scroll_down?
286
+ scroll_down(MOUSE_SCROLL_STEP)
287
+ true
288
+ else
289
+ false
290
+ end
291
+ end
292
+
293
+ # Scrolls the viewport up, clamping at the top.
294
+ # Disables auto-scroll when the user moves away from the bottom.
295
+ # @param lines [Integer] number of lines to scroll
296
+ def scroll_up(lines)
297
+ @scroll_offset = [@scroll_offset - lines, 0].max
298
+ @auto_scroll = @scroll_offset >= @max_scroll
299
+ end
300
+
301
+ # Scrolls the viewport down, clamping at max_scroll.
302
+ # Re-enables auto-scroll when the user reaches the bottom.
303
+ # @param lines [Integer] number of lines to scroll
304
+ def scroll_down(lines)
305
+ @scroll_offset = [@scroll_offset + lines, @max_scroll].min
306
+ @auto_scroll = @scroll_offset >= @max_scroll
307
+ end
308
+
309
+ # @return [Boolean] true when WebSocket is fully subscribed and ready
310
+ def connected?
311
+ @cable_client.status == :subscribed
201
312
  end
202
313
 
203
314
  def printable_char?(event)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: anima-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yevhenii Hurin
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: foreman
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.88'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.88'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: httparty
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -23,6 +37,20 @@ dependencies:
23
37
  - - "~>"
24
38
  - !ruby/object:Gem::Version
25
39
  version: '0.24'
40
+ - !ruby/object:Gem::Dependency
41
+ name: puma
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '6.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '6.0'
26
54
  - !ruby/object:Gem::Dependency
27
55
  name: rails
28
56
  requirement: !ruby/object:Gem::Requirement
@@ -51,6 +79,20 @@ dependencies:
51
79
  - - "~>"
52
80
  - !ruby/object:Gem::Version
53
81
  version: '1.4'
82
+ - !ruby/object:Gem::Dependency
83
+ name: solid_cable
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.0'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.0'
54
96
  - !ruby/object:Gem::Dependency
55
97
  name: solid_queue
56
98
  requirement: !ruby/object:Gem::Requirement
@@ -79,6 +121,20 @@ dependencies:
79
121
  - - "~>"
80
122
  - !ruby/object:Gem::Version
81
123
  version: '2.0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: websocket-client-simple
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '0.8'
131
+ type: :runtime
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '0.8'
82
138
  email:
83
139
  - evgeny.gurin@gmail.com
84
140
  executables:
@@ -89,25 +145,43 @@ files:
89
145
  - ".mise.toml"
90
146
  - ".reek.yml"
91
147
  - CHANGELOG.md
148
+ - Gemfile
92
149
  - LICENSE.txt
150
+ - Procfile
151
+ - Procfile.dev
93
152
  - README.md
94
153
  - Rakefile
154
+ - anima-core.gemspec
155
+ - app/channels/application_cable/channel.rb
156
+ - app/channels/application_cable/connection.rb
157
+ - app/channels/session_channel.rb
158
+ - app/controllers/api/sessions_controller.rb
159
+ - app/controllers/application_controller.rb
160
+ - app/jobs/agent_request_job.rb
95
161
  - app/jobs/application_job.rb
96
162
  - app/jobs/count_event_tokens_job.rb
97
163
  - app/models/application_record.rb
98
164
  - app/models/event.rb
99
165
  - app/models/session.rb
166
+ - bin/jobs
167
+ - bin/rails
168
+ - bin/rake
169
+ - config.ru
100
170
  - config/application.rb
101
171
  - config/boot.rb
172
+ - config/cable.yml
102
173
  - config/database.yml
103
174
  - config/environment.rb
104
175
  - config/environments/development.rb
105
176
  - config/environments/production.rb
106
177
  - config/environments/test.rb
178
+ - config/initializers/event_subscribers.rb
107
179
  - config/initializers/inflections.rb
180
+ - config/puma.rb
108
181
  - config/queue.yml
109
182
  - config/recurring.yml
110
183
  - config/routes.rb
184
+ - db/cable_schema.rb
111
185
  - db/migrate/.keep
112
186
  - db/migrate/20260308124202_create_sessions.rb
113
187
  - db/migrate/20260308124203_create_events.rb
@@ -118,6 +192,7 @@ files:
118
192
  - db/queue_schema.rb
119
193
  - db/seeds.rb
120
194
  - exe/anima
195
+ - lib/agent_loop.rb
121
196
  - lib/anima.rb
122
197
  - lib/anima/cli.rb
123
198
  - lib/anima/installer.rb
@@ -126,6 +201,7 @@ files:
126
201
  - lib/events/base.rb
127
202
  - lib/events/bus.rb
128
203
  - lib/events/subscriber.rb
204
+ - lib/events/subscribers/action_cable_bridge.rb
129
205
  - lib/events/subscribers/message_collector.rb
130
206
  - lib/events/subscribers/persister.rb
131
207
  - lib/events/system_message.rb
@@ -140,6 +216,8 @@ files:
140
216
  - lib/tools/registry.rb
141
217
  - lib/tools/web_get.rb
142
218
  - lib/tui/app.rb
219
+ - lib/tui/cable_client.rb
220
+ - lib/tui/message_store.rb
143
221
  - lib/tui/screens/anthropic.rb
144
222
  - lib/tui/screens/chat.rb
145
223
  - lib/tui/screens/settings.rb
@@ -166,6 +244,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
166
244
  requirements: []
167
245
  rubygems_version: 3.6.9
168
246
  specification_version: 4
169
- summary: Ruby framework for building AI agents with desires, personality, and personal
170
- growth
247
+ summary: A personal AI agent with desires, personality, and personal growth
171
248
  test_files: []