anima-core 1.0.2 → 1.1.1

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +51 -0
  4. data/README.md +63 -29
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +30 -11
  7. data/app/decorators/tool_call_decorator.rb +32 -3
  8. data/app/decorators/tool_decorator.rb +57 -0
  9. data/app/decorators/tool_response_decorator.rb +12 -4
  10. data/app/decorators/web_get_tool_decorator.rb +102 -0
  11. data/app/jobs/agent_request_job.rb +93 -23
  12. data/app/jobs/mneme_job.rb +51 -0
  13. data/app/jobs/passive_recall_job.rb +29 -0
  14. data/app/models/concerns/event/broadcasting.rb +4 -0
  15. data/app/models/event.rb +10 -0
  16. data/app/models/goal.rb +27 -0
  17. data/app/models/goal_pinned_event.rb +11 -0
  18. data/app/models/pinned_event.rb +41 -0
  19. data/app/models/session.rb +402 -6
  20. data/app/models/snapshot.rb +76 -0
  21. data/bin/jobs +5 -0
  22. data/config/initializers/event_subscribers.rb +12 -3
  23. data/config/initializers/fts5_schema_dump.rb +21 -0
  24. data/config/queue.yml +0 -1
  25. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  26. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  27. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  28. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  29. data/lib/agent_loop.rb +63 -20
  30. data/lib/analytical_brain/runner.rb +158 -65
  31. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  32. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  33. data/lib/anima/cli.rb +32 -9
  34. data/lib/anima/installer.rb +11 -24
  35. data/lib/anima/settings.rb +59 -0
  36. data/lib/anima/spinner.rb +75 -0
  37. data/lib/anima/version.rb +1 -1
  38. data/lib/environment_probe.rb +4 -4
  39. data/lib/events/bounce_back.rb +37 -0
  40. data/lib/events/subscribers/persister.rb +19 -0
  41. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  42. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  43. data/lib/events/tool_call.rb +5 -3
  44. data/lib/llm/client.rb +19 -9
  45. data/lib/mneme/compressed_viewport.rb +200 -0
  46. data/lib/mneme/l2_runner.rb +138 -0
  47. data/lib/mneme/passive_recall.rb +69 -0
  48. data/lib/mneme/runner.rb +254 -0
  49. data/lib/mneme/search.rb +150 -0
  50. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  51. data/lib/mneme/tools/everything_ok.rb +24 -0
  52. data/lib/mneme/tools/save_snapshot.rb +68 -0
  53. data/lib/mneme.rb +29 -0
  54. data/lib/providers/anthropic.rb +57 -13
  55. data/lib/shell_session.rb +194 -63
  56. data/lib/tasks/fts5.rake +6 -0
  57. data/lib/tools/base.rb +2 -1
  58. data/lib/tools/bash.rb +4 -2
  59. data/lib/tools/registry.rb +22 -3
  60. data/lib/tools/remember.rb +179 -0
  61. data/lib/tools/request_feature.rb +3 -1
  62. data/lib/tools/spawn_specialist.rb +21 -9
  63. data/lib/tools/spawn_subagent.rb +22 -11
  64. data/lib/tools/subagent_prompts.rb +20 -3
  65. data/lib/tools/web_get.rb +21 -10
  66. data/lib/tui/app.rb +222 -125
  67. data/lib/tui/decorators/base_decorator.rb +165 -0
  68. data/lib/tui/decorators/bash_decorator.rb +20 -0
  69. data/lib/tui/decorators/edit_decorator.rb +19 -0
  70. data/lib/tui/decorators/read_decorator.rb +24 -0
  71. data/lib/tui/decorators/think_decorator.rb +36 -0
  72. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  73. data/lib/tui/decorators/write_decorator.rb +19 -0
  74. data/lib/tui/flash.rb +139 -0
  75. data/lib/tui/formatting.rb +28 -0
  76. data/lib/tui/height_map.rb +93 -0
  77. data/lib/tui/message_store.rb +97 -8
  78. data/lib/tui/performance_logger.rb +90 -0
  79. data/lib/tui/screens/chat.rb +358 -133
  80. data/templates/config.toml +47 -0
  81. data/templates/soul.md +1 -1
  82. metadata +83 -4
  83. data/CHANGELOG.md +0 -80
  84. data/Gemfile +0 -17
  85. data/lib/tools/return_result.rb +0 -81
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Decorators
5
+ # Renders edit tool calls and responses.
6
+ # Calls show the file path with a pencil icon.
7
+ class EditDecorator < BaseDecorator
8
+ ICON = "\u270F\uFE0F" # pencil
9
+
10
+ def icon
11
+ ICON
12
+ end
13
+
14
+ def color
15
+ "yellow"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Decorators
5
+ # Renders read tool calls and responses.
6
+ # Calls show the file path with a page icon.
7
+ # Responses show file content in dim text.
8
+ class ReadDecorator < BaseDecorator
9
+ ICON = "\u{1F4C4}" # page facing up
10
+
11
+ def icon
12
+ ICON
13
+ end
14
+
15
+ def color
16
+ "cyan"
17
+ end
18
+
19
+ def response_color
20
+ "dark_gray"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Decorators
5
+ # Renders think tool events — the agent's inner reasoning.
6
+ # "aloud" thoughts use yellow (narration for the user), "inner"
7
+ # thoughts use dark_gray (dimmed to signal internality).
8
+ class ThinkDecorator < BaseDecorator
9
+ THOUGHT_BUBBLE = "\u{1F4AD}" # thought balloon
10
+
11
+ def icon
12
+ THOUGHT_BUBBLE
13
+ end
14
+
15
+ # Think events always dispatch here via BaseDecorator#render.
16
+ #
17
+ # @param tui [RatatuiRuby] TUI rendering API
18
+ # @return [Array<RatatuiRuby::Widgets::Line>]
19
+ def render_think(tui)
20
+ aloud = data["visibility"] == "aloud"
21
+ fg = aloud ? "yellow" : "dark_gray"
22
+ style = tui.style(fg: fg)
23
+ ts = data["timestamp"]
24
+
25
+ meta = []
26
+ meta << "[#{format_ns_timestamp(ts)}]" if ts
27
+ header = meta.empty? ? icon : "#{meta.join(" ")} #{icon}"
28
+
29
+ content_lines = data["content"].to_s.split("\n", -1)
30
+ lines = [tui.line(spans: [tui.span(content: "#{header} #{content_lines.first}", style: style)])]
31
+ content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
32
+ lines
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Decorators
5
+ # Renders web_get tool calls and responses.
6
+ # Calls show the URL with a globe icon.
7
+ class WebGetDecorator < BaseDecorator
8
+ ICON = "\u{1F310}" # globe with meridians
9
+
10
+ def icon
11
+ ICON
12
+ end
13
+
14
+ def color
15
+ "blue"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Decorators
5
+ # Renders write tool calls and responses.
6
+ # Calls show the file path with a memo icon.
7
+ class WriteDecorator < BaseDecorator
8
+ ICON = "\u{1F4DD}" # memo
9
+
10
+ def icon
11
+ ICON
12
+ end
13
+
14
+ def color
15
+ "yellow"
16
+ end
17
+ end
18
+ end
19
+ end
data/lib/tui/flash.rb ADDED
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ # Ephemeral notification system for the TUI, modeled after Rails flash
5
+ # messages. Notifications render as a colored bar at the top of the
6
+ # chat pane and auto-dismiss after a configurable timeout or on any
7
+ # keypress.
8
+ #
9
+ # Reusable beyond Bounce Back — useful for connection status changes,
10
+ # background task notifications, and any transient user feedback that
11
+ # doesn't belong in the chat stream.
12
+ #
13
+ # @example Adding a flash
14
+ # flash = TUI::Flash.new
15
+ # flash.error("Message not delivered: API token not configured")
16
+ # flash.warning("Rate limited, retry in 30s")
17
+ # flash.info("Reconnected to server")
18
+ #
19
+ # @example Rendering (returns height consumed)
20
+ # flash_height = flash.render(frame, area, tui)
21
+ #
22
+ # @example Dismissing
23
+ # flash.dismiss!
24
+ class Flash
25
+ AUTO_DISMISS_SECONDS = 5.0
26
+
27
+ # Flash area occupies at most 1/3 of the chat pane height.
28
+ MAX_HEIGHT_FRACTION = 3
29
+
30
+ Entry = Struct.new(:message, :level, :created_at, keyword_init: true)
31
+
32
+ LEVEL_STYLES = {
33
+ error: {fg: "white", bg: "red", icon: " \u2718 "},
34
+ warning: {fg: "black", bg: "yellow", icon: " \u26A0 "},
35
+ info: {fg: "white", bg: "blue", icon: " \u2139 "}
36
+ }.freeze
37
+
38
+ def initialize
39
+ @entries = []
40
+ end
41
+
42
+ # @param message [String]
43
+ def error(message)
44
+ push(message, :error)
45
+ end
46
+
47
+ # @param message [String]
48
+ def warning(message)
49
+ push(message, :warning)
50
+ end
51
+
52
+ # @param message [String]
53
+ def info(message)
54
+ push(message, :info)
55
+ end
56
+
57
+ # Removes expired entries and returns true if any remain.
58
+ def any?
59
+ expire!
60
+ @entries.any?
61
+ end
62
+
63
+ # @return [Boolean]
64
+ def empty?
65
+ !any?
66
+ end
67
+
68
+ # Removes all entries immediately (e.g. on keypress).
69
+ def dismiss!
70
+ @entries.clear
71
+ end
72
+
73
+ # Renders flash entries as colored bars at the top of the given area.
74
+ # Returns the height consumed so the caller can adjust layout.
75
+ #
76
+ # @param frame [RatatuiRuby::Frame]
77
+ # @param area [RatatuiRuby::Rect] full chat area (flash renders at top)
78
+ # @param tui [RatatuiRuby::TUI]
79
+ # @return [Integer] number of rows consumed
80
+ def render(frame, area, tui)
81
+ expire!
82
+ return 0 if @entries.empty?
83
+
84
+ height = [@entries.size, area.height / MAX_HEIGHT_FRACTION].min
85
+
86
+ flash_area, _ = tui.split(
87
+ area,
88
+ direction: :vertical,
89
+ constraints: [
90
+ tui.constraint_length(height),
91
+ tui.constraint_fill(1)
92
+ ]
93
+ )
94
+
95
+ @entries.each_with_index do |entry, index|
96
+ break if index >= height
97
+
98
+ row_area = row_rect(flash_area, index, tui)
99
+ render_entry(frame, row_area, entry, tui)
100
+ end
101
+
102
+ height
103
+ end
104
+
105
+ private
106
+
107
+ def push(message, level)
108
+ @entries << Entry.new(message: message, level: level, created_at: monotonic_now)
109
+ end
110
+
111
+ def expire!
112
+ now = monotonic_now
113
+ @entries.reject! { |entry| now - entry.created_at > AUTO_DISMISS_SECONDS }
114
+ end
115
+
116
+ def row_rect(area, index, tui)
117
+ rows = (0...area.height).map { tui.constraint_length(1) }
118
+ chunks = tui.split(area, direction: :vertical, constraints: rows)
119
+ chunks[index]
120
+ end
121
+
122
+ def render_entry(frame, area, entry, tui)
123
+ config = LEVEL_STYLES.fetch(entry.level, LEVEL_STYLES[:info])
124
+ style = tui.style(fg: config[:fg], bg: config[:bg], modifiers: [:bold])
125
+
126
+ text = "#{config[:icon]}#{entry.message} "
127
+ # Pad to full width so background color fills the entire row
128
+ padded = text.ljust(area.width)
129
+
130
+ line = tui.line(spans: [tui.span(content: padded, style: style)])
131
+ widget = tui.paragraph(text: [line])
132
+ frame.render_widget(widget, area)
133
+ end
134
+
135
+ def monotonic_now
136
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ # Shared formatting helpers for timestamps and token counts.
5
+ # Used by both the Chat screen and client-side decorators
6
+ # to avoid duplicating display logic.
7
+ module Formatting
8
+ # Formats a token count for display, with tilde prefix for estimates.
9
+ # @param tokens [Integer, nil] token count
10
+ # @param estimated [Boolean] whether the count is an estimate
11
+ # @return [String] formatted label, e.g. "[42 tok]" or "[~28 tok]"
12
+ def format_token_label(tokens, estimated)
13
+ return "" unless tokens
14
+
15
+ label = estimated ? "~#{tokens}" : tokens.to_s
16
+ "[#{label} tok]"
17
+ end
18
+
19
+ # Converts nanosecond-precision timestamp to human-readable HH:MM:SS.
20
+ # @param ns [Integer, nil] nanosecond timestamp
21
+ # @return [String] formatted time, or "--:--:--" when nil
22
+ def format_ns_timestamp(ns)
23
+ return "--:--:--" unless ns
24
+
25
+ Time.at(ns / 1_000_000_000.0).strftime("%H:%M:%S")
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ # Tracks estimated visual heights for chat entries, enabling
5
+ # viewport virtualization. Heights are in visual (wrapped) line
6
+ # units, estimated from content length and terminal width.
7
+ #
8
+ # Provides efficient scroll-position-to-entry-index mapping so
9
+ # the chat screen can render only visible messages instead of
10
+ # processing the entire conversation history.
11
+ #
12
+ # @example
13
+ # map = HeightMap.new
14
+ # map.update(entries, 80) { |entry, width| estimate(entry, width) }
15
+ # first, last = map.visible_range(scroll_offset, viewport_height)
16
+ # total = map.total_height
17
+ class HeightMap
18
+ # @return [Integer] number of tracked entries
19
+ attr_reader :size
20
+
21
+ def initialize
22
+ @heights = []
23
+ @size = 0
24
+ end
25
+
26
+ # Replaces all heights from a fresh estimation pass.
27
+ # Each entry's height is computed by the caller-supplied block.
28
+ #
29
+ # @param entries [Array<Hash>] message store entries
30
+ # @param width [Integer] terminal width for wrap estimation
31
+ # @yield [entry, width] block returning estimated visual line count
32
+ # @return [void]
33
+ def update(entries, width)
34
+ @heights = entries.map { |entry| [yield(entry, width), 1].max }
35
+ @size = @heights.size
36
+ end
37
+
38
+ # @return [Integer] sum of all estimated entry heights
39
+ def total_height
40
+ @heights.sum(0)
41
+ end
42
+
43
+ # Cumulative height of entries before the given index.
44
+ #
45
+ # @param index [Integer] entry index (0-based)
46
+ # @return [Integer] total visual lines above this entry
47
+ def cumulative_height(index)
48
+ return 0 if index <= 0 || @heights.empty?
49
+ return @heights.sum(0) if index >= @size
50
+
51
+ @heights[0...index].sum(0)
52
+ end
53
+
54
+ # Finds the entry range visible within a scroll window.
55
+ # An entry is visible if any of its lines fall within
56
+ # [scroll_offset, scroll_offset + visible_height).
57
+ #
58
+ # @param scroll_offset [Integer] top of viewport in visual lines
59
+ # @param visible_height [Integer] viewport height in visual lines
60
+ # @return [Array(Integer, Integer)] [first_visible, last_visible]
61
+ def visible_range(scroll_offset, visible_height)
62
+ return [0, 0] if @heights.empty?
63
+
64
+ first_found = false
65
+ first = 0
66
+ last = 0
67
+ cumulative = 0
68
+ end_line = scroll_offset + visible_height
69
+
70
+ @heights.each_with_index do |entry_height, idx|
71
+ entry_end = cumulative + entry_height
72
+ unless first_found
73
+ if entry_end > scroll_offset
74
+ first = idx
75
+ first_found = true
76
+ end
77
+ end
78
+ last = idx if cumulative < end_line
79
+ cumulative = entry_end
80
+ end
81
+
82
+ first = [@size - 1, 0].max unless first_found
83
+ [first, [last, first].max]
84
+ end
85
+
86
+ # Clears all tracked heights.
87
+ # @return [void]
88
+ def reset
89
+ @heights.clear
90
+ @size = 0
91
+ end
92
+ end
93
+ end
@@ -14,6 +14,11 @@ module TUI
14
14
  # rendered content fall back to existing behavior: tool events aggregate
15
15
  # into counters, messages store role and content.
16
16
  #
17
+ # Entries with event IDs are maintained in ID order (ascending)
18
+ # regardless of arrival order, preventing misordering from race
19
+ # conditions between live broadcasts and viewport replays.
20
+ # Duplicate IDs are deduplicated by updating the existing entry.
21
+ #
17
22
  # Tool counters aggregate per agent turn: a new counter starts when a
18
23
  # tool_call arrives after a message entry. Consecutive tool events
19
24
  # increment the same counter until the next message breaks the chain.
@@ -32,6 +37,15 @@ module TUI
32
37
  @entries = []
33
38
  @entries_by_id = {}
34
39
  @mutex = Mutex.new
40
+ @version = 0
41
+ end
42
+
43
+ # Monotonically increasing counter that bumps on every mutation.
44
+ # Consumers compare this to a cached value to detect changes
45
+ # without copying the full entries array on every frame.
46
+ # @return [Integer]
47
+ def version
48
+ @mutex.synchronize { @version }
35
49
  end
36
50
 
37
51
  # @return [Array<Hash>] thread-safe copy of stored entries
@@ -39,6 +53,11 @@ module TUI
39
53
  @mutex.synchronize { @entries.dup }
40
54
  end
41
55
 
56
+ # @return [Integer] number of stored entries (no array copy)
57
+ def size
58
+ @mutex.synchronize { @entries.size }
59
+ end
60
+
42
61
  # Processes a raw event payload from the WebSocket channel.
43
62
  # Uses structured decorator data when available; falls back to
44
63
  # role/content extraction for messages and tool counter aggregation.
@@ -77,6 +96,7 @@ module TUI
77
96
  @mutex.synchronize do
78
97
  @entries = []
79
98
  @entries_by_id = {}
99
+ @version += 1
80
100
  end
81
101
  end
82
102
 
@@ -111,6 +131,7 @@ module TUI
111
131
  return false unless entry
112
132
 
113
133
  @entries.delete(entry)
134
+ @version += 1
114
135
  true
115
136
  end
116
137
  end
@@ -131,6 +152,7 @@ module TUI
131
152
  @entries.delete(entry)
132
153
  removed += 1
133
154
  end
155
+ @version += 1 if removed > 0
134
156
  removed
135
157
  end
136
158
  end
@@ -151,6 +173,7 @@ module TUI
151
173
  return false unless entry
152
174
 
153
175
  entry[:data] = rendered
176
+ @version += 1
154
177
  true
155
178
  end
156
179
  end
@@ -165,15 +188,79 @@ module TUI
165
188
  event_data.dig("rendered")&.values&.compact&.first
166
189
  end
167
190
 
191
+ # Inserts a rendered entry at the correct chronological position.
192
+ # System prompt entries (no ID) are always placed at position 0.
168
193
  def record_rendered(data, event_type: nil, id: nil)
169
194
  @mutex.synchronize do
170
195
  entry = {type: :rendered, data: data, event_type: event_type, id: id}
171
- @entries << entry
172
- @entries_by_id[id] = entry if id
196
+ insert_ordered(entry)
197
+ @version += 1
173
198
  end
174
199
  true
175
200
  end
176
201
 
202
+ # Inserts an entry in event-ID order. Entries without an ID are
203
+ # appended. If an entry with the same ID already exists, updates
204
+ # it in-place (deduplication for live/viewport replay races).
205
+ # System prompt entries are always placed at position 0.
206
+ #
207
+ # @param entry [Hash] the entry to insert
208
+ # @return [void]
209
+ def insert_ordered(entry)
210
+ if entry[:event_type] == "system_prompt"
211
+ @entries.unshift(entry)
212
+ return
213
+ end
214
+
215
+ id = entry[:id]
216
+ unless id
217
+ @entries << entry
218
+ return
219
+ end
220
+
221
+ existing = @entries_by_id[id]
222
+ if existing
223
+ existing[:data] = entry[:data] if entry.key?(:data)
224
+ existing[:content] = entry[:content] if entry.key?(:content)
225
+ existing[:event_type] = entry[:event_type] if entry.key?(:event_type)
226
+ return
227
+ end
228
+
229
+ insert_sorted_by_id(entry)
230
+ @entries_by_id[id] = entry
231
+ end
232
+
233
+ # Inserts an entry in sorted order by event ID. Optimized for the
234
+ # common case where events arrive in order (appends without scanning).
235
+ # Entries without IDs (tool counters, etc.) are skipped during the
236
+ # sort scan and don't affect insertion position.
237
+ #
238
+ # @param entry [Hash] entry with a non-nil +:id+
239
+ # @return [void]
240
+ def insert_sorted_by_id(entry)
241
+ id = entry[:id]
242
+
243
+ # Fast path: entry belongs at the end (typical during live streaming)
244
+ last_id = last_entry_id
245
+ if last_id.nil? || last_id < id
246
+ @entries << entry
247
+ return
248
+ end
249
+
250
+ # Out-of-order arrival: insert before the first entry with a higher ID
251
+ insert_pos = @entries.index { |e| e[:id] && e[:id] > id } || @entries.size
252
+ @entries.insert(insert_pos, entry)
253
+ end
254
+
255
+ # Returns the highest event ID in the entries array, scanning from the
256
+ # end for efficiency (entries with IDs are typically at the tail).
257
+ #
258
+ # @return [Integer, nil] the highest event ID, or nil if no entries have IDs
259
+ def last_entry_id
260
+ @entries.reverse_each { |e| return e[:id] if e[:id] }
261
+ nil
262
+ end
263
+
177
264
  def record_tool_call
178
265
  @mutex.synchronize do
179
266
  current = current_tool_counter
@@ -182,6 +269,7 @@ module TUI
182
269
  else
183
270
  @entries << {type: :tool_counter, calls: 1, responses: 0}
184
271
  end
272
+ @version += 1
185
273
  end
186
274
  true
187
275
  end
@@ -189,7 +277,10 @@ module TUI
189
277
  def record_tool_response
190
278
  @mutex.synchronize do
191
279
  current = current_tool_counter
192
- current[:responses] += 1 if current
280
+ return false unless current
281
+
282
+ current[:responses] += 1
283
+ @version += 1
193
284
  end
194
285
  true
195
286
  end
@@ -198,12 +289,10 @@ module TUI
198
289
  content = event_data["content"]
199
290
  return false if content.nil?
200
291
 
201
- event_id = event_data["id"]
202
-
203
292
  @mutex.synchronize do
204
- entry = {type: :message, role: ROLE_MAP.fetch(event_data["type"]), content: content, id: event_id}
205
- @entries << entry
206
- @entries_by_id[event_id] = entry if event_id
293
+ entry = {type: :message, role: ROLE_MAP.fetch(event_data["type"]), content: content, id: event_data["id"]}
294
+ insert_ordered(entry)
295
+ @version += 1
207
296
  end
208
297
  true
209
298
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module TUI
6
+ # Frame-level performance logger for TUI render profiling.
7
+ #
8
+ # When enabled via `--debug`, logs timing data for each render phase
9
+ # to `log/tui_performance.log`. Each frame produces one log line with
10
+ # phase durations in milliseconds, enabling bottleneck identification.
11
+ #
12
+ # Uses monotonic clock to avoid wall-clock jitter.
13
+ #
14
+ # @example
15
+ # logger = PerformanceLogger.new(enabled: true)
16
+ # logger.start_frame
17
+ # logger.measure(:build_lines) { build_message_lines(tui) }
18
+ # logger.measure(:line_count) { widget.line_count(width) }
19
+ # logger.end_frame
20
+ class PerformanceLogger
21
+ LOG_PATH = "log/tui_performance.log"
22
+
23
+ # @param enabled [Boolean] whether to actually log (no-op when false)
24
+ def initialize(enabled: false)
25
+ @enabled = enabled
26
+ @phases = {}
27
+ @frame_start = nil
28
+ @frame_count = 0
29
+ @logger = nil
30
+
31
+ return unless @enabled
32
+
33
+ @logger = Logger.new(LOG_PATH, 1, 5 * 1024 * 1024) # 5MB rotation
34
+ @logger.formatter = proc { |_sev, time, _prog, msg| "#{time.strftime("%H:%M:%S.%L")} #{msg}\n" }
35
+ @logger.info("TUI Performance Logger started — pid=#{Process.pid}")
36
+ end
37
+
38
+ # @return [Boolean] true when logging is active
39
+ def enabled?
40
+ @enabled
41
+ end
42
+
43
+ # Marks the beginning of a render frame.
44
+ def start_frame
45
+ return unless @enabled
46
+
47
+ @frame_start = monotonic_now
48
+ @phases = {}
49
+ end
50
+
51
+ # Measures a named phase within the current frame.
52
+ # Returns the block's result so it can be used inline.
53
+ #
54
+ # @param name [Symbol] phase name (e.g. :build_lines, :line_count)
55
+ # @yield the code to measure
56
+ # @return [Object] the block's return value
57
+ def measure(name)
58
+ return yield unless @enabled
59
+
60
+ start = monotonic_now
61
+ result = yield
62
+ @phases[name] = ((monotonic_now - start) * 1000).round(2)
63
+ result
64
+ end
65
+
66
+ # Logs the completed frame with all phase timings.
67
+ def end_frame
68
+ return unless @enabled
69
+
70
+ total = ((monotonic_now - @frame_start) * 1000).round(2)
71
+ @frame_count += 1
72
+
73
+ parts = @phases.map { |name, ms| "#{name}=#{ms}ms" }
74
+ @logger.info("frame=#{@frame_count} total=#{total}ms #{parts.join(" ")}")
75
+ end
76
+
77
+ # Logs a one-off informational message (e.g. cache hit/miss).
78
+ #
79
+ # @param message [String]
80
+ def info(message)
81
+ @logger&.info(message)
82
+ end
83
+
84
+ private
85
+
86
+ def monotonic_now
87
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
+ end
89
+ end
90
+ end