anima-core 1.1.3 → 1.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.
- checksums.yaml +4 -4
- data/.reek.yml +10 -1
- data/README.md +36 -11
- data/agents/codebase-analyzer.md +2 -2
- data/agents/codebase-pattern-finder.md +2 -2
- data/agents/documentation-researcher.md +2 -2
- data/agents/thoughts-analyzer.md +2 -2
- data/agents/web-search-researcher.md +3 -3
- data/app/channels/session_channel.rb +83 -64
- data/app/decorators/agent_message_decorator.rb +2 -2
- data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
- data/app/decorators/system_message_decorator.rb +2 -2
- data/app/decorators/tool_call_decorator.rb +6 -6
- data/app/decorators/tool_decorator.rb +4 -4
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +5 -19
- data/app/decorators/web_get_tool_decorator.rb +41 -9
- data/app/jobs/agent_request_job.rb +33 -24
- data/app/jobs/count_message_tokens_job.rb +39 -0
- data/app/jobs/passive_recall_job.rb +4 -4
- data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
- data/app/models/goal.rb +17 -4
- data/app/models/goal_pinned_message.rb +11 -0
- data/app/models/message.rb +127 -0
- data/app/models/pending_message.rb +43 -0
- data/app/models/pinned_message.rb +41 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +385 -226
- data/app/models/snapshot.rb +25 -25
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/lib/agent_loop.rb +14 -41
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +40 -37
- data/lib/analytical_brain/tools/activate_skill.rb +5 -9
- data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
- data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
- data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
- data/lib/analytical_brain/tools/finish_goal.rb +5 -8
- data/lib/analytical_brain/tools/read_workflow.rb +5 -9
- data/lib/analytical_brain/tools/rename_session.rb +3 -10
- data/lib/analytical_brain/tools/set_goal.rb +3 -7
- data/lib/analytical_brain/tools/update_goal.rb +3 -7
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/installer.rb +7 -1
- data/lib/anima/settings.rb +46 -6
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/base.rb +1 -1
- data/lib/events/bounce_back.rb +7 -7
- data/lib/events/subscribers/persister.rb +15 -22
- data/lib/events/subscribers/subagent_message_router.rb +20 -8
- data/lib/events/subscribers/transient_broadcaster.rb +2 -2
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +54 -20
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +57 -57
- data/lib/mneme/l2_runner.rb +4 -4
- data/lib/mneme/passive_recall.rb +2 -2
- data/lib/mneme/runner.rb +57 -75
- data/lib/mneme/search.rb +38 -38
- data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
- data/lib/mneme/tools/everything_ok.rb +1 -3
- data/lib/mneme/tools/save_snapshot.rb +12 -16
- data/lib/shell_session.rb +54 -16
- data/lib/tools/base.rb +23 -0
- data/lib/tools/bash.rb +60 -16
- data/lib/tools/edit.rb +6 -8
- data/lib/tools/mark_goal_completed.rb +86 -0
- data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
- data/lib/tools/read.rb +6 -5
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +37 -8
- data/lib/tools/remember.rb +46 -55
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +15 -25
- data/lib/tools/spawn_subagent.rb +14 -22
- data/lib/tools/subagent_prompts.rb +42 -6
- data/lib/tools/think.rb +26 -10
- data/lib/tools/web_get.rb +23 -4
- data/lib/tools/write.rb +4 -4
- data/lib/tui/app.rb +178 -13
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +4 -4
- data/lib/tui/decorators/base_decorator.rb +17 -8
- data/lib/tui/decorators/bash_decorator.rb +2 -2
- data/lib/tui/decorators/edit_decorator.rb +5 -4
- data/lib/tui/decorators/read_decorator.rb +4 -8
- data/lib/tui/decorators/think_decorator.rb +3 -5
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +5 -4
- data/lib/tui/flash.rb +1 -1
- data/lib/tui/formatting.rb +22 -0
- data/lib/tui/message_store.rb +103 -59
- data/lib/tui/screens/chat.rb +293 -78
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +42 -5
- data/templates/soul.md +7 -19
- data/workflows/create_handoff.md +1 -1
- data/workflows/create_note.md +1 -1
- data/workflows/create_plan.md +1 -1
- data/workflows/implement_plan.md +1 -1
- data/workflows/iterate_plan.md +1 -1
- data/workflows/research_codebase.md +1 -1
- data/workflows/resume_handoff.md +1 -1
- data/workflows/review_pr.md +78 -16
- data/workflows/thoughts_init.md +1 -1
- data/workflows/validate_plan.md +1 -1
- metadata +20 -9
- data/app/jobs/count_event_tokens_job.rb +0 -39
- data/app/models/event.rb +0 -129
- data/app/models/goal_pinned_event.rb +0 -11
- data/app/models/pinned_event.rb +0 -41
- data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module TUI
|
|
4
4
|
module Decorators
|
|
5
|
-
# Renders
|
|
6
|
-
# Calls show the file path with a memo icon.
|
|
5
|
+
# Renders write_file tool calls and responses.
|
|
6
|
+
# Calls show the file path with a memo icon in the unified tool color.
|
|
7
|
+
# Responses use the CRUD Create color (light_green) to signal new content.
|
|
7
8
|
class WriteDecorator < BaseDecorator
|
|
8
9
|
ICON = "\u{1F4DD}" # memo
|
|
9
10
|
|
|
@@ -11,8 +12,8 @@ module TUI
|
|
|
11
12
|
ICON
|
|
12
13
|
end
|
|
13
14
|
|
|
14
|
-
def
|
|
15
|
-
"
|
|
15
|
+
def response_color
|
|
16
|
+
"light_green"
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
end
|
data/lib/tui/flash.rb
CHANGED
data/lib/tui/formatting.rb
CHANGED
|
@@ -16,6 +16,28 @@ module TUI
|
|
|
16
16
|
"[#{label} tok]"
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# Returns a semantic color for token count display.
|
|
20
|
+
# Visually flags expensive messages so runaway tool calls or bloated
|
|
21
|
+
# responses jump out immediately in debug mode.
|
|
22
|
+
#
|
|
23
|
+
# Thresholds (empirically tuned from real agent sessions):
|
|
24
|
+
# < 1k → dark_gray (routine, ignorable)
|
|
25
|
+
# < 3k → white (normal)
|
|
26
|
+
# < 10k → yellow (notable)
|
|
27
|
+
# < 20k → 208/orange (expensive)
|
|
28
|
+
# ≥ 20k → red (alarm — likely runaway)
|
|
29
|
+
#
|
|
30
|
+
# @param tokens [Integer] token count
|
|
31
|
+
# @return [String, Integer] named color or 256-color index
|
|
32
|
+
def token_count_color(tokens)
|
|
33
|
+
return "dark_gray" if tokens < 1_000
|
|
34
|
+
return "white" if tokens < 3_000
|
|
35
|
+
return "yellow" if tokens < 10_000
|
|
36
|
+
return 208 if tokens < 20_000 # orange (256-color)
|
|
37
|
+
|
|
38
|
+
"red"
|
|
39
|
+
end
|
|
40
|
+
|
|
19
41
|
# Converts nanosecond-precision timestamp to human-readable HH:MM:SS.
|
|
20
42
|
# @param ns [Integer, nil] nanosecond timestamp
|
|
21
43
|
# @return [String] formatted time, or "--:--:--" when nil
|
data/lib/tui/message_store.rb
CHANGED
|
@@ -5,25 +5,25 @@ module TUI
|
|
|
5
5
|
# Replaces {Events::Subscribers::MessageCollector} in the WebSocket-based
|
|
6
6
|
# TUI, with no dependency on Rails or the Events module.
|
|
7
7
|
#
|
|
8
|
-
# Accepts Action Cable
|
|
9
|
-
# - `{type: :rendered, data:,
|
|
8
|
+
# Accepts Action Cable message payloads and stores typed entries:
|
|
9
|
+
# - `{type: :rendered, data:, message_type:, id:}` for messages with structured decorator output
|
|
10
10
|
# - `{type: :message, role:, content:, id:}` for user/agent messages (fallback)
|
|
11
11
|
# - `{type: :tool_counter, calls:, responses:}` for tool activity
|
|
12
12
|
#
|
|
13
|
-
# Structured data takes priority when available.
|
|
14
|
-
# rendered content fall back to existing behavior: tool
|
|
15
|
-
# into counters, messages store role and content.
|
|
13
|
+
# Structured data takes priority when available. Messages with nil
|
|
14
|
+
# rendered content fall back to existing behavior: tool messages aggregate
|
|
15
|
+
# into counters, conversation messages store role and content.
|
|
16
16
|
#
|
|
17
|
-
# Entries with
|
|
17
|
+
# Entries with message IDs are maintained in ID order (ascending)
|
|
18
18
|
# regardless of arrival order, preventing misordering from race
|
|
19
19
|
# conditions between live broadcasts and viewport replays.
|
|
20
20
|
# Duplicate IDs are deduplicated by updating the existing entry.
|
|
21
21
|
#
|
|
22
22
|
# Tool counters aggregate per agent turn: a new counter starts when a
|
|
23
|
-
# tool_call arrives after a
|
|
24
|
-
# increment the same counter until the next message breaks the chain.
|
|
23
|
+
# tool_call arrives after a conversation entry. Consecutive tool messages
|
|
24
|
+
# increment the same counter until the next conversation message breaks the chain.
|
|
25
25
|
#
|
|
26
|
-
# When
|
|
26
|
+
# When a message arrives with `"action" => "update"` and a known `"id"`,
|
|
27
27
|
# the existing entry is replaced in-place, preserving display order.
|
|
28
28
|
class MessageStore
|
|
29
29
|
MESSAGE_TYPES = %w[user_message agent_message].freeze
|
|
@@ -36,6 +36,8 @@ module TUI
|
|
|
36
36
|
def initialize
|
|
37
37
|
@entries = []
|
|
38
38
|
@entries_by_id = {}
|
|
39
|
+
@pending_entries = []
|
|
40
|
+
@pending_by_id = {}
|
|
39
41
|
@mutex = Mutex.new
|
|
40
42
|
@version = 0
|
|
41
43
|
end
|
|
@@ -48,14 +50,14 @@ module TUI
|
|
|
48
50
|
@mutex.synchronize { @version }
|
|
49
51
|
end
|
|
50
52
|
|
|
51
|
-
# @return [Array<Hash>] thread-safe copy of stored entries
|
|
53
|
+
# @return [Array<Hash>] thread-safe copy of stored entries (pending messages at the end)
|
|
52
54
|
def messages
|
|
53
|
-
@mutex.synchronize { @entries.dup }
|
|
55
|
+
@mutex.synchronize { @entries.dup + @pending_entries.dup }
|
|
54
56
|
end
|
|
55
57
|
|
|
56
|
-
# @return [Integer] number of stored entries (no array copy)
|
|
58
|
+
# @return [Integer] number of stored entries including pending (no array copy)
|
|
57
59
|
def size
|
|
58
|
-
@mutex.synchronize { @entries.size }
|
|
60
|
+
@mutex.synchronize { @entries.size + @pending_entries.size }
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
# Processes a raw event payload from the WebSocket channel.
|
|
@@ -69,16 +71,16 @@ module TUI
|
|
|
69
71
|
# and optionally "rendered" (hash of mode => lines), "id", "action"
|
|
70
72
|
# @return [Boolean] true if the event type was recognized and handled
|
|
71
73
|
def process_event(event_data)
|
|
72
|
-
|
|
74
|
+
message_id = event_data["id"]
|
|
73
75
|
|
|
74
|
-
if event_data["action"] == "update" &&
|
|
75
|
-
return update_existing(event_data,
|
|
76
|
+
if event_data["action"] == "update" && message_id
|
|
77
|
+
return update_existing(event_data, message_id)
|
|
76
78
|
end
|
|
77
79
|
|
|
78
80
|
rendered = extract_rendered(event_data)
|
|
79
81
|
|
|
80
82
|
if rendered
|
|
81
|
-
record_rendered(rendered,
|
|
83
|
+
record_rendered(rendered, message_type: event_data["type"], id: message_id)
|
|
82
84
|
else
|
|
83
85
|
case event_data["type"]
|
|
84
86
|
when "tool_call" then record_tool_call
|
|
@@ -90,44 +92,75 @@ module TUI
|
|
|
90
92
|
end
|
|
91
93
|
|
|
92
94
|
# Removes all entries. Called on view mode change and session switch
|
|
93
|
-
# to prepare for re-decorated viewport
|
|
95
|
+
# to prepare for re-decorated viewport messages from the server.
|
|
94
96
|
# @return [void]
|
|
95
97
|
def clear
|
|
96
98
|
@mutex.synchronize do
|
|
97
99
|
@entries = []
|
|
98
100
|
@entries_by_id = {}
|
|
101
|
+
@pending_entries = []
|
|
102
|
+
@pending_by_id = {}
|
|
99
103
|
@version += 1
|
|
100
104
|
end
|
|
101
105
|
end
|
|
102
106
|
|
|
107
|
+
# Adds a pending message to the separate pending list.
|
|
108
|
+
# Pending messages always render after real messages.
|
|
109
|
+
#
|
|
110
|
+
# @param pending_message_id [Integer] PendingMessage database ID
|
|
111
|
+
# @param content [String] message text
|
|
112
|
+
# @return [void]
|
|
113
|
+
def add_pending(pending_message_id, content)
|
|
114
|
+
@mutex.synchronize do
|
|
115
|
+
entry = {
|
|
116
|
+
type: :rendered,
|
|
117
|
+
data: {"role" => "user", "content" => content, "status" => "pending"},
|
|
118
|
+
message_type: "user_message",
|
|
119
|
+
pending_message_id: pending_message_id
|
|
120
|
+
}
|
|
121
|
+
old = @pending_by_id[pending_message_id]
|
|
122
|
+
@pending_entries.delete(old) if old
|
|
123
|
+
@pending_entries << entry
|
|
124
|
+
@pending_by_id[pending_message_id] = entry
|
|
125
|
+
@version += 1
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Removes a pending message by its PendingMessage ID.
|
|
130
|
+
#
|
|
131
|
+
# @param pending_message_id [Integer] PendingMessage database ID
|
|
132
|
+
# @return [Boolean] true if found and removed
|
|
133
|
+
def remove_pending(pending_message_id)
|
|
134
|
+
@mutex.synchronize do
|
|
135
|
+
entry = @pending_by_id.delete(pending_message_id)
|
|
136
|
+
return false unless entry
|
|
137
|
+
|
|
138
|
+
@pending_entries.delete(entry)
|
|
139
|
+
@version += 1
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
103
144
|
# Returns the last pending user message for recall editing.
|
|
104
|
-
# Walks entries backwards and returns the first pending user_message found.
|
|
105
145
|
#
|
|
106
|
-
# @return [Hash, nil] `{
|
|
146
|
+
# @return [Hash, nil] `{pending_message_id: Integer, content: String}` or nil
|
|
107
147
|
def last_pending_user_message
|
|
108
148
|
@mutex.synchronize do
|
|
109
|
-
@
|
|
110
|
-
|
|
149
|
+
entry = @pending_entries.last
|
|
150
|
+
return nil unless entry
|
|
111
151
|
|
|
112
|
-
|
|
113
|
-
return {id: entry[:id], content: entry.dig(:data, "content")}
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
# Only check the most recent user message
|
|
117
|
-
break
|
|
118
|
-
end
|
|
119
|
-
nil
|
|
152
|
+
{pending_message_id: entry[:pending_message_id], content: entry.dig(:data, "content")}
|
|
120
153
|
end
|
|
121
154
|
end
|
|
122
155
|
|
|
123
|
-
# Removes an entry by its
|
|
156
|
+
# Removes an entry by its message ID. Used when a pending message is
|
|
124
157
|
# recalled for editing or deleted by another client.
|
|
125
158
|
#
|
|
126
|
-
# @param
|
|
159
|
+
# @param message_id [Integer] database ID of the message to remove
|
|
127
160
|
# @return [Boolean] true if the entry was found and removed
|
|
128
|
-
def remove_by_id(
|
|
161
|
+
def remove_by_id(message_id)
|
|
129
162
|
@mutex.synchronize do
|
|
130
|
-
entry = @entries_by_id.delete(
|
|
163
|
+
entry = @entries_by_id.delete(message_id)
|
|
131
164
|
return false unless entry
|
|
132
165
|
|
|
133
166
|
@entries.delete(entry)
|
|
@@ -136,17 +169,17 @@ module TUI
|
|
|
136
169
|
end
|
|
137
170
|
end
|
|
138
171
|
|
|
139
|
-
# Removes entries by their
|
|
140
|
-
# that
|
|
172
|
+
# Removes entries by their message IDs. Used when the brain reports
|
|
173
|
+
# that messages have left the LLM's viewport (context window eviction).
|
|
141
174
|
# Acquires the mutex once for the entire batch.
|
|
142
175
|
#
|
|
143
|
-
# @param
|
|
176
|
+
# @param message_ids [Array<Integer>] database IDs of messages to remove
|
|
144
177
|
# @return [Integer] count of entries actually removed
|
|
145
|
-
def remove_by_ids(
|
|
178
|
+
def remove_by_ids(message_ids)
|
|
146
179
|
@mutex.synchronize do
|
|
147
180
|
removed = 0
|
|
148
|
-
|
|
149
|
-
entry = @entries_by_id.delete(
|
|
181
|
+
message_ids.each do |message_id|
|
|
182
|
+
entry = @entries_by_id.delete(message_id)
|
|
150
183
|
next unless entry
|
|
151
184
|
|
|
152
185
|
@entries.delete(entry)
|
|
@@ -159,17 +192,17 @@ module TUI
|
|
|
159
192
|
|
|
160
193
|
private
|
|
161
194
|
|
|
162
|
-
# Replaces data on an existing entry matched by
|
|
195
|
+
# Replaces data on an existing entry matched by message ID.
|
|
163
196
|
# Only updates rendered entries — tool counters and plain messages
|
|
164
197
|
# are not individually addressable by ID.
|
|
165
198
|
#
|
|
166
199
|
# @return [Boolean] true if the entry was found and updated
|
|
167
|
-
def update_existing(event_data,
|
|
200
|
+
def update_existing(event_data, message_id)
|
|
168
201
|
rendered = extract_rendered(event_data)
|
|
169
202
|
return false unless rendered
|
|
170
203
|
|
|
171
204
|
@mutex.synchronize do
|
|
172
|
-
entry = @entries_by_id[
|
|
205
|
+
entry = @entries_by_id[message_id]
|
|
173
206
|
return false unless entry
|
|
174
207
|
|
|
175
208
|
entry[:data] = rendered
|
|
@@ -190,28 +223,25 @@ module TUI
|
|
|
190
223
|
|
|
191
224
|
# Inserts a rendered entry at the correct chronological position.
|
|
192
225
|
# System prompt entries (no ID) are always placed at position 0.
|
|
193
|
-
def record_rendered(data,
|
|
226
|
+
def record_rendered(data, message_type: nil, id: nil)
|
|
194
227
|
@mutex.synchronize do
|
|
195
|
-
entry = {type: :rendered, data: data,
|
|
228
|
+
entry = {type: :rendered, data: data, message_type: message_type, id: id}
|
|
196
229
|
insert_ordered(entry)
|
|
197
230
|
@version += 1
|
|
198
231
|
end
|
|
199
232
|
true
|
|
200
233
|
end
|
|
201
234
|
|
|
202
|
-
# Inserts an entry in
|
|
235
|
+
# Inserts an entry in message-ID order. Entries without an ID are
|
|
203
236
|
# appended. If an entry with the same ID already exists, updates
|
|
204
237
|
# it in-place (deduplication for live/viewport replay races).
|
|
205
|
-
#
|
|
238
|
+
# Callers send system prompt entries with {Message::SYSTEM_PROMPT_ID}
|
|
239
|
+
# (0) so they sort before all positive-ID messages and deduplicate
|
|
240
|
+
# on subsequent broadcasts.
|
|
206
241
|
#
|
|
207
242
|
# @param entry [Hash] the entry to insert
|
|
208
243
|
# @return [void]
|
|
209
244
|
def insert_ordered(entry)
|
|
210
|
-
if entry[:event_type] == "system_prompt"
|
|
211
|
-
@entries.unshift(entry)
|
|
212
|
-
return
|
|
213
|
-
end
|
|
214
|
-
|
|
215
245
|
id = entry[:id]
|
|
216
246
|
unless id
|
|
217
247
|
@entries << entry
|
|
@@ -222,7 +252,7 @@ module TUI
|
|
|
222
252
|
if existing
|
|
223
253
|
existing[:data] = entry[:data] if entry.key?(:data)
|
|
224
254
|
existing[:content] = entry[:content] if entry.key?(:content)
|
|
225
|
-
existing[:
|
|
255
|
+
existing[:message_type] = entry[:message_type] if entry.key?(:message_type)
|
|
226
256
|
return
|
|
227
257
|
end
|
|
228
258
|
|
|
@@ -230,10 +260,14 @@ module TUI
|
|
|
230
260
|
@entries_by_id[id] = entry
|
|
231
261
|
end
|
|
232
262
|
|
|
233
|
-
# Inserts an entry in sorted order by
|
|
234
|
-
# common
|
|
235
|
-
#
|
|
236
|
-
#
|
|
263
|
+
# Inserts an entry in sorted order by message ID. Optimized for two
|
|
264
|
+
# common cases: appending (live streaming, ascending order) and
|
|
265
|
+
# prepending (session history replay, descending/newest-first order).
|
|
266
|
+
# Falls back to binary scan for out-of-order arrivals.
|
|
267
|
+
#
|
|
268
|
+
# Note: prepending N messages via +unshift+ is O(n) per call. For
|
|
269
|
+
# large viewport replays this totals O(n²), acceptable at typical
|
|
270
|
+
# viewport sizes (50–100 messages).
|
|
237
271
|
#
|
|
238
272
|
# @param entry [Hash] entry with a non-nil +:id+
|
|
239
273
|
# @return [void]
|
|
@@ -247,15 +281,25 @@ module TUI
|
|
|
247
281
|
return
|
|
248
282
|
end
|
|
249
283
|
|
|
284
|
+
# Fast path: entry belongs at the beginning (session history replay, newest-first).
|
|
285
|
+
# Only safe when the first entry has an ID — non-ID entries (tool counters)
|
|
286
|
+
# at the head would be displaced, so we fall through to the general path.
|
|
287
|
+
first_id = @entries.first&.dig(:id)
|
|
288
|
+
if first_id && id < first_id
|
|
289
|
+
@entries.unshift(entry)
|
|
290
|
+
return
|
|
291
|
+
end
|
|
292
|
+
|
|
250
293
|
# Out-of-order arrival: insert before the first entry with a higher ID
|
|
251
294
|
insert_pos = @entries.index { |e| e[:id] && e[:id] > id } || @entries.size
|
|
252
295
|
@entries.insert(insert_pos, entry)
|
|
253
296
|
end
|
|
254
297
|
|
|
255
|
-
# Returns the highest
|
|
298
|
+
# Returns the highest message ID in the entries array, scanning from the
|
|
256
299
|
# end for efficiency (entries with IDs are typically at the tail).
|
|
300
|
+
# Used by {#insert_sorted_by_id} to detect the append fast path.
|
|
257
301
|
#
|
|
258
|
-
# @return [Integer, nil] the highest
|
|
302
|
+
# @return [Integer, nil] the highest message ID, or nil if no entries have IDs
|
|
259
303
|
def last_entry_id
|
|
260
304
|
@entries.reverse_each { |e| return e[:id] if e[:id] }
|
|
261
305
|
nil
|