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.
data/config/database.yml CHANGED
@@ -13,6 +13,10 @@ development:
13
13
  <<: *default
14
14
  database: <%= File.join(anima_home, "db", "development_queue.sqlite3") %>
15
15
  migrations_paths: db/queue_migrate
16
+ cable:
17
+ <<: *default
18
+ database: <%= File.join(anima_home, "db", "development_cable.sqlite3") %>
19
+ migrations_paths: db/cable_migrate
16
20
 
17
21
  test:
18
22
  primary:
@@ -22,6 +26,10 @@ test:
22
26
  <<: *default
23
27
  database: <%= File.join(anima_home, "db", "test_queue.sqlite3") %>
24
28
  migrations_paths: db/queue_migrate
29
+ cable:
30
+ <<: *default
31
+ database: <%= File.join(anima_home, "db", "test_cable.sqlite3") %>
32
+ migrations_paths: db/cable_migrate
25
33
 
26
34
  production:
27
35
  primary:
@@ -31,3 +39,7 @@ production:
31
39
  <<: *default
32
40
  database: <%= File.join(anima_home, "db", "production_queue.sqlite3") %>
33
41
  migrations_paths: db/queue_migrate
42
+ cable:
43
+ <<: *default
44
+ database: <%= File.join(anima_home, "db", "production_cable.sqlite3") %>
45
+ migrations_paths: db/cable_migrate
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Registers global EventBus subscribers at boot time.
4
+ # Subscribers registered here receive all events regardless of which
5
+ # process emitted them (brain server, background job, etc.).
6
+ Rails.application.config.after_initialize do
7
+ # Global persister handles events from all sessions (brain server, background jobs).
8
+ # Skipped in test — specs manage their own persisters for isolation.
9
+ Events::Bus.subscribe(Events::Subscribers::Persister.new) unless Rails.env.test?
10
+ Events::Bus.subscribe(Events::Subscribers::ActionCableBridge.instance)
11
+ end
data/config/puma.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Anima brain server — serves Action Cable WebSocket connections
4
+ # and health check endpoint. Port 42134 by default.
5
+
6
+ threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
7
+ threads threads_count, threads_count
8
+
9
+ port ENV.fetch("PORT", 42134)
10
+
11
+ pidfile ENV.fetch("PIDFILE", File.expand_path("~/.anima/tmp/pids/puma.pid"))
12
+
13
+ plugin :tmp_restart
data/config/routes.rb CHANGED
@@ -1,4 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Rails.application.routes.draw do
4
+ mount ActionCable.server => "/cable"
5
+ get "up", to: "rails/health#show", as: :rails_health_check
6
+
7
+ namespace :api do
8
+ resources :sessions, only: [:create] do
9
+ get :current, on: :collection
10
+ end
11
+ end
4
12
  end
data/config.ru ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config/environment"
4
+
5
+ run Rails.application
@@ -0,0 +1,11 @@
1
+ ActiveRecord::Schema[8.1].define(version: 1) do
2
+ create_table "solid_cable_messages", force: :cascade do |t|
3
+ t.binary "channel", limit: 1024, null: false
4
+ t.binary "payload", limit: 536870912, null: false
5
+ t.datetime "created_at", null: false
6
+ t.integer "channel_hash", limit: 8, null: false
7
+ t.index ["channel"], name: "index_solid_cable_messages_on_channel"
8
+ t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
9
+ t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
10
+ end
11
+ end
data/lib/agent_loop.rb ADDED
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Orchestrates the LLM agent loop: accepts user input, runs the tool-use
4
+ # cycle via {LLM::Client}, and emits events through {Events::Bus}.
5
+ #
6
+ # Extracted from {TUI::Screens::Chat} so the same agent logic can run from
7
+ # the TUI, a background job, or an Action Cable channel.
8
+ #
9
+ # @note Not thread-safe. Callers must serialize concurrent calls to {#process}
10
+ # (e.g. TUI uses a loading flag, future callers should use session-level locks).
11
+ #
12
+ # @example Basic usage
13
+ # loop = AgentLoop.new(session: session)
14
+ # loop.process("What files are in the current directory?")
15
+ # loop.finalize
16
+ #
17
+ # @example With dependency injection (testing)
18
+ # loop = AgentLoop.new(session: session, client: mock_client, registry: mock_registry)
19
+ # loop.process("hello")
20
+ #
21
+ # @example Background job usage (retry-safe)
22
+ # loop = AgentLoop.new(session: session)
23
+ # loop.run # processes persisted session messages without emitting UserMessage
24
+ # loop.finalize
25
+ class AgentLoop
26
+ # @return [Session] the conversation session this loop operates on
27
+ attr_reader :session
28
+
29
+ # @param session [Session] the conversation session
30
+ # @param shell_session [ShellSession, nil] injectable persistent shell;
31
+ # created automatically if not provided
32
+ # @param client [LLM::Client, nil] injectable LLM client;
33
+ # created lazily on first {#process} call if not provided
34
+ # @param registry [Tools::Registry, nil] injectable tool registry;
35
+ # built lazily on first {#process} call if not provided
36
+ def initialize(session:, shell_session: nil, client: nil, registry: nil)
37
+ @session = session
38
+ @shell_session = shell_session || ShellSession.new(session_id: session.id)
39
+ @client = client
40
+ @registry = registry
41
+ end
42
+
43
+ # Runs the agent loop for a single user input.
44
+ #
45
+ # Emits {Events::UserMessage} immediately, then delegates to {#run}.
46
+ # On error emits {Events::AgentMessage} with the error text.
47
+ #
48
+ # @param input [String] raw user input
49
+ # @return [String, nil] the agent's response text, or nil for blank input
50
+ def process(input)
51
+ text = input.to_s.strip
52
+ return if text.empty?
53
+
54
+ Events::Bus.emit(Events::UserMessage.new(content: text, session_id: @session.id))
55
+ run
56
+ rescue => error
57
+ error_message = "#{error.class}: #{error.message}"
58
+ Events::Bus.emit(Events::AgentMessage.new(content: error_message, session_id: @session.id))
59
+ error_message
60
+ end
61
+
62
+ # Runs the LLM tool-use loop on persisted session messages.
63
+ #
64
+ # Unlike {#process}, does not emit {Events::UserMessage} and lets errors
65
+ # propagate — designed for callers like {AgentRequestJob} that handle
66
+ # retries and need errors to bubble up.
67
+ #
68
+ # @return [String] the agent's response text
69
+ # @raise [Providers::Anthropic::TransientError] on retryable network/server errors
70
+ # @raise [Providers::Anthropic::AuthenticationError] on auth failures
71
+ def run
72
+ @client ||= LLM::Client.new
73
+ @registry ||= build_tool_registry
74
+
75
+ messages = @session.messages_for_llm
76
+ response = @client.chat_with_tools(messages, registry: @registry, session_id: @session.id)
77
+ Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
78
+ response
79
+ end
80
+
81
+ # Clean up the underlying {ShellSession} PTY and resources.
82
+ # Safe to call multiple times — subsequent calls are no-ops.
83
+ def finalize
84
+ @shell_session&.finalize
85
+ end
86
+
87
+ private
88
+
89
+ # Builds the default tool registry with all available tools.
90
+ # @return [Tools::Registry] registry with Bash and WebGet tools
91
+ def build_tool_registry
92
+ registry = Tools::Registry.new(context: {shell_session: @shell_session})
93
+ registry.register(Tools::WebGet)
94
+ registry.register(Tools::Bash)
95
+ registry
96
+ end
97
+ end
data/lib/anima/cli.rb CHANGED
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
+ require_relative "../anima"
4
5
 
5
6
  module Anima
6
7
  class CLI < Thor
7
8
  VALID_ENVIRONMENTS = %w[development test production].freeze
9
+ DEFAULT_PORT = 42134
10
+ DEFAULT_HOST = "localhost:#{DEFAULT_PORT}"
8
11
 
9
12
  def self.exit_on_failure?
10
13
  true
@@ -16,10 +19,13 @@ module Anima
16
19
  Installer.new.run
17
20
  end
18
21
 
19
- desc "start", "Boot Anima (runs pending migrations, then exits)"
20
- option :environment, aliases: "-e", default: "development", desc: "Rails environment"
22
+ # Start the Anima brain server (Puma + Solid Queue) via Foreman.
23
+ # Environment precedence: -e flag > RAILS_ENV env var > "development".
24
+ # Requires prior installation (~/.anima must exist).
25
+ desc "start", "Start Anima (web + workers)"
26
+ option :environment, aliases: "-e", desc: "Rails environment (default: $RAILS_ENV or development)"
21
27
  def start
22
- env = options[:environment]
28
+ env = options[:environment] || ENV.fetch("RAILS_ENV", "development")
23
29
  unless VALID_ENVIRONMENTS.include?(env)
24
30
  say "Invalid environment: #{env}. Must be one of: #{VALID_ENVIRONMENTS.join(", ")}", :red
25
31
  exit 1
@@ -32,17 +38,29 @@ module Anima
32
38
  exit 1
33
39
  end
34
40
 
35
- system(Anima.gem_root.join("bin/rails").to_s, "db:prepare") || abort("db:prepare failed")
36
- say "Anima booted successfully (#{env}).", :green
41
+ gem_root = Anima.gem_root
42
+ system(gem_root.join("bin/rails").to_s, "db:prepare", chdir: gem_root.to_s) || abort("db:prepare failed")
43
+ exec("foreman", "start", "-f", gem_root.join("Procfile").to_s, "-p", DEFAULT_PORT.to_s, chdir: gem_root.to_s)
37
44
  end
38
45
 
39
46
  desc "tui", "Launch the Anima terminal interface"
47
+ option :host, desc: "Brain server address (default: #{DEFAULT_HOST})"
40
48
  def tui
41
49
  require "ratatui_ruby"
42
- ENV["RAILS_ENV"] ||= "development"
43
- require_relative "../../config/environment"
44
- ActiveRecord::Tasks::DatabaseTasks.prepare_all
45
- TUI::App.new.run
50
+ require "net/http"
51
+ require "json"
52
+ require_relative "../tui/app"
53
+
54
+ host = options[:host] || DEFAULT_HOST
55
+
56
+ say "Connecting to brain at #{host}...", :cyan
57
+ session_id = fetch_current_session_with_retry(host)
58
+ say "Session ##{session_id} — starting TUI", :cyan
59
+
60
+ cable_client = TUI::CableClient.new(host: host, session_id: session_id)
61
+ cable_client.connect
62
+
63
+ TUI::App.new(cable_client: cable_client).run
46
64
  end
47
65
 
48
66
  desc "version", "Show version"
@@ -51,5 +69,42 @@ module Anima
51
69
  require_relative "version"
52
70
  say "anima #{Anima::VERSION}"
53
71
  end
72
+
73
+ private
74
+
75
+ MAX_SESSION_FETCH_ATTEMPTS = 10
76
+ SESSION_FETCH_DELAY = 2 # seconds between retries
77
+
78
+ # Fetches the current session ID from the brain's REST API.
79
+ # Retries up to {MAX_SESSION_FETCH_ATTEMPTS} times if the brain is not running.
80
+ #
81
+ # @param host [String] brain server address
82
+ # @return [Integer] session ID
83
+ def fetch_current_session_with_retry(host)
84
+ attempts = 0
85
+ begin
86
+ fetch_current_session(host)
87
+ rescue Errno::ECONNREFUSED, Net::ReadTimeout, Net::OpenTimeout, SocketError => error
88
+ attempts += 1
89
+ if attempts >= MAX_SESSION_FETCH_ATTEMPTS
90
+ say "Cannot connect to brain after #{MAX_SESSION_FETCH_ATTEMPTS} attempts", :red
91
+ exit 1
92
+ end
93
+ say "Brain not available (#{error.class.name.split("::").last}). " \
94
+ "Retrying #{attempts}/#{MAX_SESSION_FETCH_ATTEMPTS}... (Ctrl+C to cancel)", :yellow
95
+ sleep SESSION_FETCH_DELAY
96
+ retry
97
+ end
98
+ end
99
+
100
+ # Fetches the current session ID from the brain's REST API.
101
+ # @param host [String] brain server address
102
+ # @return [Integer] session ID
103
+ # @raise [RuntimeError] if the brain returns an error response
104
+ def fetch_current_session(host)
105
+ uri = URI("http://#{host}/api/sessions/current")
106
+ body = Net::HTTP.get(uri)
107
+ JSON.parse(body)["id"]
108
+ end
54
109
  end
55
110
  end
@@ -29,7 +29,7 @@ module Anima
29
29
  create_config_file
30
30
  generate_credentials
31
31
  create_systemd_service
32
- say "Installation complete. Run 'anima start' to begin."
32
+ say "Installation complete. Brain is running. Connect with 'anima tui'."
33
33
  end
34
34
 
35
35
  def create_directories
@@ -96,10 +96,9 @@ module Anima
96
96
 
97
97
  [Service]
98
98
  Type=simple
99
- ExecStart=#{anima_bin} start
99
+ ExecStart=#{anima_bin} start -e production
100
100
  Restart=on-failure
101
101
  RestartSec=5
102
- Environment=RAILS_ENV=production
103
102
 
104
103
  [Install]
105
104
  WantedBy=default.target
@@ -107,6 +106,8 @@ module Anima
107
106
 
108
107
  say " created #{service_path}"
109
108
  system("systemctl", "--user", "daemon-reload", err: File::NULL, out: File::NULL)
109
+ system("systemctl", "--user", "enable", "--now", "anima.service", err: File::NULL, out: File::NULL)
110
+ say " enabled and started anima.service"
110
111
  end
111
112
 
112
113
  private
data/lib/anima/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Anima
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/anima.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
3
4
  require_relative "anima/version"
4
5
 
5
6
  module Anima
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Forwards EventBus events to Action Cable, bridging internal pub/sub
6
+ # to external WebSocket clients. Each event is broadcast to the
7
+ # session-specific stream (e.g. "session_42"), matching the stream
8
+ # name used by {SessionChannel}.
9
+ #
10
+ # Only events with a valid session_id are broadcast — events without
11
+ # one have no destination channel and are silently skipped.
12
+ #
13
+ # @example
14
+ # Events::Bus.subscribe(Events::Subscribers::ActionCableBridge.instance)
15
+ # # Now all events with session_id flow to "session_<id>" streams
16
+ class ActionCableBridge
17
+ include Events::Subscriber
18
+ include Singleton
19
+
20
+ # Receives a Rails.event notification hash and broadcasts the payload
21
+ # to the session's Action Cable stream.
22
+ #
23
+ # @param event [Hash] with :payload containing event data including :session_id
24
+ def emit(event)
25
+ payload = event[:payload]
26
+ return unless payload.is_a?(Hash)
27
+
28
+ session_id = payload[:session_id]
29
+ return if session_id.nil?
30
+
31
+ ActionCable.server.broadcast("session_#{session_id}", payload)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -5,16 +5,23 @@ module Events
5
5
  # Persists all events to SQLite as they flow through the event bus.
6
6
  # Each event is written as an Event record belonging to the active session.
7
7
  #
8
- # @example
9
- # session = Session.create!
8
+ # When initialized with a specific session, all events are saved to that
9
+ # session. When initialized without one (global mode), the session is
10
+ # looked up from the event's session_id payload field.
11
+ #
12
+ # @example Session-scoped
10
13
  # persister = Events::Subscribers::Persister.new(session)
11
14
  # Events::Bus.subscribe(persister)
15
+ #
16
+ # @example Global (persists events for any session)
17
+ # persister = Events::Subscribers::Persister.new
18
+ # Events::Bus.subscribe(persister)
12
19
  class Persister
13
20
  include Events::Subscriber
14
21
 
15
22
  attr_reader :session
16
23
 
17
- def initialize(session)
24
+ def initialize(session = nil)
18
25
  @session = session
19
26
  @mutex = Mutex.new
20
27
  end
@@ -28,8 +35,11 @@ module Events
28
35
  event_type = payload[:type]
29
36
  return if event_type.nil?
30
37
 
38
+ target_session = @session || Session.find_by(id: payload[:session_id])
39
+ return unless target_session
40
+
31
41
  @mutex.synchronize do
32
- @session.events.create!(
42
+ target_session.events.create!(
33
43
  event_type: event_type,
34
44
  payload: payload,
35
45
  tool_use_id: payload[:tool_use_id],
@@ -19,6 +19,11 @@ module Providers
19
19
  class AuthenticationError < Error; end
20
20
  class TokenFormatError < Error; end
21
21
 
22
+ # Transient errors that may succeed on retry (network issues, rate limits, server errors).
23
+ class TransientError < Error; end
24
+ class RateLimitError < TransientError; end
25
+ class ServerError < TransientError; end
26
+
22
27
  class << self
23
28
  def validate!
24
29
  token = fetch_token
@@ -75,6 +80,8 @@ module Providers
75
80
  )
76
81
 
77
82
  handle_response(response)
83
+ rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => e
84
+ raise TransientError, "#{e.class}: #{e.message}"
78
85
  end
79
86
 
80
87
  # Count tokens in a message payload without creating a message.
@@ -96,6 +103,8 @@ module Providers
96
103
 
97
104
  result = handle_response(response)
98
105
  result["input_tokens"]
106
+ rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => e
107
+ raise TransientError, "#{e.class}: #{e.message}"
99
108
  end
100
109
 
101
110
  def validate_credentials!
@@ -147,9 +156,9 @@ module Providers
147
156
  raise AuthenticationError,
148
157
  "Forbidden (403): #{error_message(response)}"
149
158
  when 429
150
- raise Error, "Rate limit exceeded: #{error_message(response)}"
159
+ raise RateLimitError, "Rate limit exceeded: #{error_message(response)}"
151
160
  when 500..599
152
- raise Error, "Anthropic server error (#{response.code}): #{response.message}"
161
+ raise ServerError, "Anthropic server error (#{response.code}): #{response.message}"
153
162
  else
154
163
  raise Error, "Unexpected response (#{response.code}): #{response.message}"
155
164
  end
data/lib/tui/app.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cable_client"
4
+ require_relative "message_store"
3
5
  require_relative "screens/chat"
4
6
  require_relative "screens/settings"
5
7
  require_relative "screens/anthropic"
@@ -19,13 +21,24 @@ module TUI
19
21
 
20
22
  SIDEBAR_WIDTH = 28
21
23
 
24
+ # Connection status display styles
25
+ STATUS_STYLES = {
26
+ disconnected: {label: " DISCONNECTED ", fg: "white", bg: "red"},
27
+ connecting: {label: " CONNECTING ", fg: "black", bg: "yellow"},
28
+ connected: {label: " CONNECTED ", fg: "black", bg: "yellow"},
29
+ subscribed: {label: " CONNECTED ", fg: "black", bg: "green"},
30
+ reconnecting: {label: " RECONNECTING ", fg: "black", bg: "yellow"}
31
+ }.freeze
32
+
22
33
  attr_reader :current_screen, :command_mode
23
34
 
24
- def initialize
35
+ # @param cable_client [TUI::CableClient] WebSocket client connected to the brain
36
+ def initialize(cable_client:)
37
+ @cable_client = cable_client
25
38
  @current_screen = :chat
26
39
  @command_mode = false
27
40
  @screens = {
28
- chat: Screens::Chat.new,
41
+ chat: Screens::Chat.new(cable_client: cable_client),
29
42
  settings: Screens::Settings.new,
30
43
  anthropic: Screens::Anthropic.new
31
44
  }
@@ -36,9 +49,13 @@ module TUI
36
49
  loop do
37
50
  tui.draw { |frame| render(frame, tui) }
38
51
 
39
- break if handle_event(tui.poll_event) == :quit
52
+ event = tui.poll_event(timeout: 0.1)
53
+ next if event.nil? || event.none?
54
+ break if handle_event(event) == :quit
40
55
  end
41
56
  end
57
+ ensure
58
+ @cable_client.disconnect
42
59
  end
43
60
 
44
61
  private
@@ -89,16 +106,29 @@ module TUI
89
106
  end
90
107
 
91
108
  def render_info(frame, area, tui)
92
- info_text = tui.line(spans: [
93
- tui.span(content: "Anima v#{Anima::VERSION}", style: tui.style(fg: "white"))
94
- ])
95
- hint_text = tui.line(spans: [
96
- tui.span(content: "Ctrl+a", style: tui.style(fg: "cyan", modifiers: [:bold])),
97
- tui.span(content: " command mode", style: tui.style(fg: "dark_gray"))
98
- ])
109
+ session = @screens[:chat].session_info
110
+ lines = [
111
+ tui.line(spans: [
112
+ tui.span(content: "Anima v#{Anima::VERSION}", style: tui.style(fg: "white"))
113
+ ]),
114
+ tui.line(spans: [tui.span(content: "")]),
115
+ tui.line(spans: [
116
+ tui.span(content: "Session ", style: tui.style(fg: "dark_gray")),
117
+ tui.span(content: "##{session[:id]}", style: tui.style(fg: "cyan", modifiers: [:bold]))
118
+ ]),
119
+ tui.line(spans: [
120
+ tui.span(content: "Messages ", style: tui.style(fg: "dark_gray")),
121
+ tui.span(content: session[:message_count].to_s, style: tui.style(fg: "cyan"))
122
+ ]),
123
+ tui.line(spans: [tui.span(content: "")]),
124
+ tui.line(spans: [
125
+ tui.span(content: "Ctrl+a", style: tui.style(fg: "cyan", modifiers: [:bold])),
126
+ tui.span(content: " command mode", style: tui.style(fg: "dark_gray"))
127
+ ])
128
+ ]
99
129
 
100
130
  info = tui.paragraph(
101
- text: [info_text, hint_text],
131
+ text: lines,
102
132
  block: tui.block(
103
133
  title: "Info",
104
134
  borders: [:all],
@@ -118,10 +148,28 @@ module TUI
118
148
  tui.span(content: " NORMAL ", style: tui.style(fg: "black", bg: "cyan", modifiers: [:bold]))
119
149
  end
120
150
 
121
- widget = tui.paragraph(text: tui.line(spans: [mode_span]))
151
+ conn_span = connection_status_span(tui)
152
+
153
+ widget = tui.paragraph(text: tui.line(spans: [mode_span, conn_span]))
122
154
  frame.render_widget(widget, area)
123
155
  end
124
156
 
157
+ def connection_status_span(tui)
158
+ cable_status = @cable_client.status
159
+
160
+ if cable_status == :reconnecting
161
+ attempt = @cable_client.reconnect_attempt
162
+ max = CableClient::MAX_RECONNECT_ATTEMPTS
163
+ label = " RECONNECTING (#{attempt}/#{max}) "
164
+ style = STATUS_STYLES[:reconnecting]
165
+ else
166
+ style = STATUS_STYLES.fetch(cable_status, STATUS_STYLES[:disconnected])
167
+ label = style[:label]
168
+ end
169
+
170
+ tui.span(content: label, style: tui.style(fg: style[:fg], bg: style[:bg], modifiers: [:bold]))
171
+ end
172
+
125
173
  def chat_loading?
126
174
  @screens[:chat].loading?
127
175
  end
@@ -157,6 +205,11 @@ module TUI
157
205
  end
158
206
 
159
207
  def handle_normal_mode(event)
208
+ if event.mouse?
209
+ delegate_to_screen(event)
210
+ return nil
211
+ end
212
+
160
213
  return nil unless event.key?
161
214
 
162
215
  if ctrl_a?(event)
@@ -169,9 +222,14 @@ module TUI
169
222
  return nil
170
223
  end
171
224
 
225
+ delegate_to_screen(event)
226
+ nil
227
+ end
228
+
229
+ # Forwards an event to the active screen for handling
230
+ def delegate_to_screen(event)
172
231
  screen = @screens[@current_screen]
173
232
  screen.handle_event(event) if screen.respond_to?(:handle_event)
174
- nil
175
233
  end
176
234
 
177
235
  def ctrl_a?(event)