anima-core 1.0.2 → 1.1.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/.gitattributes +1 -0
- data/.reek.yml +47 -0
- data/README.md +60 -26
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +29 -10
- data/app/decorators/tool_call_decorator.rb +7 -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 +90 -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 +18 -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 +335 -6
- data/app/models/snapshot.rb +76 -0
- data/config/initializers/event_subscribers.rb +14 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- 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 +2 -1
- data/lib/anima/installer.rb +11 -12
- data/lib/anima/settings.rb +41 -0
- data/lib/anima/version.rb +1 -1
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/agent_dispatcher.rb +29 -0
- data/lib/events/subscribers/persister.rb +17 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/llm/client.rb +16 -8
- 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 +188 -59
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/remember.rb +179 -0
- 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 +15 -6
- 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 +25 -1
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +358 -133
- data/templates/config.toml +40 -0
- 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
|
@@ -32,6 +32,15 @@ module TUI
|
|
|
32
32
|
@entries = []
|
|
33
33
|
@entries_by_id = {}
|
|
34
34
|
@mutex = Mutex.new
|
|
35
|
+
@version = 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Monotonically increasing counter that bumps on every mutation.
|
|
39
|
+
# Consumers compare this to a cached value to detect changes
|
|
40
|
+
# without copying the full entries array on every frame.
|
|
41
|
+
# @return [Integer]
|
|
42
|
+
def version
|
|
43
|
+
@mutex.synchronize { @version }
|
|
35
44
|
end
|
|
36
45
|
|
|
37
46
|
# @return [Array<Hash>] thread-safe copy of stored entries
|
|
@@ -39,6 +48,11 @@ module TUI
|
|
|
39
48
|
@mutex.synchronize { @entries.dup }
|
|
40
49
|
end
|
|
41
50
|
|
|
51
|
+
# @return [Integer] number of stored entries (no array copy)
|
|
52
|
+
def size
|
|
53
|
+
@mutex.synchronize { @entries.size }
|
|
54
|
+
end
|
|
55
|
+
|
|
42
56
|
# Processes a raw event payload from the WebSocket channel.
|
|
43
57
|
# Uses structured decorator data when available; falls back to
|
|
44
58
|
# role/content extraction for messages and tool counter aggregation.
|
|
@@ -77,6 +91,7 @@ module TUI
|
|
|
77
91
|
@mutex.synchronize do
|
|
78
92
|
@entries = []
|
|
79
93
|
@entries_by_id = {}
|
|
94
|
+
@version += 1
|
|
80
95
|
end
|
|
81
96
|
end
|
|
82
97
|
|
|
@@ -111,6 +126,7 @@ module TUI
|
|
|
111
126
|
return false unless entry
|
|
112
127
|
|
|
113
128
|
@entries.delete(entry)
|
|
129
|
+
@version += 1
|
|
114
130
|
true
|
|
115
131
|
end
|
|
116
132
|
end
|
|
@@ -131,6 +147,7 @@ module TUI
|
|
|
131
147
|
@entries.delete(entry)
|
|
132
148
|
removed += 1
|
|
133
149
|
end
|
|
150
|
+
@version += 1 if removed > 0
|
|
134
151
|
removed
|
|
135
152
|
end
|
|
136
153
|
end
|
|
@@ -151,6 +168,7 @@ module TUI
|
|
|
151
168
|
return false unless entry
|
|
152
169
|
|
|
153
170
|
entry[:data] = rendered
|
|
171
|
+
@version += 1
|
|
154
172
|
true
|
|
155
173
|
end
|
|
156
174
|
end
|
|
@@ -170,6 +188,7 @@ module TUI
|
|
|
170
188
|
entry = {type: :rendered, data: data, event_type: event_type, id: id}
|
|
171
189
|
@entries << entry
|
|
172
190
|
@entries_by_id[id] = entry if id
|
|
191
|
+
@version += 1
|
|
173
192
|
end
|
|
174
193
|
true
|
|
175
194
|
end
|
|
@@ -182,6 +201,7 @@ module TUI
|
|
|
182
201
|
else
|
|
183
202
|
@entries << {type: :tool_counter, calls: 1, responses: 0}
|
|
184
203
|
end
|
|
204
|
+
@version += 1
|
|
185
205
|
end
|
|
186
206
|
true
|
|
187
207
|
end
|
|
@@ -189,7 +209,10 @@ module TUI
|
|
|
189
209
|
def record_tool_response
|
|
190
210
|
@mutex.synchronize do
|
|
191
211
|
current = current_tool_counter
|
|
192
|
-
|
|
212
|
+
return false unless current
|
|
213
|
+
|
|
214
|
+
current[:responses] += 1
|
|
215
|
+
@version += 1
|
|
193
216
|
end
|
|
194
217
|
true
|
|
195
218
|
end
|
|
@@ -204,6 +227,7 @@ module TUI
|
|
|
204
227
|
entry = {type: :message, role: ROLE_MAP.fetch(event_data["type"]), content: content, id: event_id}
|
|
205
228
|
@entries << entry
|
|
206
229
|
@entries_by_id[event_id] = entry if event_id
|
|
230
|
+
@version += 1
|
|
207
231
|
end
|
|
208
232
|
true
|
|
209
233
|
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
|