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.
- checksums.yaml +4 -4
- data/.reek.yml +18 -0
- data/CHANGELOG.md +26 -0
- data/README.md +134 -19
- data/Rakefile +3 -0
- data/app/jobs/application_job.rb +4 -0
- data/app/jobs/count_event_tokens_job.rb +28 -0
- data/app/models/application_record.rb +5 -0
- data/app/models/event.rb +64 -0
- data/app/models/session.rb +105 -0
- data/config/application.rb +31 -0
- data/config/boot.rb +8 -0
- data/config/database.yml +33 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +8 -0
- data/config/environments/production.rb +8 -0
- data/config/environments/test.rb +9 -0
- data/config/initializers/inflections.rb +9 -0
- data/config/queue.yml +18 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +4 -0
- data/db/migrate/.keep +0 -0
- data/db/migrate/20260308124202_create_sessions.rb +9 -0
- data/db/migrate/20260308124203_create_events.rb +18 -0
- data/db/migrate/20260308130000_add_event_indexes.rb +9 -0
- data/db/migrate/20260308140000_remove_position_from_events.rb +8 -0
- data/db/migrate/20260308150000_add_token_count_to_events.rb +7 -0
- data/db/migrate/20260308160000_add_tool_use_id_to_events.rb +8 -0
- data/db/queue_schema.rb +141 -0
- data/db/seeds.rb +1 -0
- data/exe/anima +6 -0
- data/lib/anima/cli.rb +55 -0
- data/lib/anima/installer.rb +118 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +4 -0
- data/lib/events/agent_message.rb +11 -0
- data/lib/events/base.rb +38 -0
- data/lib/events/bus.rb +39 -0
- data/lib/events/subscriber.rb +26 -0
- data/lib/events/subscribers/message_collector.rb +64 -0
- data/lib/events/subscribers/persister.rb +46 -0
- data/lib/events/system_message.rb +11 -0
- data/lib/events/tool_call.rb +29 -0
- data/lib/events/tool_response.rb +33 -0
- data/lib/events/user_message.rb +11 -0
- data/lib/llm/client.rb +161 -0
- data/lib/providers/anthropic.rb +164 -0
- data/lib/shell_session.rb +333 -0
- data/lib/tools/base.rb +58 -0
- data/lib/tools/bash.rb +53 -0
- data/lib/tools/registry.rb +60 -0
- data/lib/tools/web_get.rb +62 -0
- data/lib/tui/app.rb +181 -0
- data/lib/tui/screens/anthropic.rb +25 -0
- data/lib/tui/screens/chat.rb +210 -0
- data/lib/tui/screens/settings.rb +52 -0
- metadata +124 -4
- 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
|