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
data/lib/tui/app.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "time"
|
|
|
4
4
|
require_relative "cable_client"
|
|
5
5
|
require_relative "input_buffer"
|
|
6
6
|
require_relative "message_store"
|
|
7
|
+
require_relative "performance_logger"
|
|
7
8
|
require_relative "screens/chat"
|
|
8
9
|
|
|
9
10
|
module TUI
|
|
@@ -12,6 +13,7 @@ module TUI
|
|
|
12
13
|
|
|
13
14
|
COMMAND_KEYS = {
|
|
14
15
|
"a" => :anthropic_token,
|
|
16
|
+
"h" => :toggle_hud,
|
|
15
17
|
"n" => :new_session,
|
|
16
18
|
"s" => :session_picker,
|
|
17
19
|
"v" => :view_mode,
|
|
@@ -21,7 +23,8 @@ module TUI
|
|
|
21
23
|
MENU_LABELS = (COMMAND_KEYS.map { |key, action| "[#{key}] #{action.to_s.tr("_", " ").capitalize}" } +
|
|
22
24
|
["[\u2191] Scroll chat", "[\u2193] Return to input"]).freeze
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
# HUD occupies 1/3 of screen width, clamped to a usable minimum.
|
|
27
|
+
HUD_MIN_WIDTH = 24
|
|
25
28
|
|
|
26
29
|
# Picker entry prefix width: "[N]" (3) + marker (1) + space (1) = 5
|
|
27
30
|
PICKER_PREFIX_WIDTH = 5
|
|
@@ -77,14 +80,20 @@ module TUI
|
|
|
77
80
|
|
|
78
81
|
attr_reader :current_screen, :command_mode, :session_picker_active,
|
|
79
82
|
:view_mode_picker_active
|
|
83
|
+
# @return [Boolean] true when the HUD info panel is visible
|
|
84
|
+
attr_reader :hud_visible
|
|
80
85
|
# @return [Boolean] true when the token setup popup overlay is visible
|
|
81
86
|
attr_reader :token_setup_active
|
|
82
87
|
# @return [Boolean] true when graceful shutdown has been requested via signal
|
|
83
88
|
attr_reader :shutdown_requested
|
|
89
|
+
# @return [TUI::PerformanceLogger] frame timing logger (no-op when debug is off)
|
|
90
|
+
attr_reader :perf_logger
|
|
84
91
|
|
|
85
92
|
# @param cable_client [TUI::CableClient] WebSocket client connected to the brain
|
|
86
|
-
|
|
93
|
+
# @param debug [Boolean] enable performance logging to log/tui_performance.log
|
|
94
|
+
def initialize(cable_client:, debug: false)
|
|
87
95
|
@cable_client = cable_client
|
|
96
|
+
@perf_logger = PerformanceLogger.new(enabled: debug)
|
|
88
97
|
@current_screen = :chat
|
|
89
98
|
@command_mode = false
|
|
90
99
|
@session_picker_active = false
|
|
@@ -92,17 +101,19 @@ module TUI
|
|
|
92
101
|
@session_picker_page = 0
|
|
93
102
|
@session_picker_mode = :root
|
|
94
103
|
@session_picker_parent_id = nil
|
|
104
|
+
@hud_visible = true
|
|
95
105
|
@view_mode_picker_active = false
|
|
96
106
|
@view_mode_picker_index = 0
|
|
97
107
|
@token_setup_active = false
|
|
98
108
|
@token_input_buffer = InputBuffer.new
|
|
99
109
|
@token_setup_error = nil
|
|
110
|
+
@token_setup_warning = nil
|
|
100
111
|
@token_setup_status = :idle
|
|
101
112
|
@shutdown_requested = false
|
|
102
113
|
@previous_signal_handlers = {}
|
|
103
114
|
@watchdog_thread = nil
|
|
104
115
|
@screens = {
|
|
105
|
-
chat: Screens::Chat.new(cable_client: cable_client)
|
|
116
|
+
chat: Screens::Chat.new(cable_client: cable_client, perf_logger: @perf_logger)
|
|
106
117
|
}
|
|
107
118
|
end
|
|
108
119
|
|
|
@@ -130,20 +141,31 @@ module TUI
|
|
|
130
141
|
private
|
|
131
142
|
|
|
132
143
|
def render(frame, tui)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
144
|
+
@perf_logger.start_frame
|
|
145
|
+
|
|
146
|
+
@screens[:chat].hud_hint = !@hud_visible
|
|
147
|
+
|
|
148
|
+
if @hud_visible
|
|
149
|
+
hud_width = [frame.area.width / 3, HUD_MIN_WIDTH].max
|
|
150
|
+
content_area, sidebar = tui.split(
|
|
151
|
+
frame.area,
|
|
152
|
+
direction: :horizontal,
|
|
153
|
+
constraints: [
|
|
154
|
+
tui.constraint_fill(1),
|
|
155
|
+
tui.constraint_length(hud_width)
|
|
156
|
+
]
|
|
157
|
+
)
|
|
141
158
|
|
|
142
|
-
|
|
143
|
-
|
|
159
|
+
@perf_logger.measure(:chat_render) { @screens[@current_screen].render(frame, content_area, tui) }
|
|
160
|
+
@perf_logger.measure(:sidebar) { render_sidebar(frame, sidebar, tui) }
|
|
161
|
+
else
|
|
162
|
+
@perf_logger.measure(:chat_render) { @screens[@current_screen].render(frame, frame.area, tui) }
|
|
163
|
+
end
|
|
144
164
|
|
|
145
165
|
check_token_setup_signals
|
|
146
166
|
render_token_setup_popup(frame, frame.area, tui) if @token_setup_active
|
|
167
|
+
|
|
168
|
+
@perf_logger.end_frame
|
|
147
169
|
end
|
|
148
170
|
|
|
149
171
|
def render_sidebar(frame, area, tui)
|
|
@@ -171,10 +193,73 @@ module TUI
|
|
|
171
193
|
frame.render_widget(menu, area)
|
|
172
194
|
end
|
|
173
195
|
|
|
196
|
+
# HUD status icons for goal progress and sub-agent activity.
|
|
197
|
+
GOAL_ICON_ACTIVE = "\u25CF" # ●
|
|
198
|
+
GOAL_ICON_IN_PROGRESS = "\u25D0" # ◐
|
|
199
|
+
GOAL_ICON_COMPLETED = "\u2713" # ✓
|
|
200
|
+
CHILD_ICON_RUNNING = "\u25CF" # ●
|
|
201
|
+
CHILD_ICON_IDLE = "\u25CC" # ◌
|
|
202
|
+
|
|
174
203
|
def render_info(frame, area, tui)
|
|
175
204
|
session = @screens[:chat].session_info
|
|
176
|
-
view_mode = @screens[:chat].view_mode
|
|
177
205
|
|
|
206
|
+
# Split into main content area and bottom status bar
|
|
207
|
+
main_area, status_area = tui.split(
|
|
208
|
+
area,
|
|
209
|
+
direction: :vertical,
|
|
210
|
+
constraints: [
|
|
211
|
+
tui.constraint_fill(1),
|
|
212
|
+
tui.constraint_length(3)
|
|
213
|
+
]
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
render_hud_content(frame, main_area, tui, session)
|
|
217
|
+
render_hud_status_bar(frame, status_area, tui)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Renders the main HUD content: session name, goals, skills,
|
|
221
|
+
# workflow, and sub-agents.
|
|
222
|
+
def render_hud_content(frame, area, tui, session)
|
|
223
|
+
session_label = session[:name] || "##{session[:id]}"
|
|
224
|
+
|
|
225
|
+
lines = [
|
|
226
|
+
tui.line(spans: [
|
|
227
|
+
tui.span(content: "\u{1F4CB} ", style: tui.style(fg: "dark_gray")),
|
|
228
|
+
tui.span(content: session_label, style: tui.style(fg: "cyan", modifiers: [:bold]))
|
|
229
|
+
]),
|
|
230
|
+
hud_goals_section(tui, session),
|
|
231
|
+
hud_skills_line(tui, session),
|
|
232
|
+
hud_workflow_line(tui, session),
|
|
233
|
+
hud_children_section(tui, session),
|
|
234
|
+
interaction_state_line(tui)
|
|
235
|
+
].flatten.compact
|
|
236
|
+
|
|
237
|
+
content = tui.paragraph(
|
|
238
|
+
text: lines,
|
|
239
|
+
wrap: true,
|
|
240
|
+
block: tui.block(
|
|
241
|
+
borders: [:left, :top, :right],
|
|
242
|
+
border_type: :rounded,
|
|
243
|
+
border_style: {fg: "white"}
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
frame.render_widget(content, area)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Renders the bottom status bar: connection state and model name.
|
|
250
|
+
def render_hud_status_bar(frame, area, tui)
|
|
251
|
+
cable_status = @cable_client.status
|
|
252
|
+
style = STATUS_STYLES.fetch(cable_status, STATUS_STYLES[:disconnected])
|
|
253
|
+
|
|
254
|
+
status_label = if cable_status == :reconnecting
|
|
255
|
+
attempt = @cable_client.reconnect_attempt
|
|
256
|
+
max = CableClient::MAX_RECONNECT_ATTEMPTS
|
|
257
|
+
"#{style[:label]} (#{attempt}/#{max})"
|
|
258
|
+
else
|
|
259
|
+
style[:label]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
view_mode = @screens[:chat].view_mode
|
|
178
263
|
mode_label = view_mode.capitalize
|
|
179
264
|
mode_color = case view_mode
|
|
180
265
|
when "verbose" then "yellow"
|
|
@@ -182,110 +267,133 @@ module TUI
|
|
|
182
267
|
else "cyan"
|
|
183
268
|
end
|
|
184
269
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
lines = [
|
|
188
|
-
tui.line(spans: [
|
|
189
|
-
tui.span(content: "Anima v#{Anima::VERSION}", style: tui.style(fg: "white"))
|
|
190
|
-
]),
|
|
191
|
-
tui.line(spans: [tui.span(content: "")]),
|
|
192
|
-
if session[:name]
|
|
193
|
-
tui.line(spans: [
|
|
194
|
-
tui.span(content: session_label, style: tui.style(fg: "cyan", modifiers: [:bold]))
|
|
195
|
-
])
|
|
196
|
-
else
|
|
270
|
+
bar = tui.paragraph(
|
|
271
|
+
text: [
|
|
197
272
|
tui.line(spans: [
|
|
198
|
-
tui.span(content:
|
|
199
|
-
tui.span(content:
|
|
273
|
+
tui.span(content: status_label, style: tui.style(fg: style[:color], modifiers: [:bold])),
|
|
274
|
+
tui.span(content: " \u2502 ", style: tui.style(fg: "dark_gray")),
|
|
275
|
+
tui.span(content: mode_label, style: tui.style(fg: mode_color, modifiers: [:bold]))
|
|
200
276
|
])
|
|
201
|
-
|
|
202
|
-
tui.line(spans: [
|
|
203
|
-
tui.span(content: "Messages ", style: tui.style(fg: "dark_gray")),
|
|
204
|
-
tui.span(content: session[:message_count].to_s, style: tui.style(fg: "cyan"))
|
|
205
|
-
]),
|
|
206
|
-
active_skills_line(tui, session),
|
|
207
|
-
active_workflow_line(tui, session),
|
|
208
|
-
goals_line(tui, session),
|
|
209
|
-
tui.line(spans: [tui.span(content: "")]),
|
|
210
|
-
tui.line(spans: [
|
|
211
|
-
tui.span(content: "Mode ", style: tui.style(fg: "dark_gray")),
|
|
212
|
-
tui.span(content: mode_label, style: tui.style(fg: mode_color, modifiers: [:bold]))
|
|
213
|
-
]),
|
|
214
|
-
interaction_state_line(tui),
|
|
215
|
-
tui.line(spans: [tui.span(content: "")]),
|
|
216
|
-
connection_status_line(tui),
|
|
217
|
-
tui.line(spans: [tui.span(content: "")]),
|
|
218
|
-
tui.line(spans: [
|
|
219
|
-
tui.span(content: "Ctrl+a", style: tui.style(fg: "cyan", modifiers: [:bold])),
|
|
220
|
-
tui.span(content: " command mode", style: tui.style(fg: "dark_gray"))
|
|
221
|
-
])
|
|
222
|
-
].compact
|
|
223
|
-
|
|
224
|
-
info = tui.paragraph(
|
|
225
|
-
text: lines,
|
|
277
|
+
],
|
|
226
278
|
block: tui.block(
|
|
227
|
-
|
|
228
|
-
borders: [:all],
|
|
279
|
+
borders: [:left, :bottom, :right],
|
|
229
280
|
border_type: :rounded,
|
|
230
281
|
border_style: {fg: "white"}
|
|
231
282
|
)
|
|
232
283
|
)
|
|
233
|
-
frame.render_widget(
|
|
284
|
+
frame.render_widget(bar, area)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Builds goal lines with status icons and descriptions.
|
|
288
|
+
# Root goals show as individual lines. A root goal with some completed
|
|
289
|
+
# sub-goals shows the ◐ (in-progress) icon.
|
|
290
|
+
#
|
|
291
|
+
# @return [Array<RatatuiRuby::Widgets::Line>, nil]
|
|
292
|
+
def hud_goals_section(tui, session)
|
|
293
|
+
goal_list = session[:goals]
|
|
294
|
+
return if goal_list.nil? || goal_list.empty?
|
|
295
|
+
|
|
296
|
+
lines = [
|
|
297
|
+
tui.line(spans: [tui.span(content: "")]),
|
|
298
|
+
tui.line(spans: [
|
|
299
|
+
tui.span(content: "\u{1F3AF} Goals", style: tui.style(fg: "dark_gray"))
|
|
300
|
+
])
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
goal_list.each do |goal|
|
|
304
|
+
icon, color = goal_icon_and_color(goal)
|
|
305
|
+
lines << tui.line(spans: [
|
|
306
|
+
tui.span(content: " #{icon} ", style: tui.style(fg: color)),
|
|
307
|
+
tui.span(content: goal["description"].to_s, style: tui.style(fg: "white"))
|
|
308
|
+
])
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
lines
|
|
234
312
|
end
|
|
235
313
|
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
# @param
|
|
240
|
-
# @return [
|
|
241
|
-
def
|
|
314
|
+
# Returns the status icon and color for a goal.
|
|
315
|
+
# Active goals with some completed sub-goals show as in-progress (◐).
|
|
316
|
+
#
|
|
317
|
+
# @param goal [Hash] goal data with "status" and optional "sub_goals" keys
|
|
318
|
+
# @return [Array(String, String)] icon and color pair
|
|
319
|
+
def goal_icon_and_color(goal)
|
|
320
|
+
if goal["status"] == "completed"
|
|
321
|
+
[GOAL_ICON_COMPLETED, "green"]
|
|
322
|
+
elsif goal["sub_goals"]&.any? { |sg| sg["status"] == "completed" }
|
|
323
|
+
[GOAL_ICON_IN_PROGRESS, "yellow"]
|
|
324
|
+
else
|
|
325
|
+
[GOAL_ICON_ACTIVE, "cyan"]
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Builds the skills line with brain emoji.
|
|
330
|
+
# @return [Array<RatatuiRuby::Widgets::Line>, nil]
|
|
331
|
+
def hud_skills_line(tui, session)
|
|
242
332
|
skills = session[:active_skills]
|
|
243
333
|
return if skills.nil? || skills.empty?
|
|
244
334
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
tui.
|
|
248
|
-
|
|
249
|
-
|
|
335
|
+
[
|
|
336
|
+
tui.line(spans: [tui.span(content: "")]),
|
|
337
|
+
tui.line(spans: [
|
|
338
|
+
tui.span(content: "\u{1F9E0} ", style: tui.style(fg: "dark_gray")),
|
|
339
|
+
tui.span(content: skills.join(", "), style: tui.style(fg: "yellow"))
|
|
340
|
+
])
|
|
341
|
+
]
|
|
250
342
|
end
|
|
251
343
|
|
|
252
|
-
# Builds the
|
|
253
|
-
#
|
|
254
|
-
|
|
255
|
-
# @param session [Hash] session info hash containing :active_workflow string
|
|
256
|
-
# @return [RatatuiRuby::Widgets::Line, nil] styled workflow line, or nil when empty
|
|
257
|
-
def active_workflow_line(tui, session)
|
|
344
|
+
# Builds the workflow line with scroll emoji.
|
|
345
|
+
# @return [Array<RatatuiRuby::Widgets::Line>, nil]
|
|
346
|
+
def hud_workflow_line(tui, session)
|
|
258
347
|
workflow = session[:active_workflow]
|
|
259
348
|
return if workflow.nil? || workflow.empty?
|
|
260
349
|
|
|
261
|
-
|
|
262
|
-
tui.
|
|
263
|
-
tui.
|
|
264
|
-
|
|
350
|
+
[
|
|
351
|
+
tui.line(spans: [tui.span(content: "")]),
|
|
352
|
+
tui.line(spans: [
|
|
353
|
+
tui.span(content: "\u{1F4DC} ", style: tui.style(fg: "dark_gray")),
|
|
354
|
+
tui.span(content: workflow, style: tui.style(fg: "magenta"))
|
|
355
|
+
])
|
|
356
|
+
]
|
|
265
357
|
end
|
|
266
358
|
|
|
267
|
-
# Builds the
|
|
268
|
-
#
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
# @return [RatatuiRuby::Widgets::Line, nil] styled goals line, or nil when empty
|
|
273
|
-
def goals_line(tui, session)
|
|
274
|
-
goal_list = session[:goals]
|
|
275
|
-
return if goal_list.nil? || goal_list.empty?
|
|
359
|
+
# Builds the sub-agents section with activity indicators.
|
|
360
|
+
# @return [Array<RatatuiRuby::Widgets::Line>, nil]
|
|
361
|
+
def hud_children_section(tui, session)
|
|
362
|
+
children = session[:children]
|
|
363
|
+
return if children.nil? || children.empty?
|
|
276
364
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
365
|
+
lines = [
|
|
366
|
+
tui.line(spans: [tui.span(content: "")]),
|
|
367
|
+
tui.line(spans: [
|
|
368
|
+
tui.span(content: "\u{1F465} Sub-agents", style: tui.style(fg: "dark_gray"))
|
|
369
|
+
])
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
children.each do |child|
|
|
373
|
+
icon, color = child_icon_and_color(child)
|
|
374
|
+
name = child["name"] || "sub-agent"
|
|
375
|
+
lines << tui.line(spans: [
|
|
376
|
+
tui.span(content: " #{icon} ", style: tui.style(fg: color)),
|
|
377
|
+
tui.span(content: "@#{name}", style: tui.style(fg: "white"))
|
|
378
|
+
])
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
lines
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Returns the activity icon and color for a child session.
|
|
385
|
+
#
|
|
386
|
+
# @param child [Hash] child session data with "processing" key
|
|
387
|
+
# @return [Array(String, String)] icon and color pair
|
|
388
|
+
def child_icon_and_color(child)
|
|
389
|
+
if child["processing"]
|
|
390
|
+
[CHILD_ICON_RUNNING, "yellow"]
|
|
391
|
+
else
|
|
392
|
+
[CHILD_ICON_IDLE, "green"]
|
|
393
|
+
end
|
|
285
394
|
end
|
|
286
395
|
|
|
287
|
-
#
|
|
288
|
-
# Shows "Scrolling" when chat pane is focused, or "Thinking..." during LLM processing.
|
|
396
|
+
# Shows "Scrolling" when chat pane is focused, "Thinking..." during LLM processing.
|
|
289
397
|
def interaction_state_line(tui)
|
|
290
398
|
if @screens[:chat].chat_focused
|
|
291
399
|
tui.line(spans: [
|
|
@@ -295,33 +403,9 @@ module TUI
|
|
|
295
403
|
tui.line(spans: [
|
|
296
404
|
tui.span(content: "Thinking...", style: tui.style(fg: "magenta", modifiers: [:bold]))
|
|
297
405
|
])
|
|
298
|
-
else
|
|
299
|
-
tui.line(spans: [tui.span(content: "")])
|
|
300
406
|
end
|
|
301
407
|
end
|
|
302
408
|
|
|
303
|
-
# Builds the connection status line for the info panel.
|
|
304
|
-
# Shows a single emoji for the normal (subscribed) state; adds descriptive
|
|
305
|
-
# text only when something requires attention.
|
|
306
|
-
# @param tui [RatatuiRuby] TUI rendering context
|
|
307
|
-
# @return [RatatuiRuby::Widgets::Line] styled status line with emoji indicator
|
|
308
|
-
def connection_status_line(tui)
|
|
309
|
-
cable_status = @cable_client.status
|
|
310
|
-
style = STATUS_STYLES.fetch(cable_status, STATUS_STYLES[:disconnected])
|
|
311
|
-
|
|
312
|
-
label = if cable_status == :reconnecting
|
|
313
|
-
attempt = @cable_client.reconnect_attempt
|
|
314
|
-
max = CableClient::MAX_RECONNECT_ATTEMPTS
|
|
315
|
-
"#{style[:label]} (#{attempt}/#{max})"
|
|
316
|
-
else
|
|
317
|
-
style[:label]
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
tui.line(spans: [
|
|
321
|
-
tui.span(content: label, style: tui.style(fg: style[:color], modifiers: [:bold]))
|
|
322
|
-
])
|
|
323
|
-
end
|
|
324
|
-
|
|
325
409
|
def chat_loading?
|
|
326
410
|
@screens[:chat].loading?
|
|
327
411
|
end
|
|
@@ -365,6 +449,9 @@ module TUI
|
|
|
365
449
|
when :anthropic_token
|
|
366
450
|
activate_token_setup
|
|
367
451
|
nil
|
|
452
|
+
when :toggle_hud
|
|
453
|
+
@hud_visible = !@hud_visible
|
|
454
|
+
nil
|
|
368
455
|
when :new_session
|
|
369
456
|
@screens[:chat].new_session
|
|
370
457
|
@current_screen = :chat
|
|
@@ -891,13 +978,14 @@ module TUI
|
|
|
891
978
|
# -- Token setup popup -----------------------------------------------
|
|
892
979
|
|
|
893
980
|
# Opens the token setup popup and resets all input state.
|
|
894
|
-
# Can be triggered manually via
|
|
981
|
+
# Can be triggered manually via C-a → a or automatically when the
|
|
895
982
|
# brain broadcasts authentication_required.
|
|
896
983
|
# @return [void]
|
|
897
984
|
def activate_token_setup
|
|
898
985
|
@token_setup_active = true
|
|
899
986
|
@token_input_buffer.clear
|
|
900
987
|
@token_setup_error = nil
|
|
988
|
+
@token_setup_warning = nil
|
|
901
989
|
@token_setup_status = :idle
|
|
902
990
|
end
|
|
903
991
|
|
|
@@ -907,6 +995,7 @@ module TUI
|
|
|
907
995
|
@token_setup_active = false
|
|
908
996
|
@token_input_buffer.clear
|
|
909
997
|
@token_setup_error = nil
|
|
998
|
+
@token_setup_warning = nil
|
|
910
999
|
@token_setup_status = :idle
|
|
911
1000
|
end
|
|
912
1001
|
|
|
@@ -933,9 +1022,11 @@ module TUI
|
|
|
933
1022
|
if result[:success]
|
|
934
1023
|
@token_setup_status = :success
|
|
935
1024
|
@token_setup_error = nil
|
|
1025
|
+
@token_setup_warning = result[:warning]
|
|
936
1026
|
else
|
|
937
1027
|
@token_setup_status = :error
|
|
938
1028
|
@token_setup_error = result[:message]
|
|
1029
|
+
@token_setup_warning = nil
|
|
939
1030
|
end
|
|
940
1031
|
end
|
|
941
1032
|
|
|
@@ -1087,9 +1178,15 @@ module TUI
|
|
|
1087
1178
|
end
|
|
1088
1179
|
|
|
1089
1180
|
if @token_setup_status == :success
|
|
1090
|
-
lines <<
|
|
1091
|
-
tui.
|
|
1092
|
-
|
|
1181
|
+
lines << if @token_setup_warning
|
|
1182
|
+
tui.line(spans: [
|
|
1183
|
+
tui.span(content: "Token saved (API unavailable, validation skipped)", style: tui.style(fg: "yellow", modifiers: [:bold]))
|
|
1184
|
+
])
|
|
1185
|
+
else
|
|
1186
|
+
tui.line(spans: [
|
|
1187
|
+
tui.span(content: "Token saved and validated!", style: tui.style(fg: "green", modifiers: [:bold]))
|
|
1188
|
+
])
|
|
1189
|
+
end
|
|
1093
1190
|
lines << tui.line(spans: [tui.span(content: "")])
|
|
1094
1191
|
end
|
|
1095
1192
|
|
|
@@ -1110,7 +1207,7 @@ module TUI
|
|
|
1110
1207
|
def token_status_display
|
|
1111
1208
|
case @token_setup_status
|
|
1112
1209
|
when :success
|
|
1113
|
-
["Valid", "green"]
|
|
1210
|
+
@token_setup_warning ? ["Saved (unverified)", "yellow"] : ["Valid", "green"]
|
|
1114
1211
|
when :validating
|
|
1115
1212
|
["Validating...", "yellow"]
|
|
1116
1213
|
when :error
|
|
@@ -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
|