anima-core 0.0.1 → 0.1.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +18 -0
  3. data/CHANGELOG.md +26 -0
  4. data/README.md +134 -19
  5. data/Rakefile +3 -0
  6. data/app/jobs/application_job.rb +4 -0
  7. data/app/jobs/count_event_tokens_job.rb +28 -0
  8. data/app/models/application_record.rb +5 -0
  9. data/app/models/event.rb +64 -0
  10. data/app/models/session.rb +105 -0
  11. data/config/application.rb +31 -0
  12. data/config/boot.rb +8 -0
  13. data/config/database.yml +33 -0
  14. data/config/environment.rb +5 -0
  15. data/config/environments/development.rb +8 -0
  16. data/config/environments/production.rb +8 -0
  17. data/config/environments/test.rb +9 -0
  18. data/config/initializers/inflections.rb +9 -0
  19. data/config/queue.yml +18 -0
  20. data/config/recurring.yml +15 -0
  21. data/config/routes.rb +4 -0
  22. data/db/migrate/.keep +0 -0
  23. data/db/migrate/20260308124202_create_sessions.rb +9 -0
  24. data/db/migrate/20260308124203_create_events.rb +18 -0
  25. data/db/migrate/20260308130000_add_event_indexes.rb +9 -0
  26. data/db/migrate/20260308140000_remove_position_from_events.rb +8 -0
  27. data/db/migrate/20260308150000_add_token_count_to_events.rb +7 -0
  28. data/db/migrate/20260308160000_add_tool_use_id_to_events.rb +8 -0
  29. data/db/queue_schema.rb +141 -0
  30. data/db/seeds.rb +1 -0
  31. data/exe/anima +6 -0
  32. data/lib/anima/cli.rb +55 -0
  33. data/lib/anima/installer.rb +118 -0
  34. data/lib/anima/version.rb +1 -1
  35. data/lib/anima.rb +4 -0
  36. data/lib/events/agent_message.rb +11 -0
  37. data/lib/events/base.rb +38 -0
  38. data/lib/events/bus.rb +39 -0
  39. data/lib/events/subscriber.rb +26 -0
  40. data/lib/events/subscribers/message_collector.rb +64 -0
  41. data/lib/events/subscribers/persister.rb +46 -0
  42. data/lib/events/system_message.rb +11 -0
  43. data/lib/events/tool_call.rb +29 -0
  44. data/lib/events/tool_response.rb +33 -0
  45. data/lib/events/user_message.rb +11 -0
  46. data/lib/llm/client.rb +161 -0
  47. data/lib/providers/anthropic.rb +164 -0
  48. data/lib/shell_session.rb +333 -0
  49. data/lib/tools/base.rb +58 -0
  50. data/lib/tools/bash.rb +53 -0
  51. data/lib/tools/registry.rb +60 -0
  52. data/lib/tools/web_get.rb +62 -0
  53. data/lib/tui/app.rb +181 -0
  54. data/lib/tui/screens/anthropic.rb +25 -0
  55. data/lib/tui/screens/chat.rb +210 -0
  56. data/lib/tui/screens/settings.rb +52 -0
  57. metadata +124 -4
  58. data/BRAINSTORM.md +0 -466
data/lib/tools/base.rb ADDED
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Abstract base class for all Anima tools. Subclasses must implement
5
+ # the class-level schema methods and the instance-level {#execute} method.
6
+ #
7
+ # @abstract Subclass and implement {.tool_name}, {.description},
8
+ # {.input_schema}, and {#execute}
9
+ #
10
+ # @example Implementing a tool
11
+ # class Tools::Echo < Tools::Base
12
+ # def self.tool_name = "echo"
13
+ # def self.description = "Echoes input back"
14
+ # def self.input_schema
15
+ # {type: "object", properties: {text: {type: "string"}}, required: ["text"]}
16
+ # end
17
+ #
18
+ # def execute(input)
19
+ # input["text"]
20
+ # end
21
+ # end
22
+ class Base
23
+ class << self
24
+ # @return [String] unique tool identifier sent to the LLM
25
+ def tool_name
26
+ raise NotImplementedError, "#{self} must implement .tool_name"
27
+ end
28
+
29
+ # @return [String] human-readable description for the LLM
30
+ def description
31
+ raise NotImplementedError, "#{self} must implement .description"
32
+ end
33
+
34
+ # @return [Hash] JSON Schema describing the tool's input parameters
35
+ def input_schema
36
+ raise NotImplementedError, "#{self} must implement .input_schema"
37
+ end
38
+
39
+ # Builds the schema hash expected by the Anthropic tools API.
40
+ # @return [Hash] with :name, :description, and :input_schema keys
41
+ def schema
42
+ {name: tool_name, description: description, input_schema: input_schema}
43
+ end
44
+ end
45
+
46
+ # Accepts and discards context keywords so that the Registry can pass
47
+ # shared dependencies (e.g. shell_session) to any tool uniformly.
48
+ # Subclasses that need specific context should override with named kwargs.
49
+ def initialize(**) = nil
50
+
51
+ # Execute the tool with the given input.
52
+ # @param input [Hash] parsed input matching {.input_schema}
53
+ # @return [String, Hash] result content; Hash with :error key signals failure
54
+ def execute(input)
55
+ raise NotImplementedError, "#{self.class} must implement #execute"
56
+ end
57
+ end
58
+ end
data/lib/tools/bash.rb ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Executes bash commands in a persistent {ShellSession}. Commands share
5
+ # working directory, environment variables, and shell history within a
6
+ # conversation. Output is truncated and timeouts are enforced by the
7
+ # underlying session.
8
+ #
9
+ # @see ShellSession#run
10
+ class Bash < Base
11
+ def self.tool_name = "bash"
12
+
13
+ def self.description = "Execute a bash command. Working directory and environment persist across calls within a conversation."
14
+
15
+ def self.input_schema
16
+ {
17
+ type: "object",
18
+ properties: {
19
+ command: {type: "string", description: "The bash command to execute"}
20
+ },
21
+ required: ["command"]
22
+ }
23
+ end
24
+
25
+ # @param shell_session [ShellSession] persistent shell backing this tool
26
+ def initialize(shell_session:, **)
27
+ @shell_session = shell_session
28
+ end
29
+
30
+ # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
31
+ # @return [String] formatted output with stdout, stderr, and exit code
32
+ # @return [Hash] with :error key on failure
33
+ def execute(input)
34
+ command = input["command"].to_s
35
+ return {error: "Command cannot be blank"} if command.strip.empty?
36
+
37
+ result = @shell_session.run(command)
38
+ return result if result.key?(:error)
39
+
40
+ format_result(result[:stdout], result[:stderr], result[:exit_code])
41
+ end
42
+
43
+ private
44
+
45
+ def format_result(stdout, stderr, exit_code)
46
+ parts = []
47
+ parts << "stdout:\n#{stdout}" unless stdout.empty?
48
+ parts << "stderr:\n#{stderr}" unless stderr.empty?
49
+ parts << "exit_code: #{exit_code}"
50
+ parts.join("\n\n")
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ class UnknownToolError < StandardError; end
5
+
6
+ # Manages tool registration, schema export, and dispatch.
7
+ # Tools are registered by class and looked up by name at execution time.
8
+ # An optional context hash is passed to each tool's constructor, allowing
9
+ # shared dependencies (e.g. a {ShellSession}) to reach tools that need them.
10
+ #
11
+ # @example
12
+ # registry = Tools::Registry.new(context: {shell_session: my_shell})
13
+ # registry.register(Tools::Bash)
14
+ # registry.execute("bash", {"command" => "ls"})
15
+ class Registry
16
+ # @return [Hash{String => Class}] registered tool classes keyed by name
17
+ attr_reader :tools
18
+
19
+ # @param context [Hash] keyword arguments forwarded to every tool constructor
20
+ def initialize(context: {})
21
+ @tools = {}
22
+ @context = context
23
+ end
24
+
25
+ # Register a tool class. The class must respond to .tool_name.
26
+ # @param tool_class [Class<Tools::Base>] the tool class to register
27
+ # @return [void]
28
+ def register(tool_class)
29
+ @tools[tool_class.tool_name] = tool_class
30
+ end
31
+
32
+ # @return [Array<Hash>] schema array for the Anthropic tools API parameter
33
+ def schemas
34
+ @tools.values.map(&:schema)
35
+ end
36
+
37
+ # Instantiate and execute a tool by name. The registry's context is
38
+ # forwarded to the tool constructor as keyword arguments.
39
+ #
40
+ # @param name [String] registered tool name
41
+ # @param input [Hash] tool input parameters
42
+ # @return [String, Hash] tool execution result
43
+ # @raise [UnknownToolError] if no tool is registered with the given name
44
+ def execute(name, input)
45
+ tool_class = @tools.fetch(name) { raise UnknownToolError, "Unknown tool: #{name}" }
46
+ tool_class.new(**@context).execute(input)
47
+ end
48
+
49
+ # @param name [String] tool name to check
50
+ # @return [Boolean] whether a tool with the given name is registered
51
+ def registered?(name)
52
+ @tools.key?(name)
53
+ end
54
+
55
+ # @return [Boolean] whether any tools are registered
56
+ def any?
57
+ @tools.any?
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+
5
+ module Tools
6
+ # Fetches content from a URL via HTTP GET. Returns the response body
7
+ # as plain text, truncated to {MAX_RESPONSE_BYTES} to prevent memory issues.
8
+ #
9
+ # Only http and https schemes are allowed.
10
+ class WebGet < Base
11
+ MAX_RESPONSE_BYTES = 100_000
12
+ REQUEST_TIMEOUT = 10
13
+
14
+ def self.tool_name = "web_get"
15
+
16
+ def self.description = "Fetch content from a URL via HTTP GET and return the response body"
17
+
18
+ def self.input_schema
19
+ {
20
+ type: "object",
21
+ properties: {
22
+ url: {type: "string", description: "The URL to fetch (http or https)"}
23
+ },
24
+ required: ["url"]
25
+ }
26
+ end
27
+
28
+ # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
29
+ # @return [String] response body (possibly truncated)
30
+ # @return [Hash] with :error key on failure
31
+ def execute(input)
32
+ validate_and_fetch(input["url"].to_s)
33
+ end
34
+
35
+ private
36
+
37
+ def validate_and_fetch(url)
38
+ scheme = URI.parse(url).scheme
39
+
40
+ unless %w[http https].include?(scheme)
41
+ return {error: "Only http and https URLs are supported, got: #{scheme.inspect}"}
42
+ end
43
+
44
+ truncate_body(HTTParty.get(url, timeout: REQUEST_TIMEOUT, follow_redirects: false).body.to_s)
45
+ rescue URI::InvalidURIError => error
46
+ {error: "Invalid URL: #{error.message}"}
47
+ rescue Net::OpenTimeout, Net::ReadTimeout
48
+ {error: "Request timed out after #{REQUEST_TIMEOUT} seconds"}
49
+ rescue Errno::ECONNREFUSED
50
+ {error: "Connection refused: #{url}"}
51
+ rescue => error
52
+ {error: "#{error.class}: #{error.message}"}
53
+ end
54
+
55
+ def truncate_body(body)
56
+ return body if body.bytesize <= MAX_RESPONSE_BYTES
57
+
58
+ body.byteslice(0, MAX_RESPONSE_BYTES) +
59
+ "\n\n[Truncated: response exceeded #{MAX_RESPONSE_BYTES} bytes]"
60
+ end
61
+ end
62
+ end
data/lib/tui/app.rb ADDED
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "screens/chat"
4
+ require_relative "screens/settings"
5
+ require_relative "screens/anthropic"
6
+
7
+ module TUI
8
+ class App
9
+ SCREENS = %i[chat settings anthropic].freeze
10
+
11
+ COMMAND_KEYS = {
12
+ "n" => :new_session,
13
+ "s" => :settings,
14
+ "a" => :anthropic,
15
+ "q" => :quit
16
+ }.freeze
17
+
18
+ MENU_LABELS = COMMAND_KEYS.map { |key, action| "[#{key}] #{action.capitalize}" }.freeze
19
+
20
+ SIDEBAR_WIDTH = 28
21
+
22
+ attr_reader :current_screen, :command_mode
23
+
24
+ def initialize
25
+ @current_screen = :chat
26
+ @command_mode = false
27
+ @screens = {
28
+ chat: Screens::Chat.new,
29
+ settings: Screens::Settings.new,
30
+ anthropic: Screens::Anthropic.new
31
+ }
32
+ end
33
+
34
+ def run
35
+ RatatuiRuby.run do |tui|
36
+ loop do
37
+ tui.draw { |frame| render(frame, tui) }
38
+
39
+ break if handle_event(tui.poll_event) == :quit
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def render(frame, tui)
47
+ main_area, sidebar = tui.split(
48
+ frame.area,
49
+ direction: :horizontal,
50
+ constraints: [
51
+ tui.constraint_fill(1),
52
+ tui.constraint_length(SIDEBAR_WIDTH)
53
+ ]
54
+ )
55
+
56
+ content_area, status_bar = tui.split(
57
+ main_area,
58
+ direction: :vertical,
59
+ constraints: [
60
+ tui.constraint_fill(1),
61
+ tui.constraint_length(1)
62
+ ]
63
+ )
64
+
65
+ @screens[@current_screen].render(frame, content_area, tui)
66
+ render_sidebar(frame, sidebar, tui)
67
+ render_status_bar(frame, status_bar, tui)
68
+ end
69
+
70
+ def render_sidebar(frame, area, tui)
71
+ if @command_mode
72
+ render_menu(frame, area, tui)
73
+ else
74
+ render_info(frame, area, tui)
75
+ end
76
+ end
77
+
78
+ def render_menu(frame, area, tui)
79
+ menu = tui.list(
80
+ items: MENU_LABELS,
81
+ block: tui.block(
82
+ title: "Command",
83
+ borders: [:all],
84
+ border_type: :rounded,
85
+ border_style: {fg: "yellow"}
86
+ )
87
+ )
88
+ frame.render_widget(menu, area)
89
+ end
90
+
91
+ 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
+ ])
99
+
100
+ info = tui.paragraph(
101
+ text: [info_text, hint_text],
102
+ block: tui.block(
103
+ title: "Info",
104
+ borders: [:all],
105
+ border_type: :rounded,
106
+ border_style: {fg: "white"}
107
+ )
108
+ )
109
+ frame.render_widget(info, area)
110
+ end
111
+
112
+ def render_status_bar(frame, area, tui)
113
+ mode_span = if @command_mode
114
+ tui.span(content: " COMMAND ", style: tui.style(fg: "black", bg: "yellow", modifiers: [:bold]))
115
+ elsif chat_loading?
116
+ tui.span(content: " THINKING ", style: tui.style(fg: "black", bg: "magenta", modifiers: [:bold]))
117
+ else
118
+ tui.span(content: " NORMAL ", style: tui.style(fg: "black", bg: "cyan", modifiers: [:bold]))
119
+ end
120
+
121
+ widget = tui.paragraph(text: tui.line(spans: [mode_span]))
122
+ frame.render_widget(widget, area)
123
+ end
124
+
125
+ def chat_loading?
126
+ @screens[:chat].loading?
127
+ end
128
+
129
+ def handle_event(event)
130
+ return nil if event.none?
131
+ return :quit if event.ctrl_c?
132
+
133
+ if @command_mode
134
+ handle_command_mode(event)
135
+ else
136
+ handle_normal_mode(event)
137
+ end
138
+ end
139
+
140
+ def handle_command_mode(event)
141
+ @command_mode = false
142
+
143
+ return nil unless event.key?
144
+
145
+ action = COMMAND_KEYS[event.code]
146
+ case action
147
+ when :quit
148
+ :quit
149
+ when :new_session
150
+ @screens[:chat].new_session
151
+ @current_screen = :chat
152
+ nil
153
+ when :settings, :anthropic
154
+ @current_screen = action
155
+ nil
156
+ end
157
+ end
158
+
159
+ def handle_normal_mode(event)
160
+ return nil unless event.key?
161
+
162
+ if ctrl_a?(event)
163
+ @command_mode = true
164
+ return nil
165
+ end
166
+
167
+ if event.esc? && @current_screen != :chat
168
+ @current_screen = :chat
169
+ return nil
170
+ end
171
+
172
+ screen = @screens[@current_screen]
173
+ screen.handle_event(event) if screen.respond_to?(:handle_event)
174
+ nil
175
+ end
176
+
177
+ def ctrl_a?(event)
178
+ event.code == "a" && event.modifiers&.include?("ctrl")
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Screens
5
+ class Anthropic
6
+ def render(frame, area, tui)
7
+ widget = tui.paragraph(
8
+ text: "Anthropic account connection will be configured here.",
9
+ alignment: :center,
10
+ wrap: true,
11
+ block: tui.block(
12
+ title: "Anthropic Connection",
13
+ titles: [
14
+ {content: "Esc back", position: :bottom, alignment: :center}
15
+ ],
16
+ borders: [:all],
17
+ border_type: :rounded,
18
+ border_style: {fg: "magenta"}
19
+ )
20
+ )
21
+ frame.render_widget(widget, area)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Screens
5
+ class Chat
6
+ INPUT_HEIGHT = 3
7
+ MAX_INPUT_LENGTH = 10_000
8
+ PRINTABLE_CHAR = /\A[[:print:]]\z/
9
+
10
+ ROLE_USER = "user"
11
+ ROLE_ASSISTANT = "assistant"
12
+ ROLE_LABELS = {ROLE_USER => "You", ROLE_ASSISTANT => "Anima"}.freeze
13
+
14
+ attr_reader :input, :message_collector, :session
15
+
16
+ def initialize(message_collector: nil, persister: nil, session: nil, shell_session: nil)
17
+ @message_collector = message_collector || Events::Subscribers::MessageCollector.new
18
+ @input = ""
19
+ @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)
30
+ end
31
+
32
+ def messages
33
+ @message_collector.messages
34
+ end
35
+
36
+ def render(frame, area, tui)
37
+ chat_area, input_area = tui.split(
38
+ area,
39
+ direction: :vertical,
40
+ constraints: [
41
+ tui.constraint_fill(1),
42
+ tui.constraint_length(INPUT_HEIGHT)
43
+ ]
44
+ )
45
+
46
+ render_messages(frame, chat_area, tui)
47
+ render_input(frame, input_area, tui)
48
+ end
49
+
50
+ def handle_event(event)
51
+ return false if @loading
52
+
53
+ if event.enter?
54
+ submit_message
55
+ true
56
+ elsif event.backspace?
57
+ @input = @input.chop
58
+ true
59
+ elsif printable_char?(event) && @input.length < MAX_INPUT_LENGTH
60
+ @input += event.code
61
+ true
62
+ else
63
+ false
64
+ end
65
+ end
66
+
67
+ 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
77
+ end
78
+
79
+ def finalize
80
+ @submit_thread&.join
81
+ @shell_session&.finalize
82
+ Events::Bus.unsubscribe(@message_collector)
83
+ Events::Bus.unsubscribe(@persister)
84
+ end
85
+
86
+ def loading?
87
+ @loading
88
+ end
89
+
90
+ private
91
+
92
+ def render_messages(frame, area, tui)
93
+ lines = build_message_lines(tui)
94
+
95
+ if @loading
96
+ lines << tui.line(spans: [
97
+ tui.span(content: "Thinking...", style: tui.style(fg: "yellow", modifiers: [:bold]))
98
+ ])
99
+ end
100
+
101
+ if lines.empty?
102
+ lines << tui.line(spans: [
103
+ tui.span(content: "Type a message to start chatting.", style: tui.style(fg: "dark_gray"))
104
+ ])
105
+ end
106
+
107
+ widget = tui.paragraph(
108
+ text: lines,
109
+ wrap: true,
110
+ block: tui.block(
111
+ title: "Chat",
112
+ borders: [:all],
113
+ border_type: :rounded,
114
+ border_style: {fg: "cyan"}
115
+ )
116
+ )
117
+ frame.render_widget(widget, area)
118
+ end
119
+
120
+ def build_message_lines(tui)
121
+ messages.flat_map do |msg|
122
+ role_style = if msg[:role] == ROLE_USER
123
+ tui.style(fg: "green", modifiers: [:bold])
124
+ else
125
+ tui.style(fg: "cyan", modifiers: [:bold])
126
+ end
127
+
128
+ label = ROLE_LABELS.fetch(msg[:role], msg[:role])
129
+
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
+ ]
137
+ end
138
+ end
139
+
140
+ 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")
144
+
145
+ widget = tui.paragraph(
146
+ text: tui.line(spans: [
147
+ tui.span(content: "> #{@input}#{cursor}", style: text_style)
148
+ ]),
149
+ block: tui.block(
150
+ title: @loading ? "Waiting..." : "Input",
151
+ titles: @loading ? [] : [
152
+ {content: "Enter send", position: :bottom, alignment: :center}
153
+ ],
154
+ borders: [:all],
155
+ border_type: :rounded,
156
+ border_style: border_style
157
+ )
158
+ )
159
+ frame.render_widget(widget, area)
160
+ end
161
+
162
+ def submit_message
163
+ text = @input.strip
164
+ return if text.empty?
165
+
166
+ Events::Bus.emit(Events::UserMessage.new(content: text, session_id: @session.id))
167
+ @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
185
+ end
186
+
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
192
+ end
193
+
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
+ })
200
+ end
201
+ end
202
+
203
+ def printable_char?(event)
204
+ return false if event.modifiers&.include?("ctrl")
205
+
206
+ event.code.length == 1 && event.code.match?(PRINTABLE_CHAR)
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Screens
5
+ class Settings
6
+ MENU_ITEMS = [
7
+ "General",
8
+ "Appearance",
9
+ "Keybindings"
10
+ ].freeze
11
+
12
+ def initialize
13
+ # Initialized on first render — requires tui context from RatatuiRuby.run block
14
+ @list_state = nil
15
+ end
16
+
17
+ def render(frame, area, tui)
18
+ @list_state ||= tui.list_state(0)
19
+
20
+ list = tui.list(
21
+ items: MENU_ITEMS,
22
+ highlight_style: {fg: "yellow", bold: true},
23
+ highlight_symbol: "> ",
24
+ block: tui.block(
25
+ title: "Settings",
26
+ titles: [
27
+ {content: "↑/↓ navigate • Esc back", position: :bottom, alignment: :center}
28
+ ],
29
+ borders: [:all],
30
+ border_type: :rounded,
31
+ border_style: {fg: "green"}
32
+ )
33
+ )
34
+ frame.render_stateful_widget(list, area, @list_state)
35
+ end
36
+
37
+ def handle_event(event)
38
+ return false unless @list_state
39
+
40
+ if event.down? || event.j?
41
+ @list_state.select_next
42
+ true
43
+ elsif event.up? || event.k?
44
+ @list_state.select_previous
45
+ true
46
+ else
47
+ false
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end