anima-core 0.2.0 → 0.3.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +20 -32
  4. data/anima-core.gemspec +1 -0
  5. data/app/channels/session_channel.rb +220 -26
  6. data/app/decorators/agent_message_decorator.rb +24 -0
  7. data/app/decorators/application_decorator.rb +6 -0
  8. data/app/decorators/event_decorator.rb +173 -0
  9. data/app/decorators/system_message_decorator.rb +21 -0
  10. data/app/decorators/tool_call_decorator.rb +48 -0
  11. data/app/decorators/tool_response_decorator.rb +37 -0
  12. data/app/decorators/user_message_decorator.rb +35 -0
  13. data/app/jobs/agent_request_job.rb +31 -2
  14. data/app/jobs/count_event_tokens_job.rb +14 -3
  15. data/app/models/concerns/event/broadcasting.rb +63 -0
  16. data/app/models/event.rb +36 -0
  17. data/app/models/session.rb +46 -14
  18. data/config/application.rb +1 -0
  19. data/config/initializers/event_subscribers.rb +0 -1
  20. data/config/routes.rb +0 -6
  21. data/db/cable_schema.rb +14 -2
  22. data/db/migrate/20260312170000_add_view_mode_to_sessions.rb +7 -0
  23. data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
  24. data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
  25. data/lib/agent_loop.rb +5 -2
  26. data/lib/anima/cli.rb +1 -40
  27. data/lib/anima/version.rb +1 -1
  28. data/lib/events/subscribers/persister.rb +1 -0
  29. data/lib/events/user_message.rb +17 -0
  30. data/lib/providers/anthropic.rb +3 -13
  31. data/lib/tools/edit.rb +227 -0
  32. data/lib/tools/read.rb +152 -0
  33. data/lib/tools/write.rb +86 -0
  34. data/lib/tui/app.rb +831 -55
  35. data/lib/tui/cable_client.rb +79 -31
  36. data/lib/tui/input_buffer.rb +181 -0
  37. data/lib/tui/message_store.rb +162 -14
  38. data/lib/tui/screens/chat.rb +504 -75
  39. metadata +30 -5
  40. data/app/controllers/api/sessions_controller.rb +0 -25
  41. data/lib/events/subscribers/action_cable_bridge.rb +0 -35
  42. data/lib/tui/screens/anthropic.rb +0 -25
  43. data/lib/tui/screens/settings.rb +0 -52
@@ -25,21 +25,11 @@ module Providers
25
25
  class ServerError < TransientError; end
26
26
 
27
27
  class << self
28
- def validate!
29
- token = fetch_token
30
- validate_token_format!(token)
31
- validate_token_api!(token)
32
- true
33
- end
34
-
35
28
  def fetch_token
36
29
  token = Rails.application.credentials.dig(:anthropic, :subscription_token)
37
30
  raise AuthenticationError, <<~MSG.strip if token.blank?
38
31
  No Anthropic subscription token found in credentials.
39
- Run: bin/rails credentials:edit
40
- Add:
41
- anthropic:
42
- subscription_token: sk-ant-oat01-YOUR_TOKEN_HERE
32
+ Use the TUI token setup (Ctrl+a → a) to configure your token.
43
33
  MSG
44
34
  token
45
35
  end
@@ -123,7 +113,7 @@ module Providers
123
113
  true
124
114
  when 401
125
115
  raise AuthenticationError,
126
- "Token rejected by Anthropic API (401). Re-run `claude setup-token` and update credentials."
116
+ "Token rejected by Anthropic API (401). Re-run `claude setup-token` and use the TUI token setup (Ctrl+a → a)."
127
117
  when 403
128
118
  raise AuthenticationError,
129
119
  "Token not authorized for API access (403). This credential may be restricted to Claude Code only."
@@ -151,7 +141,7 @@ module Providers
151
141
  raise Error, "Bad request: #{error_message(response)}"
152
142
  when 401
153
143
  raise AuthenticationError,
154
- "Authentication failed (401): #{error_message(response)}. Re-run `claude setup-token` and update credentials."
144
+ "Authentication failed (401): #{error_message(response)}. Re-run `claude setup-token` and use the TUI token setup (Ctrl+a → a)."
155
145
  when 403
156
146
  raise AuthenticationError,
157
147
  "Forbidden (403): #{error_message(response)}"
data/lib/tools/edit.rb ADDED
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Performs surgical text replacement with uniqueness constraint.
5
+ # Finds old_text in the file (must match exactly one location), replaces
6
+ # with new_text, and returns a unified diff. Falls back to
7
+ # whitespace-normalized fuzzy matching when exact match fails.
8
+ #
9
+ # Normalizes BOM and CRLF line endings for matching, restoring them after
10
+ # the edit. Rejects ambiguous edits where old_text matches zero or
11
+ # multiple locations.
12
+ #
13
+ # @example Replacing a method body
14
+ # tool.execute("path" => "app.rb",
15
+ # "old_text" => "def greet\n 'hi'\nend",
16
+ # "new_text" => "def greet\n 'hello'\nend")
17
+ # # => "--- app.rb\n+++ app.rb\n@@ -1,3 +1,3 @@\n ..."
18
+ class Edit < Base
19
+ MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
20
+
21
+ def self.tool_name = "edit"
22
+
23
+ def self.description = "Replace exact text in a file. old_text must match exactly one location; " \
24
+ "include surrounding lines for uniqueness. Use for surgical edits; " \
25
+ "use write for new files or full replacement."
26
+
27
+ def self.input_schema
28
+ {
29
+ type: "object",
30
+ properties: {
31
+ path: {type: "string", description: "Absolute or relative file path (relative resolved against working directory)"},
32
+ old_text: {type: "string", description: "Exact text to find (must match exactly one location — include surrounding context if needed)"},
33
+ new_text: {type: "string", description: "Replacement text (empty string to delete)"}
34
+ },
35
+ required: %w[path old_text new_text]
36
+ }
37
+ end
38
+
39
+ # @param shell_session [ShellSession, nil] provides working directory for resolving relative paths
40
+ def initialize(shell_session: nil, **)
41
+ @working_directory = shell_session&.pwd
42
+ end
43
+
44
+ # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
45
+ # @return [String] unified diff showing the change
46
+ # @return [Hash] with :error key on failure
47
+ def execute(input)
48
+ path, old_text, new_text = extract_params(input)
49
+ return {error: "Path cannot be blank"} if path.empty?
50
+ return {error: "old_text cannot be blank"} if old_text.empty?
51
+
52
+ path = resolve_path(path)
53
+
54
+ error = validate_file(path)
55
+ return error if error
56
+
57
+ edit_file(path, old_text, new_text)
58
+ end
59
+
60
+ private
61
+
62
+ def extract_params(input)
63
+ path = input["path"].to_s.strip
64
+ old_text = input["old_text"].to_s
65
+ new_text = input["new_text"].to_s
66
+ [path, old_text, new_text]
67
+ end
68
+
69
+ def resolve_path(path)
70
+ if @working_directory
71
+ File.expand_path(path, @working_directory)
72
+ else
73
+ File.expand_path(path)
74
+ end
75
+ end
76
+
77
+ def validate_file(path)
78
+ return {error: "File not found: #{path}"} unless File.exist?(path)
79
+ return {error: "Is a directory: #{path}"} if File.directory?(path)
80
+ return {error: "Permission denied: #{path}"} unless File.readable?(path) && File.writable?(path)
81
+ size = File.size(path)
82
+ if size > MAX_FILE_SIZE
83
+ {error: "File is #{size} bytes (#{size / 1_048_576} MB). " \
84
+ "Max editable size is #{MAX_FILE_SIZE / 1_048_576} MB. Use bash tool with sed instead."}
85
+ end
86
+ end
87
+
88
+ def edit_file(path, old_text, new_text)
89
+ raw = File.binread(path)
90
+ bom = extract_bom(raw)
91
+ content = raw[bom.length..].force_encoding("UTF-8")
92
+ had_crlf = content.include?("\r\n")
93
+ normalized = had_crlf ? content.gsub("\r\n", "\n") : content
94
+
95
+ match = find_unique_match(normalized, old_text, path)
96
+ return match if match.is_a?(Hash)
97
+
98
+ position, matched_text, fuzzy = match
99
+ new_content = normalized[0...position] + new_text + normalized[(position + matched_text.length)..]
100
+
101
+ if normalized == new_content
102
+ return {error: "old_text and new_text are identical. No changes made to #{path}."}
103
+ end
104
+
105
+ output = had_crlf ? new_content.gsub("\n", "\r\n") : new_content
106
+ File.binwrite(path, bom + output.b)
107
+
108
+ build_diff(path, normalized, new_content, fuzzy)
109
+ rescue Errno::EACCES
110
+ {error: "Permission denied: #{path}"}
111
+ rescue Errno::ENOSPC
112
+ {error: "No space left on device: #{path}"}
113
+ rescue Errno::EROFS
114
+ {error: "Read-only file system: #{path}"}
115
+ end
116
+
117
+ # @return [String] UTF-8 BOM bytes if present, empty binary string otherwise
118
+ def extract_bom(raw)
119
+ bytes = raw.b
120
+ bytes.start_with?("\xEF\xBB\xBF".b) ? bytes[0, 3] : "".b
121
+ end
122
+
123
+ # Finds exactly one match for old_text in content.
124
+ # Tries exact match first, then whitespace-normalized fuzzy match.
125
+ # @return [Array(Integer, String, Boolean)] position, matched text, fuzzy flag
126
+ # @return [Hash] error hash if zero or multiple matches found
127
+ def find_unique_match(content, old_text, path)
128
+ exact = find_all_positions(content, old_text)
129
+ return [exact[0], old_text, false] if exact.one?
130
+ return ambiguity_error(exact, content, path) if exact.length > 1
131
+
132
+ fuzzy = find_fuzzy_matches(content, old_text)
133
+ return [fuzzy[0][0], fuzzy[0][1], true] if fuzzy.one?
134
+ return ambiguity_error(fuzzy.map(&:first), content, path, fuzzy: true) if fuzzy.length > 1
135
+
136
+ {error: "Could not find old_text in #{path}. " \
137
+ "Verify the text exists and matches exactly (including whitespace). " \
138
+ "Use the read tool to check current file contents."}
139
+ end
140
+
141
+ def ambiguity_error(positions, content, path, fuzzy: false)
142
+ kind = fuzzy ? "fuzzy matches" : "matches"
143
+ line_numbers = positions.map { |pos| line_number_at(content, pos) }
144
+ {error: "Found #{positions.length} #{kind} for old_text in #{path}. " \
145
+ "Provide more surrounding context to uniquely identify the location. " \
146
+ "Matches at lines: #{line_numbers.join(", ")}"}
147
+ end
148
+
149
+ def line_number_at(content, position)
150
+ content[0...position].count("\n") + 1
151
+ end
152
+
153
+ def find_all_positions(content, text)
154
+ positions = []
155
+ offset = 0
156
+ while (pos = content.index(text, offset))
157
+ positions << pos
158
+ offset = pos + 1
159
+ end
160
+ positions
161
+ end
162
+
163
+ # Finds old_text in content using whitespace-normalized line comparison.
164
+ # @return [Array<Array(Integer, String)>] array of [position, matched_text] pairs
165
+ def find_fuzzy_matches(content, old_text)
166
+ content_lines = content.split("\n", -1)
167
+ search_lines = old_text.split("\n", -1)
168
+ search_lines.pop if search_lines.last&.empty? && old_text.end_with?("\n")
169
+ trailing_newline = old_text.end_with?("\n")
170
+
171
+ normalized_search = search_lines.map { |line| collapse_whitespace(line) }
172
+ return [] if normalized_search.all?(&:empty?)
173
+
174
+ window_size = search_lines.length
175
+ matches = []
176
+ (0..content_lines.length - window_size).each do |start_idx|
177
+ window = content_lines[start_idx, window_size]
178
+ next unless window.map { |line| collapse_whitespace(line) } == normalized_search
179
+
180
+ pos = start_idx.zero? ? 0 : content_lines[0...start_idx].sum { |line| line.length + 1 }
181
+ matched = window.join("\n")
182
+ matched += "\n" if trailing_newline
183
+ matches << [pos, matched]
184
+ end
185
+
186
+ matches
187
+ end
188
+
189
+ def collapse_whitespace(text)
190
+ text.gsub(/[[:blank:]]+/, " ").strip
191
+ end
192
+
193
+ # Generates a unified diff between old and new content with 3 lines of context.
194
+ DIFF_CONTEXT = 3
195
+
196
+ def build_diff(path, old_content, new_content, fuzzy)
197
+ before = old_content.lines(chomp: true)
198
+ after = new_content.lines(chomp: true)
199
+
200
+ first = 0
201
+ first += 1 while first < before.length && first < after.length && before[first] == after[first]
202
+
203
+ old_end = before.length - 1
204
+ new_end = after.length - 1
205
+ while old_end > first && new_end > first && before[old_end] == after[new_end]
206
+ old_end -= 1
207
+ new_end -= 1
208
+ end
209
+
210
+ ctx_start = [first - DIFF_CONTEXT, 0].max
211
+ old_ctx_end = [old_end + DIFF_CONTEXT, before.length - 1].min
212
+ new_ctx_end = [new_end + DIFF_CONTEXT, after.length - 1].min
213
+
214
+ hunk = []
215
+ hunk << "--- #{path}"
216
+ hunk << "+++ #{path}"
217
+ hunk << "@@ -#{ctx_start + 1},#{old_ctx_end - ctx_start + 1} +#{ctx_start + 1},#{new_ctx_end - ctx_start + 1} @@"
218
+ (ctx_start...first).each { |idx| hunk << " #{before[idx]}" }
219
+ (first..old_end).each { |idx| hunk << "-#{before[idx]}" }
220
+ (first..new_end).each { |idx| hunk << "+#{after[idx]}" }
221
+ ((old_end + 1)..old_ctx_end).each { |idx| hunk << " #{before[idx]}" }
222
+
223
+ diff = hunk.join("\n")
224
+ fuzzy ? "(fuzzy match — whitespace differences were ignored)\n#{diff}" : diff
225
+ end
226
+ end
227
+ end
data/lib/tools/read.rb ADDED
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Reads file contents with smart truncation and offset/limit paging.
5
+ # Returns plain text without line numbers, normalized to LF line endings.
6
+ #
7
+ # Truncation limits: `MAX_LINES` lines or `MAX_BYTES` bytes, whichever
8
+ # hits first. When truncated, appends a continuation hint with the next
9
+ # offset value so the agent can page through large files.
10
+ #
11
+ # @example Basic read
12
+ # tool.execute("path" => "config/routes.rb")
13
+ # # => "Rails.application.routes.draw do\n ..."
14
+ #
15
+ # @example Paging through a large file
16
+ # tool.execute("path" => "large.log", "offset" => 2001, "limit" => 500)
17
+ # # => "line 2001 content\n..."
18
+ class Read < Base
19
+ MAX_LINES = 2_000
20
+ MAX_BYTES = 50_000
21
+
22
+ def self.tool_name = "read"
23
+
24
+ def self.description = "Read file contents. Returns plain text with smart truncation. Use offset/limit to page through large files."
25
+
26
+ def self.input_schema
27
+ {
28
+ type: "object",
29
+ properties: {
30
+ path: {type: "string", description: "Absolute or relative file path (relative resolved against working directory)"},
31
+ offset: {type: "integer", description: "1-indexed line number to start from (default: 1)"},
32
+ limit: {type: "integer", description: "Maximum number of lines to read (default: 2000, also limited by #{MAX_BYTES} byte cap)"}
33
+ },
34
+ required: ["path"]
35
+ }
36
+ end
37
+
38
+ # @param shell_session [ShellSession, nil] provides working directory for resolving relative paths
39
+ def initialize(shell_session: nil, **)
40
+ @working_directory = shell_session&.pwd
41
+ end
42
+
43
+ # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
44
+ # @return [String] file contents (possibly truncated with continuation hint)
45
+ # @return [Hash] with :error key on failure
46
+ def execute(input)
47
+ path, offset, limit = extract_params(input)
48
+ return {error: "Path cannot be blank"} if path.empty?
49
+
50
+ path = resolve_path(path)
51
+
52
+ error = validate_file(path)
53
+ return error if error
54
+
55
+ read_file(path, offset, limit)
56
+ end
57
+
58
+ private
59
+
60
+ def extract_params(input)
61
+ path = input["path"].to_s.strip
62
+ offset = [input["offset"].to_i, 1].max
63
+ raw_limit = input["limit"]
64
+ limit = raw_limit ? [raw_limit.to_i, 1].max : MAX_LINES
65
+ [path, offset, limit]
66
+ end
67
+
68
+ def resolve_path(path)
69
+ if @working_directory
70
+ File.expand_path(path, @working_directory)
71
+ else
72
+ File.expand_path(path)
73
+ end
74
+ end
75
+
76
+ def validate_file(path)
77
+ return {error: "File not found: #{path}"} unless File.exist?(path)
78
+ return {error: "Is a directory: #{path}"} if File.directory?(path)
79
+ {error: "Permission denied: #{path}"} unless File.readable?(path)
80
+ end
81
+
82
+ # Reads the file, normalizes line endings, and applies truncation limits.
83
+ # Two limits are enforced as first-hit-wins: line count and byte size.
84
+ # A single line exceeding `MAX_BYTES` is rejected outright (likely minified).
85
+ # Files larger than `MAX_READ_SIZE` are rejected to avoid memory exhaustion.
86
+ MAX_READ_SIZE = 10 * 1024 * 1024 # 10 MB
87
+
88
+ def read_file(path, offset, limit)
89
+ file_size = File.size(path)
90
+ if file_size > MAX_READ_SIZE
91
+ return {error: "File is #{file_size} bytes (#{file_size / 1_048_576} MB). " \
92
+ "Max readable size is #{MAX_READ_SIZE / 1_048_576} MB. " \
93
+ "Use bash tool with: head -n #{offset + limit} #{path} | tail -n +#{offset}"}
94
+ end
95
+
96
+ lines = normalize(File.read(path))
97
+ return "" if lines.empty?
98
+
99
+ start_index = offset - 1
100
+ return "[File has #{lines.size} lines. Offset #{offset} is beyond end of file.]" if start_index >= lines.size
101
+
102
+ window = lines[start_index, [limit, MAX_LINES].min]
103
+
104
+ error = check_oversized_lines(window, offset, path)
105
+ return error if error
106
+
107
+ build_output(window, lines.size, offset)
108
+ end
109
+
110
+ def normalize(content)
111
+ content.gsub("\r\n", "\n").lines
112
+ end
113
+
114
+ def check_oversized_lines(window, offset, path)
115
+ index = window.index { |line| line.bytesize > MAX_BYTES }
116
+ return unless index
117
+
118
+ line_num = offset + index
119
+ {error: "Line #{line_num} exceeds #{MAX_BYTES} bytes (likely minified). " \
120
+ "Use bash tool with: sed -n '#{line_num}p' #{path}"}
121
+ end
122
+
123
+ def build_output(window, total_lines, offset)
124
+ text, count = accumulate_lines(window)
125
+ end_line = offset + count - 1
126
+
127
+ if end_line < total_lines
128
+ text + "\n\n[Showing lines #{offset}-#{end_line} of #{total_lines}. Use offset=#{end_line + 1} to continue.]"
129
+ else
130
+ text
131
+ end
132
+ end
133
+
134
+ # Accumulates lines until `MAX_BYTES` would be exceeded.
135
+ # @return [Array(String, Integer)] accumulated text and number of lines included
136
+ def accumulate_lines(window)
137
+ output = +""
138
+ bytes = 0
139
+ count = 0
140
+
141
+ window.each_with_index do |line, index|
142
+ break if bytes + line.bytesize > MAX_BYTES && index > 0
143
+
144
+ output << line
145
+ bytes += line.bytesize
146
+ count += 1
147
+ end
148
+
149
+ [output, count]
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Tools
6
+ # Creates or overwrites files with automatic intermediate directory creation.
7
+ # Writes content exactly as given — no line ending normalization, no BOM
8
+ # handling. Full replacement only; no append or merge.
9
+ #
10
+ # @example Creating a new file
11
+ # tool.execute("path" => "config/new.yml", "content" => "key: value\n")
12
+ # # => "Wrote 11 bytes to /home/user/project/config/new.yml"
13
+ #
14
+ # @example Overwriting an existing file
15
+ # tool.execute("path" => "README.md", "content" => "# Title\n")
16
+ # # => "Wrote 9 bytes to /home/user/project/README.md"
17
+ class Write < Base
18
+ def self.tool_name = "write"
19
+
20
+ def self.description = "Create or overwrite a file. Creates intermediate directories automatically. Use for new files or full replacement."
21
+
22
+ def self.input_schema
23
+ {
24
+ type: "object",
25
+ properties: {
26
+ path: {type: "string", description: "Absolute or relative file path (relative resolved against working directory)"},
27
+ content: {type: "string", description: "Full file content to write"}
28
+ },
29
+ required: %w[path content]
30
+ }
31
+ end
32
+
33
+ # @param shell_session [ShellSession, nil] provides working directory for resolving relative paths
34
+ def initialize(shell_session: nil, **)
35
+ @working_directory = shell_session&.pwd
36
+ end
37
+
38
+ # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
39
+ # @return [String] confirmation with bytes written and resolved path
40
+ # @return [Hash] with :error key on failure
41
+ def execute(input)
42
+ path, content = extract_params(input)
43
+ return {error: "Path cannot be blank"} if path.empty?
44
+
45
+ path = resolve_path(path)
46
+
47
+ error = validate_target(path)
48
+ return error if error
49
+
50
+ write_file(path, content)
51
+ end
52
+
53
+ private
54
+
55
+ def extract_params(input)
56
+ path = input["path"].to_s.strip
57
+ content = input["content"].to_s
58
+ [path, content]
59
+ end
60
+
61
+ def resolve_path(path)
62
+ if @working_directory
63
+ File.expand_path(path, @working_directory)
64
+ else
65
+ File.expand_path(path)
66
+ end
67
+ end
68
+
69
+ def validate_target(path)
70
+ return {error: "Is a directory: #{path}"} if File.directory?(path)
71
+ {error: "Not writable: #{path}"} if File.exist?(path) && !File.writable?(path)
72
+ end
73
+
74
+ def write_file(path, content)
75
+ FileUtils.mkdir_p(File.dirname(path))
76
+ bytes = File.write(path, content)
77
+ "Wrote #{bytes} bytes to #{path}"
78
+ rescue Errno::EACCES
79
+ {error: "Permission denied: #{path}"}
80
+ rescue Errno::ENOSPC
81
+ {error: "No space left on device: #{path}"}
82
+ rescue Errno::EROFS
83
+ {error: "Read-only file system: #{path}"}
84
+ end
85
+ end
86
+ end