anima-core 1.0.1 → 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 +61 -0
- data/README.md +202 -116
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +44 -10
- data/app/decorators/agent_message_decorator.rb +6 -0
- data/app/decorators/event_decorator.rb +41 -7
- data/app/decorators/tool_call_decorator.rb +66 -5
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +35 -5
- data/app/decorators/user_message_decorator.rb +6 -0
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +95 -20
- 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/20260316094817_add_interrupt_requested_to_sessions.rb +5 -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 +67 -18
- data/lib/analytical_brain/runner.rb +159 -84
- 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 +34 -1
- data/lib/anima/config_migrator.rb +205 -0
- data/lib/anima/installer.rb +13 -130
- data/lib/anima/settings.rb +42 -1
- 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 +99 -14
- 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/think.rb +57 -0
- data/lib/tools/web_get.rb +15 -6
- data/lib/tui/app.rb +230 -127
- data/lib/tui/cable_client.rb +8 -0
- 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 +374 -109
- data/templates/config.toml +156 -0
- metadata +87 -4
- data/CHANGELOG.md +0 -79
- data/Gemfile +0 -17
- data/lib/tools/return_result.rb +0 -81
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../formatting"
|
|
4
|
+
|
|
5
|
+
module TUI
|
|
6
|
+
module Decorators
|
|
7
|
+
# Client-side decorator layer for per-tool TUI rendering.
|
|
8
|
+
#
|
|
9
|
+
# Mirrors the server's Draper architecture but with a different
|
|
10
|
+
# specialization axis: server decorators are uniform per EVENT TYPE
|
|
11
|
+
# (tool_call, tool_result, message), while client decorators are
|
|
12
|
+
# unique per TOOL NAME (bash, read_file, web_get) — determining
|
|
13
|
+
# how each tool looks on screen.
|
|
14
|
+
#
|
|
15
|
+
# The factory dispatches on the +tool+ field in the structured data
|
|
16
|
+
# hash received from the server. Unknown tools fall back to generic
|
|
17
|
+
# rendering provided by this base class.
|
|
18
|
+
#
|
|
19
|
+
# @example Render a tool call
|
|
20
|
+
# decorator = TUI::Decorators::BaseDecorator.for(data)
|
|
21
|
+
# lines = decorator.render(tui)
|
|
22
|
+
class BaseDecorator
|
|
23
|
+
include Formatting
|
|
24
|
+
|
|
25
|
+
ICON = "\u{1F527}" # wrench
|
|
26
|
+
CHECKMARK = "\u2713"
|
|
27
|
+
RETURN_ARROW = "\u21A9"
|
|
28
|
+
ERROR_ICON = "\u274C"
|
|
29
|
+
|
|
30
|
+
attr_reader :data
|
|
31
|
+
|
|
32
|
+
def initialize(data)
|
|
33
|
+
@data = data
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Factory returning the per-tool decorator for the given data hash.
|
|
37
|
+
#
|
|
38
|
+
# @param data [Hash] structured event data with string keys
|
|
39
|
+
# ("role", "tool", "content", etc.)
|
|
40
|
+
# @return [BaseDecorator] the appropriate per-tool decorator
|
|
41
|
+
def self.for(data)
|
|
42
|
+
tool = resolve_tool(data)
|
|
43
|
+
decorator_for(tool).new(data)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Renders the event, dispatching by role to the appropriate method.
|
|
47
|
+
#
|
|
48
|
+
# @param tui [RatatuiRuby] TUI rendering API
|
|
49
|
+
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
50
|
+
def render(tui)
|
|
51
|
+
case data["role"].to_s
|
|
52
|
+
when "tool_call" then render_call(tui)
|
|
53
|
+
when "tool_response" then render_response(tui)
|
|
54
|
+
when "think" then render_think(tui)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Generic tool call rendering — icon, tool name, and indented input.
|
|
59
|
+
# Subclasses override for tool-specific presentation.
|
|
60
|
+
#
|
|
61
|
+
# @param tui [RatatuiRuby] TUI rendering API
|
|
62
|
+
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
63
|
+
def render_call(tui)
|
|
64
|
+
style = tui.style(fg: color)
|
|
65
|
+
header = build_call_header
|
|
66
|
+
lines = [tui.line(spans: [tui.span(content: header, style: style)])]
|
|
67
|
+
data["input"].to_s.split("\n", -1).each do |line|
|
|
68
|
+
lines << tui.line(spans: [tui.span(content: " #{line}", style: style)])
|
|
69
|
+
end
|
|
70
|
+
lines
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Generic tool response rendering — success/failure indicator and content.
|
|
74
|
+
# Subclasses override for tool-specific presentation.
|
|
75
|
+
#
|
|
76
|
+
# @param tui [RatatuiRuby] TUI rendering API
|
|
77
|
+
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
78
|
+
def render_response(tui)
|
|
79
|
+
indicator = (data["success"] == false) ? ERROR_ICON : CHECKMARK
|
|
80
|
+
tool_id = data["tool_use_id"]
|
|
81
|
+
tokens = data["tokens"]
|
|
82
|
+
|
|
83
|
+
meta_parts = []
|
|
84
|
+
meta_parts << "[#{tool_id}]" if tool_id
|
|
85
|
+
meta_parts << indicator
|
|
86
|
+
meta_parts << format_token_label(tokens, data["estimated"]) if tokens
|
|
87
|
+
prefix = " #{RETURN_ARROW} #{meta_parts.join(" ")} "
|
|
88
|
+
|
|
89
|
+
content_lines = data["content"].to_s.split("\n", -1)
|
|
90
|
+
style = tui.style(fg: response_color)
|
|
91
|
+
lines = [tui.line(spans: [tui.span(content: "#{prefix}#{content_lines.first}", style: style)])]
|
|
92
|
+
content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
|
|
93
|
+
lines
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Think rendering — delegated to ThinkDecorator, but base provides
|
|
97
|
+
# a fallback that renders as a generic tool call.
|
|
98
|
+
#
|
|
99
|
+
# @param tui [RatatuiRuby] TUI rendering API
|
|
100
|
+
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
101
|
+
def render_think(tui)
|
|
102
|
+
render_call(tui)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Icon for this tool type. Subclasses override with tool-specific icons.
|
|
106
|
+
# @return [String]
|
|
107
|
+
def icon
|
|
108
|
+
ICON
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Color for tool call headers. Subclasses override for tool-specific colors.
|
|
112
|
+
# @return [String]
|
|
113
|
+
def color
|
|
114
|
+
"white"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Color for tool response content. Subclasses override for tool-specific colors.
|
|
118
|
+
# @return [String]
|
|
119
|
+
def response_color
|
|
120
|
+
"white"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
# Builds the header line for a tool call entry.
|
|
126
|
+
# @return [String]
|
|
127
|
+
def build_call_header
|
|
128
|
+
ts = data["timestamp"]
|
|
129
|
+
tool_id = data["tool_use_id"]
|
|
130
|
+
|
|
131
|
+
meta = []
|
|
132
|
+
meta << "[#{format_ns_timestamp(ts)}]" if ts
|
|
133
|
+
prefix = meta.empty? ? icon : "#{meta.join(" ")} #{icon}"
|
|
134
|
+
header = "#{prefix} #{data["tool"]}"
|
|
135
|
+
header += " [#{tool_id}]" if tool_id
|
|
136
|
+
header
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Resolves the tool name from the data hash.
|
|
140
|
+
# Think events have role "think" but no "tool" field.
|
|
141
|
+
def self.resolve_tool(data)
|
|
142
|
+
role = data["role"].to_s
|
|
143
|
+
return "think" if role == "think"
|
|
144
|
+
|
|
145
|
+
data["tool"].to_s
|
|
146
|
+
end
|
|
147
|
+
private_class_method :resolve_tool
|
|
148
|
+
|
|
149
|
+
# Maps tool name to its decorator class.
|
|
150
|
+
# Unknown tools get the base decorator (generic rendering).
|
|
151
|
+
def self.decorator_for(tool_name)
|
|
152
|
+
case tool_name
|
|
153
|
+
when "bash" then BashDecorator
|
|
154
|
+
when "think" then ThinkDecorator
|
|
155
|
+
when "read" then ReadDecorator
|
|
156
|
+
when "edit" then EditDecorator
|
|
157
|
+
when "write" then WriteDecorator
|
|
158
|
+
when "web_get" then WebGetDecorator
|
|
159
|
+
else self
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
private_class_method :decorator_for
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TUI
|
|
4
|
+
module Decorators
|
|
5
|
+
# Renders bash tool calls and responses.
|
|
6
|
+
# Calls show the shell command with a terminal icon.
|
|
7
|
+
# Responses use green for success, red for failure.
|
|
8
|
+
class BashDecorator < BaseDecorator
|
|
9
|
+
ICON = "\u{1F4BB}" # laptop / terminal
|
|
10
|
+
|
|
11
|
+
def icon
|
|
12
|
+
ICON
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def response_color
|
|
16
|
+
(data["success"] == false) ? "red" : "green"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -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
|