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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcb9e1d40357cd0eabdc5fffa01f8727b449a5b85e6f7b7dbe9033fae461bec9
4
- data.tar.gz: d785a36f13e3a3e698b80dd123544ca6dbee535372b92776a5b7993e4125baa1
3
+ metadata.gz: e3b4a76568897bb6c9d465151e61f6dd889c42c33b71dcf0c019ae561143b2e4
4
+ data.tar.gz: 44acf306c750f33b3bb006c7be209ec80924521f61a89d2e863179672f33751b
5
5
  SHA512:
6
- metadata.gz: d84d91dc67fe56617f294b9a6982e830ac36c242fdb203bfa601c2e6fb009dd8a3d9d5243c4bfa501d7069e40bb08d15d920717374a79825ff1af7b4812713de
7
- data.tar.gz: d61f7ed56737c02f4412bcdee5d15e9b4b8e3b63f45011aa9e8ae2c1a80725413841ad1ed95d7f02ab6a8e532e0a36f0fd9abc39b50adb5fed0dbb21ec599604
6
+ metadata.gz: bb66a71439efafa605eb7e6124269e82a0183c3d133d7e63658a1bdbe1c4f6adc9a4bf3303fb4bc724337319434eded5de40e9134c456ecbb3bb4508500b9370
7
+ data.tar.gz: b06ae76628e9abd1a76832703bc1769f24ea206c39c86b5fc1ef3bcfe9a59cfe87db222042aa7ea277714d18c3fb9c7c460769974fd75edd3b1f00923560e756
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Added
4
+ - Real-time event broadcasting via `Event::Broadcasting` concern — `after_create_commit` and `after_update_commit` callbacks broadcast decorated payloads with database ID and action type to the session's ActionCable stream (#91)
5
+ - TUI `MessageStore` ID-indexed updates — events with `action: "update"` replace existing entries in-place (O(1) lookup) without changing display order
6
+ - `CountEventTokensJob` triggers broadcast — uses `update!` so token count updates push to connected clients in real time
7
+ - Connection status constants in `CableClient` — replaces magic strings with named constants for protocol message types
8
+
9
+ ### Changed
10
+ - Connection status indicator simplified — emoji-only `🟢` for normal state, descriptive text only for abnormal states (#80)
11
+ - `STATUS_STYLES` structure simplified from `{label, fg, bg}` to `{label, color}` (#80)
12
+ - `ActionCableBridge` removed — broadcasting moved from EventBus subscriber to AR callbacks, eliminating the timing gap where events were broadcast before persistence
13
+ - `SessionChannel` history includes event IDs for client-side correlation
14
+
15
+ ### Fixed
16
+ - TUI showed empty chat on reconnect — message store was cleared _after_ history arrived because `confirm_subscription` comes after `transmit` in Action Cable protocol; now clears on "subscribing" before history (#82)
17
+
18
+ ## [0.2.1] - 2026-03-13
19
+
20
+ ### Added
21
+ - TUI view mode switching via `Ctrl+a → v` — cycle between Basic, Verbose, and Debug (#75)
22
+ - Draper EventDecorator hierarchy — structured data decorators for all event types (#74)
23
+ - Decorators return structured hashes (not strings) for transport-layer filtering (#86)
24
+ - Basic mode tool call counter — inline `🔧 Tools: X/Y ✓` aggregation (#73)
25
+ - Verbose view mode rendering — timestamps, tool call previews, system messages (#76)
26
+ - Tool call previews: bash `$ command`, web_get `GET url`, generic JSON fallback
27
+ - Tool response display: truncated to 3 lines, `↩` success / `❌` failure indicators
28
+ - Debug view mode — token counts per message, full tool args/responses, tool use IDs (#77)
29
+ - Estimated token indicator (`~` prefix) for events not yet counted by background job
30
+ - View mode persisted on Session model — survives TUI disconnect/reconnect
31
+ - Mode changes broadcast to all connected clients with re-decorated viewport
32
+
33
+ ### Fixed
34
+ - Newlines in LLM responses collapsed into single line in rendered view modes
35
+ - Loading state stuck after view mode switch — input blocked with "Thinking..."
36
+
3
37
  ## [0.2.0] - 2026-03-10
4
38
 
5
39
  ### Added
data/README.md CHANGED
@@ -130,37 +130,13 @@ Updates: `gem update anima-core` — next launch runs pending migrations automat
130
130
 
131
131
  ### Authentication Setup
132
132
 
133
- Anima uses your Claude Pro/Max subscription for API access. You need a setup-token from Claude Code CLI.
133
+ Anima uses your Claude Pro/Max subscription for API access. You need a setup-token from [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code).
134
134
 
135
- **1. Get a setup-token:**
135
+ 1. Run `claude setup-token` in a terminal to get your token
136
+ 2. In the TUI, press `Ctrl+a → a` to open the token setup popup
137
+ 3. Paste the token and press Enter — Anima validates it against the Anthropic API and saves it to encrypted credentials
136
138
 
137
- ```bash
138
- claude setup-token
139
- ```
140
-
141
- This requires [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and a Claude Pro or Max subscription.
142
-
143
- **2. Store the token in Anima credentials:**
144
-
145
- ```bash
146
- cd $(gem contents anima-core | head -1 | xargs dirname | xargs dirname)
147
- bin/rails credentials:edit
148
- ```
149
-
150
- Add your token:
151
-
152
- ```yaml
153
- anthropic:
154
- subscription_token: sk-ant-oat01-YOUR_TOKEN_HERE
155
- ```
156
-
157
- **3. Verify the token works:**
158
-
159
- ```bash
160
- bin/rails runner "Providers::Anthropic.validate!"
161
- ```
162
-
163
- If the token expires or is revoked, repeat steps 1-2 with a new token.
139
+ The popup also activates automatically when Anima detects a missing or invalid token. If the token expires, repeat the process with a new one.
164
140
 
165
141
  ### Three Layers (mirroring biology)
166
142
 
@@ -186,7 +162,7 @@ Five event types form the agent's nervous system:
186
162
 
187
163
  Events flow through two channels:
188
164
  1. **In-process** — Rails Structured Event Reporter (local subscribers like Persister)
189
- 2. **Over the wire** — Action Cable WebSocket (the ActionCableBridge subscriber forwards events to connected TUI clients)
165
+ 2. **Over the wire** — Action Cable WebSocket (`Event::Broadcasting` callbacks push to connected TUI clients)
190
166
 
191
167
  Events fire, subscribers react, state updates, the cortex (LLM) reads the resulting desire landscape. The system prompt is assembled separately for each LLM call — it is not an event.
192
168
 
@@ -194,7 +170,19 @@ Events fire, subscribers react, state updates, the cortex (LLM) reads the result
194
170
 
195
171
  There is no linear chat history. There are only events attached to a session. The context window is a **viewport** — a sliding window over the event stream, assembled on demand for each LLM call within a configured token budget.
196
172
 
197
- Currently uses a simple sliding window (newest events first, walk backwards until budget exhausted). Future versions will add multi-resolution compression with Draper decorators and associative recall from Mneme.
173
+ Currently uses a simple sliding window (newest events first, walk backwards until budget exhausted). Future versions will add associative recall from Mneme.
174
+
175
+ ### TUI View Modes
176
+
177
+ Three switchable view modes let you control how much detail the TUI shows. Cycle with `Ctrl+a → v`:
178
+
179
+ | Mode | What you see |
180
+ |------|-------------|
181
+ | **Basic** (default) | User + assistant messages. Tool calls are hidden but summarized as an inline counter: `🔧 Tools: 2/2 ✓` |
182
+ | **Verbose** | Everything in Basic, plus timestamps `[HH:MM:SS]`, tool call previews (`🔧 bash` / `$ command` / `↩ response`), and system messages |
183
+ | **Debug** | Full X-ray view — timestamps, token counts per message (`[14 tok]`), full tool call args, full tool responses, tool use IDs |
184
+
185
+ View modes are implemented via Draper decorators that operate at the transport layer. Each event type has a dedicated decorator (`UserMessageDecorator`, `ToolCallDecorator`, etc.) that returns structured data — the TUI renders it. Mode is stored on the `Session` model server-side, so it persists across reconnections.
198
186
 
199
187
  ### Brain as Microservices on a Shared Event Bus
200
188
 
@@ -331,7 +319,7 @@ This single example demonstrates every core principle:
331
319
 
332
320
  ## Status
333
321
 
334
- **Core agent complete.** The conversational agent works end-to-end: event-driven architecture, LLM integration with tool calling (bash, web), sliding viewport context assembly, persistent sessions, and client-server architecture with WebSocket transport and graceful reconnection.
322
+ **Core agent complete.** The conversational agent works end-to-end: event-driven architecture, LLM integration with tool calling (bash, web), sliding viewport context assembly, persistent sessions, client-server architecture with WebSocket transport, graceful reconnection, and three TUI view modes (Basic/Verbose/Debug) via Draper decorators.
335
323
 
336
324
  The hormonal system (Thymos, feelings, desires), semantic memory (Mneme), and soul matrix (Psyche) are designed but not yet implemented — they're the next layer on top of the working agent.
337
325
 
data/anima-core.gemspec CHANGED
@@ -28,6 +28,7 @@ Gem::Specification.new do |spec|
28
28
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
29
  spec.require_paths = ["lib"]
30
30
 
31
+ spec.add_dependency "draper", "~> 4.0"
31
32
  spec.add_dependency "foreman", "~> 0.88"
32
33
  spec.add_dependency "httparty", "~> 0.24"
33
34
  spec.add_dependency "puma", "~> 6.0"
@@ -14,18 +14,25 @@ class SessionChannel < ApplicationCable::Channel
14
14
  MAX_LIST_LIMIT = 50
15
15
 
16
16
  # Subscribes the client to the session-specific stream.
17
- # Rejects the subscription if no valid session_id is provided.
18
- # Transmits chat history to the subscribing client after confirmation.
17
+ # When a valid session_id is provided, subscribes to that session.
18
+ # When omitted or zero, resolves to the most recent session (creating
19
+ # one if none exist) — this is the CQRS-compliant path where the
20
+ # server owns session resolution instead of a REST endpoint.
19
21
  #
20
- # @param params [Hash] must include :session_id (positive integer)
22
+ # Always transmits a session_changed signal so the client learns
23
+ # the authoritative session ID, followed by view_mode and history.
24
+ #
25
+ # @param params [Hash] optional :session_id (positive integer)
21
26
  def subscribed
22
- @current_session_id = params[:session_id].to_i
23
- if @current_session_id > 0
24
- stream_from stream_name
25
- transmit_history
26
- else
27
- reject
28
- end
27
+ @current_session_id = resolve_session_id
28
+ stream_from stream_name
29
+
30
+ session = Session.find_by(id: @current_session_id)
31
+ return unless session
32
+
33
+ transmit_session_changed(session)
34
+ transmit_view_mode(session)
35
+ transmit_history(session)
29
36
  end
30
37
 
31
38
  # Receives messages from clients and broadcasts them to all session subscribers.
@@ -36,14 +43,43 @@ class SessionChannel < ApplicationCable::Channel
36
43
  end
37
44
 
38
45
  # Processes user input: persists the message and enqueues LLM processing.
46
+ # When the session is actively processing an agent request, the message
47
+ # is queued as "pending" and picked up after the current loop completes.
39
48
  #
40
49
  # @param data [Hash] must include "content" with the user's message text
41
50
  def speak(data)
42
51
  content = data["content"].to_s.strip
43
- return if content.empty? || !Session.exists?(@current_session_id)
52
+ return if content.empty?
53
+
54
+ session = Session.find_by(id: @current_session_id)
55
+ return unless session
56
+
57
+ if session.processing?
58
+ Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id, status: Event::PENDING_STATUS))
59
+ else
60
+ Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id))
61
+ AgentRequestJob.perform_later(@current_session_id)
62
+ end
63
+ end
64
+
65
+ # Recalls the most recent pending message for editing. Deletes the
66
+ # pending event and broadcasts the recall so all clients remove it.
67
+ #
68
+ # @param data [Hash] must include "event_id" (positive integer)
69
+ def recall_pending(data)
70
+ event_id = data["event_id"].to_i
71
+ return if event_id <= 0
72
+
73
+ event = Event.find_by(
74
+ id: event_id,
75
+ session_id: @current_session_id,
76
+ event_type: "user_message",
77
+ status: Event::PENDING_STATUS
78
+ )
79
+ return unless event
44
80
 
45
- Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id))
46
- AgentRequestJob.perform_later(@current_session_id)
81
+ event.destroy!
82
+ ActionCable.server.broadcast(stream_name, {"action" => "user_message_recalled", "event_id" => event_id})
47
83
  end
48
84
 
49
85
  # Returns recent sessions with metadata for session picker UI.
@@ -85,12 +121,73 @@ class SessionChannel < ApplicationCable::Channel
85
121
  transmit_error("Session not found")
86
122
  end
87
123
 
124
+ # Validates and saves an Anthropic subscription token to encrypted credentials.
125
+ # Format-validated and API-validated before storage. The token never enters the
126
+ # LLM context window — it flows directly from WebSocket to encrypted credentials.
127
+ #
128
+ # @param data [Hash] must include "token" (Anthropic subscription token string)
129
+ def save_token(data)
130
+ token = data["token"].to_s.strip
131
+
132
+ Providers::Anthropic.validate_token_format!(token)
133
+ Providers::Anthropic.validate_token_api!(token)
134
+ write_anthropic_token(token)
135
+
136
+ transmit({"action" => "token_saved"})
137
+ rescue Providers::Anthropic::TokenFormatError, Providers::Anthropic::AuthenticationError => error
138
+ transmit({"action" => "token_error", "message" => error.message})
139
+ end
140
+
141
+ # Changes the session's view mode and re-broadcasts the viewport.
142
+ # All clients on the session receive the mode change and fresh history.
143
+ #
144
+ # @param data [Hash] must include "view_mode" (one of Session::VIEW_MODES)
145
+ def change_view_mode(data)
146
+ mode = data["view_mode"].to_s
147
+ return transmit_error("Invalid view mode") unless Session::VIEW_MODES.include?(mode)
148
+
149
+ session = Session.find(@current_session_id)
150
+ session.update!(view_mode: mode)
151
+
152
+ ActionCable.server.broadcast(stream_name, {"action" => "view_mode_changed", "view_mode" => mode})
153
+ broadcast_viewport(session)
154
+ rescue ActiveRecord::RecordNotFound
155
+ transmit_error("Session not found")
156
+ end
157
+
88
158
  private
89
159
 
90
160
  def stream_name
91
161
  "session_#{@current_session_id}"
92
162
  end
93
163
 
164
+ # Resolves the session to subscribe to. Uses the client-provided ID
165
+ # when valid, otherwise falls back to the most recent session or
166
+ # creates a new one.
167
+ #
168
+ # @return [Integer] resolved session ID
169
+ def resolve_session_id
170
+ id = params[:session_id].to_i
171
+ return id if id > 0
172
+
173
+ (Session.recent(1).first || Session.create!).id
174
+ end
175
+
176
+ # Transmits session metadata as a session_changed signal.
177
+ # Used on initial subscription and after session switches so the
178
+ # client can handle both paths with a single code path.
179
+ #
180
+ # @param session [Session] the session to announce
181
+ # @return [void]
182
+ def transmit_session_changed(session)
183
+ transmit({
184
+ "action" => "session_changed",
185
+ "session_id" => session.id,
186
+ "message_count" => session.events.llm_messages.count,
187
+ "view_mode" => session.view_mode
188
+ })
189
+ end
190
+
94
191
  # Switches the channel to a different session: stops current stream,
95
192
  # updates the session reference, starts the new stream, and sends
96
193
  # a session_changed signal followed by chat history.
@@ -98,26 +195,123 @@ class SessionChannel < ApplicationCable::Channel
98
195
  stop_all_streams
99
196
  @current_session_id = new_id
100
197
  stream_from stream_name
198
+
101
199
  session = Session.find(new_id)
102
- transmit({
103
- "action" => "session_changed",
104
- "session_id" => new_id,
105
- "message_count" => session.events.llm_messages.count
106
- })
107
- transmit_history
200
+ transmit_session_changed(session)
201
+ transmit_history(session)
108
202
  end
109
203
 
110
- # Sends displayable events from the LLM's viewport to the subscribing
111
- # client. The TUI shows exactly what the agent can see — no more, no less.
112
- def transmit_history
113
- session = Session.find_by(id: @current_session_id)
114
- return unless session
204
+ # Transmits the current view_mode so the TUI initializes correctly.
205
+ # Sends `{action: "view_mode", view_mode: <mode>}` to the subscribing client.
206
+ #
207
+ # @param session [Session] the session whose view_mode to transmit
208
+ # @return [void]
209
+ def transmit_view_mode(session)
210
+ transmit({"action" => "view_mode", "view_mode" => session.view_mode})
211
+ end
212
+
213
+ # Sends decorated context events (messages + tool interactions) from
214
+ # the LLM's viewport to the subscribing client. Each event is wrapped
215
+ # in an {EventDecorator} and the pre-rendered output is included in
216
+ # the transmitted payload. Tool events are included so the TUI can
217
+ # reconstruct tool call counters on reconnect.
218
+ # In debug mode, prepends the assembled system prompt as a special block.
219
+ #
220
+ # @param session [Session] the session whose history to transmit
221
+ def transmit_history(session)
222
+ transmit_system_prompt(session) if session.view_mode == "debug"
223
+
224
+ session.viewport_events.each do |event|
225
+ transmit(decorate_event_payload(event, session.view_mode))
226
+ end
227
+ end
228
+
229
+ # Broadcasts the re-decorated viewport to all clients on the session stream.
230
+ # Used after a view mode change to refresh all connected clients.
231
+ # In debug mode, prepends the assembled system prompt as a special block.
232
+ # @param session [Session] the session whose viewport to broadcast
233
+ # @return [void]
234
+ def broadcast_viewport(session)
235
+ broadcast_system_prompt(session) if session.view_mode == "debug"
115
236
 
116
237
  session.viewport_events.each do |event|
117
- next unless event.llm_message?
238
+ ActionCable.server.broadcast(stream_name, decorate_event_payload(event, session.view_mode))
239
+ end
240
+ end
241
+
242
+ # Decorates an event for transmission to clients. Merges the event's
243
+ # database ID and structured decorator output into the payload.
244
+ # Used by {#transmit_history} and {#broadcast_viewport} for historical
245
+ # and viewport re-broadcast — live broadcasts use {Event::Broadcasting}.
246
+ #
247
+ # @param event [Event] persisted event record
248
+ # @param mode [String] view mode for decoration (default: "basic")
249
+ # @return [Hash] payload with "id" and optional "rendered" key
250
+ def decorate_event_payload(event, mode = "basic")
251
+ payload = event.payload.merge("id" => event.id)
252
+ decorator = EventDecorator.for(event)
253
+ return payload unless decorator
254
+
255
+ payload.merge("rendered" => {mode => decorator.render(mode)})
256
+ end
257
+
258
+ # Transmits the assembled system prompt to the subscribing client.
259
+ # Skipped when the session has no system prompt configured.
260
+ # @param session [Session]
261
+ # @return [void]
262
+ def transmit_system_prompt(session)
263
+ payload = system_prompt_payload(session)
264
+ return unless payload
265
+
266
+ transmit(payload)
267
+ end
268
+
269
+ # Broadcasts the assembled system prompt to all clients on the stream.
270
+ # Skipped when the session has no system prompt configured.
271
+ # @param session [Session]
272
+ # @return [void]
273
+ def broadcast_system_prompt(session)
274
+ payload = system_prompt_payload(session)
275
+ return unless payload
276
+
277
+ ActionCable.server.broadcast(stream_name, payload)
278
+ end
279
+
280
+ # Builds the system prompt payload for debug mode transmission.
281
+ # @param session [Session]
282
+ # @return [Hash, nil] the system prompt payload, or nil if no prompt
283
+ def system_prompt_payload(session)
284
+ prompt = session.system_prompt
285
+ return unless prompt
118
286
 
119
- transmit(event.payload)
287
+ tokens = [(prompt.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
288
+ {
289
+ "type" => "system_prompt",
290
+ "rendered" => {
291
+ "debug" => {role: :system_prompt, content: prompt, tokens: tokens, estimated: true}
292
+ }
293
+ }
294
+ end
295
+
296
+ # Merges the Anthropic subscription token into encrypted credentials,
297
+ # preserving existing keys (e.g. secret_key_base).
298
+ #
299
+ # @param token [String] validated Anthropic subscription token
300
+ # @return [void]
301
+ def write_anthropic_token(token)
302
+ creds = Rails.application.credentials
303
+ existing = begin
304
+ YAML.safe_load(creds.read) || {}
305
+ rescue ActiveSupport::EncryptedFile::MissingContentError
306
+ {}
120
307
  end
308
+ existing["anthropic"] ||= {}
309
+ existing["anthropic"]["subscription_token"] = token
310
+ creds.write(existing.to_yaml)
311
+ # Rails memoizes the decrypted config in @config. Without clearing it,
312
+ # subsequent credential reads return stale data. No public API exists
313
+ # for cache invalidation as of Rails 8.1.
314
+ creds.instance_variable_set(:@config, nil)
121
315
  end
122
316
 
123
317
  def transmit_error(message)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates agent_message events for display in the TUI.
4
+ # Basic mode returns role and content. Verbose mode adds a timestamp.
5
+ # Debug mode adds token count (exact when counted, estimated when not).
6
+ class AgentMessageDecorator < EventDecorator
7
+ # @return [Hash] structured agent message data
8
+ # `{role: :assistant, content: String}`
9
+ def render_basic
10
+ {role: :assistant, content: content}
11
+ end
12
+
13
+ # @return [Hash] structured agent message with nanosecond timestamp
14
+ # `{role: :assistant, content: String, timestamp: Integer|nil}`
15
+ def render_verbose
16
+ {role: :assistant, content: content, timestamp: timestamp}
17
+ end
18
+
19
+ # @return [Hash] verbose output plus token count for debugging
20
+ # `{role: :assistant, content: String, timestamp: Integer|nil, tokens: Integer, estimated: Boolean}`
21
+ def render_debug
22
+ render_verbose.merge(token_info)
23
+ end
24
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base decorator for the application. All Draper decorators inherit from
4
+ # this class to share common configuration and helpers.
5
+ class ApplicationDecorator < Draper::Decorator
6
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base decorator for {Event} records, providing multi-resolution rendering
4
+ # for the TUI. Each event type has a dedicated subclass that implements
5
+ # rendering methods for each view mode (basic, verbose, debug).
6
+ #
7
+ # Decorators return structured hashes (not pre-formatted strings) so that
8
+ # the TUI can style and lay out content based on semantic role, without
9
+ # fragile regex parsing. The TUI receives structured data via ActionCable
10
+ # and formats it for display.
11
+ #
12
+ # Subclasses must override {#render_basic}. Verbose and debug modes
13
+ # delegate to basic until subclasses provide their own implementations.
14
+ #
15
+ # @example Decorate an Event AR model
16
+ # decorator = EventDecorator.for(event)
17
+ # decorator.render_basic #=> {role: :user, content: "hello"} or nil
18
+ #
19
+ # @example Render for a specific view mode
20
+ # decorator = EventDecorator.for(event)
21
+ # decorator.render("verbose") #=> {role: :user, content: "hello", timestamp: 1709312325000000000}
22
+ #
23
+ # @example Decorate a raw payload hash (from EventBus)
24
+ # decorator = EventDecorator.for(type: "user_message", content: "hello")
25
+ # decorator.render_basic #=> {role: :user, content: "hello"}
26
+ class EventDecorator < ApplicationDecorator
27
+ delegate_all
28
+
29
+ TOOL_ICON = "\u{1F527}"
30
+ RETURN_ARROW = "\u21A9"
31
+ ERROR_ICON = "\u274C"
32
+
33
+ DECORATOR_MAP = {
34
+ "user_message" => "UserMessageDecorator",
35
+ "agent_message" => "AgentMessageDecorator",
36
+ "tool_call" => "ToolCallDecorator",
37
+ "tool_response" => "ToolResponseDecorator",
38
+ "system_message" => "SystemMessageDecorator"
39
+ }.freeze
40
+ private_constant :DECORATOR_MAP
41
+
42
+ # Normalizes hash payloads into an Event-like interface so decorators
43
+ # can use {#payload}, {#event_type}, etc. uniformly on both AR models
44
+ # and raw EventBus hashes.
45
+ #
46
+ # @!attribute event_type [r] the event's type (e.g. "user_message")
47
+ # @!attribute payload [r] string-keyed hash of event data
48
+ # @!attribute timestamp [r] nanosecond-precision timestamp
49
+ # @!attribute token_count [r] cumulative token count
50
+ EventPayload = Struct.new(:event_type, :payload, :timestamp, :token_count, keyword_init: true) do
51
+ # Heuristic token estimate matching {Event#estimate_tokens} so decorators
52
+ # can call it uniformly on both AR models and hash payloads.
53
+ # @return [Integer] at least 1
54
+ def estimate_tokens
55
+ text = if event_type.to_s.in?(%w[tool_call tool_response])
56
+ payload.to_json
57
+ else
58
+ payload&.dig("content").to_s
59
+ end
60
+ [(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
61
+ end
62
+ end
63
+
64
+ # Factory returning the appropriate subclass decorator for the given event.
65
+ # Hashes are normalized via {EventPayload} to provide a uniform interface.
66
+ #
67
+ # @param event [Event, Hash] an Event AR model or a raw payload hash
68
+ # @return [EventDecorator, nil] decorated event, or nil for unknown types
69
+ def self.for(event)
70
+ source = wrap_source(event)
71
+ klass_name = DECORATOR_MAP[source.event_type]
72
+ return nil unless klass_name
73
+
74
+ klass_name.constantize.new(source)
75
+ end
76
+
77
+ RENDER_DISPATCH = {
78
+ "basic" => :render_basic,
79
+ "verbose" => :render_verbose,
80
+ "debug" => :render_debug
81
+ }.freeze
82
+ private_constant :RENDER_DISPATCH
83
+
84
+ # Dispatches to the render method for the given view mode.
85
+ #
86
+ # @param mode [String] one of "basic", "verbose", "debug"
87
+ # @return [Hash, nil] structured event data, or nil to hide the event
88
+ # @raise [ArgumentError] if the mode is not a valid view mode
89
+ def render(mode)
90
+ method = RENDER_DISPATCH[mode]
91
+ raise ArgumentError, "Invalid view mode: #{mode.inspect}" unless method
92
+
93
+ public_send(method)
94
+ end
95
+
96
+ # @abstract Subclasses must implement to render the event for basic view mode.
97
+ # @return [Hash, nil] structured event data, or nil to hide the event
98
+ def render_basic
99
+ raise NotImplementedError, "#{self.class} must implement #render_basic"
100
+ end
101
+
102
+ # Verbose view mode with timestamps and tool details.
103
+ # Delegates to {#render_basic} until subclasses provide their own implementations.
104
+ # @return [Hash, nil] structured event data, or nil to hide the event
105
+ def render_verbose
106
+ render_basic
107
+ end
108
+
109
+ # Debug view mode with token counts and system prompts.
110
+ # Delegates to {#render_basic} until subclasses provide their own implementations.
111
+ # @return [Hash, nil] structured event data, or nil to hide the event
112
+ def render_debug
113
+ render_basic
114
+ end
115
+
116
+ private
117
+
118
+ # Token count for display: exact count from {CountEventTokensJob} when
119
+ # available, heuristic estimate otherwise. Estimated counts are flagged
120
+ # so the TUI can prefix them with a tilde.
121
+ #
122
+ # @return [Hash] `{tokens: Integer, estimated: Boolean}`
123
+ def token_info
124
+ count = token_count.to_i
125
+ if count > 0
126
+ {tokens: count, estimated: false}
127
+ else
128
+ {tokens: estimate_token_count, estimated: true}
129
+ end
130
+ end
131
+
132
+ # Delegates to the underlying object's heuristic token estimator.
133
+ # Both {Event} AR models and {EventPayload} structs implement this.
134
+ #
135
+ # @return [Integer] at least 1
136
+ def estimate_token_count
137
+ object.estimate_tokens
138
+ end
139
+
140
+ # Extracts display content from the event payload.
141
+ # @return [String, nil]
142
+ def content
143
+ payload["content"]
144
+ end
145
+
146
+ # Truncates multi-line text, appending "..." when lines exceed the limit.
147
+ # @param text [String, nil] text to truncate (nil is coerced to empty string)
148
+ # @param max_lines [Integer] maximum number of lines to keep
149
+ # @return [String] truncated text
150
+ def truncate_lines(text, max_lines:)
151
+ str = text.to_s
152
+ lines = str.split("\n")
153
+ return str unless lines.size > max_lines
154
+
155
+ lines.first(max_lines).push("...").join("\n")
156
+ end
157
+
158
+ # Normalizes input to something Draper can wrap.
159
+ # Event AR models pass through; hashes become EventPayload structs
160
+ # with string-normalized keys.
161
+ def self.wrap_source(event)
162
+ return event unless event.is_a?(Hash)
163
+
164
+ normalized = event.transform_keys(&:to_s)
165
+ EventPayload.new(
166
+ event_type: normalized["type"].to_s,
167
+ payload: normalized,
168
+ timestamp: normalized["timestamp"],
169
+ token_count: normalized["token_count"]&.to_i || 0
170
+ )
171
+ end
172
+ private_class_method :wrap_source
173
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates system_message events for display in the TUI.
4
+ # Hidden in basic mode. Verbose and debug modes return timestamped system info.
5
+ class SystemMessageDecorator < EventDecorator
6
+ # @return [nil] system messages are hidden in basic mode
7
+ def render_basic
8
+ nil
9
+ end
10
+
11
+ # @return [Hash] structured system message data
12
+ # `{role: :system, content: String, timestamp: Integer|nil}`
13
+ def render_verbose
14
+ {role: :system, content: content, timestamp: timestamp}
15
+ end
16
+
17
+ # @return [Hash] same as verbose — system messages have no additional debug data
18
+ def render_debug
19
+ render_verbose
20
+ end
21
+ end