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.
- checksums.yaml +4 -4
- data/.gitattributes +1 -0
- data/.reek.yml +51 -0
- data/README.md +63 -29
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +30 -11
- data/app/decorators/tool_call_decorator.rb +32 -3
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +12 -4
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +93 -23
- data/app/jobs/mneme_job.rb +51 -0
- data/app/jobs/passive_recall_job.rb +29 -0
- data/app/models/concerns/event/broadcasting.rb +4 -0
- data/app/models/event.rb +10 -0
- data/app/models/goal.rb +27 -0
- data/app/models/goal_pinned_event.rb +11 -0
- data/app/models/pinned_event.rb +41 -0
- data/app/models/session.rb +402 -6
- data/app/models/snapshot.rb +76 -0
- data/bin/jobs +5 -0
- data/config/initializers/event_subscribers.rb +12 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- data/config/queue.yml +0 -1
- data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
- data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
- data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
- data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
- data/lib/agent_loop.rb +63 -20
- data/lib/analytical_brain/runner.rb +158 -65
- data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
- data/lib/analytical_brain/tools/finish_goal.rb +6 -1
- data/lib/anima/cli.rb +32 -9
- data/lib/anima/installer.rb +11 -24
- data/lib/anima/settings.rb +59 -0
- data/lib/anima/spinner.rb +75 -0
- data/lib/anima/version.rb +1 -1
- data/lib/environment_probe.rb +4 -4
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/persister.rb +19 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/events/tool_call.rb +5 -3
- data/lib/llm/client.rb +19 -9
- data/lib/mneme/compressed_viewport.rb +200 -0
- data/lib/mneme/l2_runner.rb +138 -0
- data/lib/mneme/passive_recall.rb +69 -0
- data/lib/mneme/runner.rb +254 -0
- data/lib/mneme/search.rb +150 -0
- data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
- data/lib/mneme/tools/everything_ok.rb +24 -0
- data/lib/mneme/tools/save_snapshot.rb +68 -0
- data/lib/mneme.rb +29 -0
- data/lib/providers/anthropic.rb +57 -13
- data/lib/shell_session.rb +194 -63
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/base.rb +2 -1
- data/lib/tools/bash.rb +4 -2
- data/lib/tools/registry.rb +22 -3
- data/lib/tools/remember.rb +179 -0
- data/lib/tools/request_feature.rb +3 -1
- data/lib/tools/spawn_specialist.rb +21 -9
- data/lib/tools/spawn_subagent.rb +22 -11
- data/lib/tools/subagent_prompts.rb +20 -3
- data/lib/tools/web_get.rb +21 -10
- data/lib/tui/app.rb +222 -125
- data/lib/tui/decorators/base_decorator.rb +165 -0
- data/lib/tui/decorators/bash_decorator.rb +20 -0
- data/lib/tui/decorators/edit_decorator.rb +19 -0
- data/lib/tui/decorators/read_decorator.rb +24 -0
- data/lib/tui/decorators/think_decorator.rb +36 -0
- data/lib/tui/decorators/web_get_decorator.rb +19 -0
- data/lib/tui/decorators/write_decorator.rb +19 -0
- data/lib/tui/flash.rb +139 -0
- data/lib/tui/formatting.rb +28 -0
- data/lib/tui/height_map.rb +93 -0
- data/lib/tui/message_store.rb +97 -8
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +358 -133
- data/templates/config.toml +47 -0
- data/templates/soul.md +1 -1
- metadata +83 -4
- data/CHANGELOG.md +0 -80
- data/Gemfile +0 -17
- 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
|
data/lib/tui/message_store.rb
CHANGED
|
@@ -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
|
-
|
|
172
|
-
@
|
|
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
|
-
|
|
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:
|
|
205
|
-
|
|
206
|
-
@
|
|
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
|