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
data/lib/tui/screens/chat.rb
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../input_buffer"
|
|
4
|
+
require_relative "../flash"
|
|
5
|
+
require_relative "../performance_logger"
|
|
6
|
+
require_relative "../height_map"
|
|
7
|
+
require_relative "../decorators/base_decorator"
|
|
8
|
+
require_relative "../decorators/bash_decorator"
|
|
9
|
+
require_relative "../decorators/read_decorator"
|
|
10
|
+
require_relative "../decorators/edit_decorator"
|
|
11
|
+
require_relative "../decorators/write_decorator"
|
|
12
|
+
require_relative "../decorators/web_get_decorator"
|
|
13
|
+
require_relative "../decorators/think_decorator"
|
|
14
|
+
require_relative "../formatting"
|
|
4
15
|
|
|
5
16
|
module TUI
|
|
6
17
|
module Screens
|
|
7
18
|
class Chat
|
|
19
|
+
include Formatting
|
|
20
|
+
|
|
8
21
|
MIN_INPUT_HEIGHT = 3
|
|
9
22
|
PRINTABLE_CHAR = /\A[[:print:]]\z/
|
|
10
23
|
|
|
@@ -18,8 +31,11 @@ module TUI
|
|
|
18
31
|
TOOL_ICON = "\u{1F527}"
|
|
19
32
|
CLOCK_ICON = "\u{1F552}"
|
|
20
33
|
CHECKMARK = "\u2713"
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
|
|
35
|
+
# Viewport virtualization tuning
|
|
36
|
+
VIEWPORT_BACK_BUFFER = 3 # entries before scroll target for upward scroll margin
|
|
37
|
+
VIEWPORT_OVERFLOW_MULTIPLIER = 2 # build this many viewports worth of lines
|
|
38
|
+
VIEWPORT_BOTTOM_THRESHOLD = 10 # entries from end before we include all trailing
|
|
23
39
|
|
|
24
40
|
ROLE_COLORS = {"user" => "green", "assistant" => "cyan"}.freeze
|
|
25
41
|
|
|
@@ -30,13 +46,17 @@ module TUI
|
|
|
30
46
|
attr_reader :message_store, :scroll_offset, :session_info, :view_mode, :sessions_list,
|
|
31
47
|
:authentication_required, :token_save_result, :parent_session_id,
|
|
32
48
|
:chat_focused
|
|
49
|
+
attr_accessor :hud_hint
|
|
33
50
|
|
|
34
51
|
# @param cable_client [TUI::CableClient] WebSocket client connected to the brain
|
|
35
52
|
# @param message_store [TUI::MessageStore, nil] injectable for testing
|
|
36
|
-
|
|
53
|
+
# @param perf_logger [TUI::PerformanceLogger, nil] optional performance logger
|
|
54
|
+
def initialize(cable_client:, message_store: nil, perf_logger: nil)
|
|
37
55
|
@cable_client = cable_client
|
|
38
56
|
@message_store = message_store || MessageStore.new
|
|
57
|
+
@perf_logger = perf_logger || PerformanceLogger.new(enabled: false)
|
|
39
58
|
@input_buffer = InputBuffer.new
|
|
59
|
+
@flash = Flash.new
|
|
40
60
|
@loading = false
|
|
41
61
|
@scroll_offset = 0
|
|
42
62
|
@auto_scroll = true
|
|
@@ -44,7 +64,7 @@ module TUI
|
|
|
44
64
|
@max_scroll = 0
|
|
45
65
|
@input_scroll_offset = 0
|
|
46
66
|
@view_mode = "basic"
|
|
47
|
-
@session_info = {id: cable_client.session_id || 0, message_count: 0, active_skills: [], active_workflow: nil, goals: []}
|
|
67
|
+
@session_info = {id: cable_client.session_id || 0, message_count: 0, active_skills: [], active_workflow: nil, goals: [], children: []}
|
|
48
68
|
@sessions_list = nil
|
|
49
69
|
@parent_session_id = nil
|
|
50
70
|
@authentication_required = false
|
|
@@ -53,6 +73,14 @@ module TUI
|
|
|
53
73
|
@input_history = []
|
|
54
74
|
@history_index = nil
|
|
55
75
|
@saved_input = nil
|
|
76
|
+
# Viewport virtualization: only renders messages visible in the scroll
|
|
77
|
+
# window. Heights are estimated for all entries (cheap string math),
|
|
78
|
+
# but Line objects are only built for the visible range + buffer.
|
|
79
|
+
@height_map = HeightMap.new
|
|
80
|
+
@height_map_version = -1
|
|
81
|
+
@height_map_width = nil
|
|
82
|
+
@height_map_loading = nil
|
|
83
|
+
@viewport = viewport_cache_empty
|
|
56
84
|
end
|
|
57
85
|
|
|
58
86
|
def messages
|
|
@@ -84,6 +112,8 @@ module TUI
|
|
|
84
112
|
)
|
|
85
113
|
|
|
86
114
|
render_messages(frame, chat_area, tui)
|
|
115
|
+
render_flash(frame, chat_area, tui)
|
|
116
|
+
|
|
87
117
|
render_input(frame, input_area, tui)
|
|
88
118
|
end
|
|
89
119
|
|
|
@@ -99,6 +129,9 @@ module TUI
|
|
|
99
129
|
return handle_paste_event(event) if event.paste?
|
|
100
130
|
return handle_scroll_key(event) if event.page_up? || event.page_down?
|
|
101
131
|
|
|
132
|
+
# Dismiss flash on any keypress (flash auto-expires too)
|
|
133
|
+
@flash.dismiss! if @flash.any?
|
|
134
|
+
|
|
102
135
|
return handle_chat_focused_event(event) if @chat_focused
|
|
103
136
|
|
|
104
137
|
if event.up?
|
|
@@ -165,6 +198,21 @@ module TUI
|
|
|
165
198
|
@cable_client.change_view_mode(mode)
|
|
166
199
|
end
|
|
167
200
|
|
|
201
|
+
# Sends an interrupt request to the server to stop the current tool chain.
|
|
202
|
+
# Called when Escape is pressed with empty input during active processing.
|
|
203
|
+
#
|
|
204
|
+
# @return [void]
|
|
205
|
+
def interrupt_execution
|
|
206
|
+
@cable_client.interrupt
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Clears the input buffer. Used when Escape is pressed with non-empty input.
|
|
210
|
+
#
|
|
211
|
+
# @return [void]
|
|
212
|
+
def clear_input
|
|
213
|
+
@input_buffer.clear
|
|
214
|
+
end
|
|
215
|
+
|
|
168
216
|
# Clears the authentication_required flag after the App has consumed it.
|
|
169
217
|
# @return [void]
|
|
170
218
|
def clear_authentication_required
|
|
@@ -221,6 +269,8 @@ module TUI
|
|
|
221
269
|
handle_active_workflow_updated(msg)
|
|
222
270
|
when "goals_updated"
|
|
223
271
|
handle_goals_updated(msg)
|
|
272
|
+
when "children_updated"
|
|
273
|
+
handle_children_updated(msg)
|
|
224
274
|
when "sessions_list"
|
|
225
275
|
@sessions_list = msg["sessions"]
|
|
226
276
|
when "user_message_recalled"
|
|
@@ -229,13 +279,15 @@ module TUI
|
|
|
229
279
|
@authentication_required = true
|
|
230
280
|
when "token_saved"
|
|
231
281
|
@authentication_required = false
|
|
232
|
-
@token_save_result = {success: true}
|
|
282
|
+
@token_save_result = {success: true, warning: msg["warning"]}.compact
|
|
233
283
|
when "token_error"
|
|
234
284
|
@token_save_result = {success: false, message: msg["message"]}
|
|
235
285
|
when "error"
|
|
236
|
-
|
|
286
|
+
@flash.error(msg["message"]) if msg["message"]
|
|
237
287
|
else
|
|
238
288
|
case type
|
|
289
|
+
when "bounce_back"
|
|
290
|
+
handle_bounce_back(msg)
|
|
239
291
|
when "connection"
|
|
240
292
|
handle_connection_status(msg)
|
|
241
293
|
when "user_message"
|
|
@@ -271,6 +323,16 @@ module TUI
|
|
|
271
323
|
@message_store.remove_by_ids(evicted_ids)
|
|
272
324
|
end
|
|
273
325
|
|
|
326
|
+
# Renders flash messages as colored bars inside the chat frame,
|
|
327
|
+
# just below the top border (respecting rounded corners).
|
|
328
|
+
def render_flash(frame, chat_area, tui)
|
|
329
|
+
return unless @flash.any?
|
|
330
|
+
|
|
331
|
+
# Inner area: inset by 1 on each side for the chat frame border
|
|
332
|
+
inner = tui.block(borders: [:all]).inner(chat_area)
|
|
333
|
+
@flash.render(frame, inner, tui)
|
|
334
|
+
end
|
|
335
|
+
|
|
274
336
|
# Reacts to connection lifecycle changes from the WebSocket client.
|
|
275
337
|
# Clears stale state when subscription begins so the store is empty
|
|
276
338
|
# before history arrives. Action Cable sends confirm_subscription
|
|
@@ -287,6 +349,25 @@ module TUI
|
|
|
287
349
|
end
|
|
288
350
|
end
|
|
289
351
|
|
|
352
|
+
# Handles a Bounce Back event: the server rolled back the user event
|
|
353
|
+
# because LLM delivery failed. Removes the phantom message from the
|
|
354
|
+
# chat, restores the text to the input field, and shows a flash.
|
|
355
|
+
def handle_bounce_back(msg)
|
|
356
|
+
event_id = msg["event_id"]
|
|
357
|
+
content = msg["content"]
|
|
358
|
+
error = msg["error"]
|
|
359
|
+
|
|
360
|
+
@message_store.remove_by_id(event_id) if event_id
|
|
361
|
+
@loading = false
|
|
362
|
+
|
|
363
|
+
if content
|
|
364
|
+
@input_buffer.clear
|
|
365
|
+
@input_buffer.insert(content)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
@flash.error("Message not delivered: #{error}") if error
|
|
369
|
+
end
|
|
370
|
+
|
|
290
371
|
def handle_session_changed(msg)
|
|
291
372
|
new_id = msg["session_id"]
|
|
292
373
|
@cable_client.update_session_id(new_id)
|
|
@@ -294,7 +375,7 @@ module TUI
|
|
|
294
375
|
@view_mode = msg["view_mode"] if msg["view_mode"]
|
|
295
376
|
@session_info = {id: new_id, name: msg["name"], message_count: msg["message_count"] || 0,
|
|
296
377
|
active_skills: msg["active_skills"] || [], active_workflow: msg["active_workflow"],
|
|
297
|
-
goals: msg["goals"] || []}
|
|
378
|
+
goals: msg["goals"] || [], children: msg["children"] || []}
|
|
298
379
|
@parent_session_id = msg["parent_session_id"]
|
|
299
380
|
@input_buffer.clear
|
|
300
381
|
@loading = false
|
|
@@ -337,6 +418,14 @@ module TUI
|
|
|
337
418
|
@session_info[:goals] = msg["goals"] || []
|
|
338
419
|
end
|
|
339
420
|
|
|
421
|
+
# Updates the children list when a sub-agent is spawned or its
|
|
422
|
+
# processing state changes. Only applies to the current session.
|
|
423
|
+
def handle_children_updated(msg)
|
|
424
|
+
return unless msg["session_id"] == @session_info[:id]
|
|
425
|
+
|
|
426
|
+
@session_info[:children] = msg["children"] || []
|
|
427
|
+
end
|
|
428
|
+
|
|
340
429
|
# Handles server broadcast of view mode change. Clears the message store
|
|
341
430
|
# in preparation for the re-decorated viewport events that follow.
|
|
342
431
|
def handle_view_mode_changed(msg)
|
|
@@ -350,45 +439,80 @@ module TUI
|
|
|
350
439
|
@auto_scroll = true
|
|
351
440
|
end
|
|
352
441
|
|
|
442
|
+
# Renders chat messages using viewport virtualization.
|
|
443
|
+
# Only builds Line objects for entries visible in the scroll window,
|
|
444
|
+
# keeping render cost constant regardless of conversation length.
|
|
445
|
+
#
|
|
446
|
+
# Uses overflow-based viewport building: starts from the estimated
|
|
447
|
+
# scroll position and builds entries forward until enough lines
|
|
448
|
+
# accumulate to fill the viewport. Real line counts (not estimates)
|
|
449
|
+
# determine when to stop, so the buffer naturally adapts to entry
|
|
450
|
+
# sizes — no fixed entry-count buffer needed.
|
|
353
451
|
def render_messages(frame, area, tui)
|
|
354
|
-
|
|
452
|
+
inner_width = [area.width - 2, 1].max
|
|
453
|
+
@visible_height = [area.height - 2, 0].max
|
|
454
|
+
entries = messages
|
|
455
|
+
version = @message_store.version
|
|
355
456
|
|
|
356
|
-
if
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
])
|
|
457
|
+
if entries.empty?
|
|
458
|
+
render_empty_or_loading(frame, area, tui)
|
|
459
|
+
return
|
|
360
460
|
end
|
|
361
461
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
462
|
+
# Phase 1: Height estimation — O(n) string math, cached by version+width.
|
|
463
|
+
# Needed for total_height / scrollbar / scroll-offset-to-entry mapping.
|
|
464
|
+
@perf_logger.measure(:estimate_heights) { update_height_map(entries, inner_width, version) }
|
|
465
|
+
|
|
466
|
+
# Phase 2: Preliminary scroll offset for visible_range lookup.
|
|
467
|
+
# Don't clamp here — Phase 5.5 sets the authoritative @max_scroll
|
|
468
|
+
# from actual viewport height. Clamping here would cap scroll_offset
|
|
469
|
+
# to the (under)estimated total, making the bottom unreachable.
|
|
470
|
+
if @auto_scroll
|
|
471
|
+
est_total = @height_map.total_height
|
|
472
|
+
est_total += 1 if @loading
|
|
473
|
+
@scroll_offset = [est_total - @visible_height, 0].max
|
|
366
474
|
end
|
|
367
475
|
|
|
368
|
-
|
|
369
|
-
|
|
476
|
+
# Phase 3: Find approximate first visible entry
|
|
477
|
+
first_vis, = @height_map.visible_range(@scroll_offset, @visible_height)
|
|
370
478
|
|
|
371
|
-
|
|
372
|
-
|
|
479
|
+
# Phase 4: Build Line objects using overflow — stops when viewport is full
|
|
480
|
+
lines = @perf_logger.measure(:build_lines) {
|
|
481
|
+
cached_viewport_lines(tui, entries, version, first_vis)
|
|
482
|
+
}
|
|
373
483
|
|
|
374
|
-
|
|
484
|
+
# Phase 5: Paragraph widget + wrapped line count
|
|
485
|
+
base_widget = @perf_logger.measure(:paragraph) {
|
|
486
|
+
tui.paragraph(text: lines, wrap: true, style: tui.style(fg: "white"))
|
|
487
|
+
}
|
|
488
|
+
wrapped_height = @perf_logger.measure(:line_count) {
|
|
489
|
+
cached_viewport_line_count(base_widget, inner_width, version)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
# Phase 5.5: Correct scroll state using actual viewport height.
|
|
493
|
+
# Replace the estimated viewport portion with the real wrapped_height.
|
|
494
|
+
vp_first = @viewport[:first]
|
|
495
|
+
vp_last = @viewport[:last]
|
|
496
|
+
est_before = @height_map.cumulative_height(vp_first)
|
|
497
|
+
est_after = @height_map.total_height - @height_map.cumulative_height(vp_last + 1)
|
|
498
|
+
loading_outside = @loading && vp_last < entries.size - 1
|
|
499
|
+
corrected_total = est_before + wrapped_height + est_after + (loading_outside ? 1 : 0)
|
|
500
|
+
|
|
501
|
+
@max_scroll = [corrected_total - @visible_height, 0].max
|
|
375
502
|
@scroll_offset = @max_scroll if @auto_scroll
|
|
376
503
|
@scroll_offset = @scroll_offset.clamp(0, @max_scroll)
|
|
377
504
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if @chat_focused
|
|
385
|
-
chat_block[:titles] = [
|
|
386
|
-
{content: "\u2191\u2193 scroll Esc return", position: :bottom, alignment: :center}
|
|
387
|
-
]
|
|
388
|
-
end
|
|
505
|
+
# Phase 6: Map global scroll_offset into the viewport paragraph.
|
|
506
|
+
# est_before cancels between scroll_offset and max_scroll, so
|
|
507
|
+
# estimation errors don't create a dead zone at the bottom —
|
|
508
|
+
# they shift to the top (oldest messages) where they're harmless.
|
|
509
|
+
max_adjusted = [wrapped_height - @visible_height, 0].max
|
|
510
|
+
adjusted_scroll = (@scroll_offset - est_before).clamp(0, max_adjusted)
|
|
389
511
|
|
|
390
|
-
widget =
|
|
391
|
-
|
|
512
|
+
widget = @perf_logger.measure(:widget_with) {
|
|
513
|
+
base_widget.with(scroll: [adjusted_scroll, 0], block: tui.block(**chat_block_config))
|
|
514
|
+
}
|
|
515
|
+
@perf_logger.measure(:render_widget) { frame.render_widget(widget, area) }
|
|
392
516
|
|
|
393
517
|
return unless @max_scroll > 0
|
|
394
518
|
|
|
@@ -403,17 +527,206 @@ module TUI
|
|
|
403
527
|
frame.render_widget(scrollbar, area)
|
|
404
528
|
end
|
|
405
529
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
530
|
+
# Renders the empty or loading state placeholder when no messages exist.
|
|
531
|
+
# Resets scroll state since there is no scrollable content.
|
|
532
|
+
#
|
|
533
|
+
# @param frame [RatatuiRuby::Frame] current render frame
|
|
534
|
+
# @param area [RatatuiRuby::Rect] available area for the chat pane
|
|
535
|
+
# @param tui [RatatuiRuby] TUI rendering API
|
|
536
|
+
# @return [void]
|
|
537
|
+
def render_empty_or_loading(frame, area, tui)
|
|
538
|
+
lines = if @loading
|
|
539
|
+
[tui.line(spans: [
|
|
540
|
+
tui.span(content: "Thinking...", style: tui.style(fg: "yellow", modifiers: [:bold]))
|
|
541
|
+
])]
|
|
542
|
+
else
|
|
543
|
+
[tui.line(spans: [
|
|
544
|
+
tui.span(content: "Type a message to start chatting.", style: tui.style(fg: "dark_gray"))
|
|
545
|
+
])]
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
widget = tui.paragraph(text: lines, wrap: true, style: tui.style(fg: "white"))
|
|
549
|
+
.with(scroll: [0, 0], block: tui.block(**chat_block_config))
|
|
550
|
+
frame.render_widget(widget, area)
|
|
551
|
+
@max_scroll = 0
|
|
552
|
+
@scroll_offset = 0
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# Re-estimates entry heights when content or width changes.
|
|
556
|
+
# Height estimation is O(n) string-length math — orders of
|
|
557
|
+
# magnitude cheaper than building Line/Span objects. Skips
|
|
558
|
+
# re-estimation when version, width, and loading state are unchanged.
|
|
559
|
+
#
|
|
560
|
+
# @param entries [Array<Hash>] message store entries
|
|
561
|
+
# @param width [Integer] available terminal width
|
|
562
|
+
# @param version [Integer] message store version counter
|
|
563
|
+
# @return [void]
|
|
564
|
+
def update_height_map(entries, width, version)
|
|
565
|
+
return if version == @height_map_version && width == @height_map_width && @loading == @height_map_loading
|
|
566
|
+
|
|
567
|
+
@height_map.update(entries, width) { |entry, avail_width| estimate_entry_height(entry, avail_width) }
|
|
568
|
+
@height_map_version = version
|
|
569
|
+
@height_map_width = width
|
|
570
|
+
@height_map_loading = @loading
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Returns cached viewport lines, rebuilding only when content
|
|
574
|
+
# changes or scroll moves outside the cached range. Uses overflow
|
|
575
|
+
# building: starts from a back-buffer before the visible entry and
|
|
576
|
+
# builds forward until the pre-wrap line count exceeds 2x the
|
|
577
|
+
# viewport height. Real line counts determine the buffer size, so
|
|
578
|
+
# it naturally adapts to entry sizes.
|
|
579
|
+
def cached_viewport_lines(tui, entries, version, first_visible_est)
|
|
580
|
+
vp = @viewport
|
|
581
|
+
vp_first = vp[:first]
|
|
582
|
+
|
|
583
|
+
# Cache hit: content unchanged and scroll target within the built range
|
|
584
|
+
if version == vp[:version] && @loading == vp[:loading] &&
|
|
585
|
+
vp_first && first_visible_est >= vp_first && first_visible_est <= vp[:last]
|
|
586
|
+
return vp[:lines]
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
entry_count = entries.size
|
|
590
|
+
|
|
591
|
+
# Start a few entries before the scroll target for upward buffer
|
|
592
|
+
buf_first = [first_visible_est - VIEWPORT_BACK_BUFFER, 0].max
|
|
593
|
+
|
|
594
|
+
# Build forward until we've accumulated enough lines to fill the
|
|
595
|
+
# viewport with margin. Pre-wrap count is a lower bound on visual
|
|
596
|
+
# height (wrapping only adds lines), so 2x guarantees coverage.
|
|
597
|
+
target = @visible_height * VIEWPORT_OVERFLOW_MULTIPLIER
|
|
598
|
+
lines = []
|
|
599
|
+
pre_wrap_count = 0
|
|
600
|
+
buf_last = buf_first
|
|
601
|
+
|
|
602
|
+
(buf_first...entry_count).each do |idx|
|
|
603
|
+
entry_lines = build_entry_lines(tui, entries[idx])
|
|
604
|
+
lines.concat(entry_lines)
|
|
605
|
+
pre_wrap_count += entry_lines.size
|
|
606
|
+
buf_last = idx
|
|
607
|
+
# Stop early only when we have enough lines AND are far from
|
|
608
|
+
# the bottom. Near the bottom, always include trailing entries
|
|
609
|
+
# so the viewport covers the actual end of content — otherwise
|
|
610
|
+
# the last entries become unreachable.
|
|
611
|
+
break if pre_wrap_count >= target && entry_count - idx > VIEWPORT_BOTTOM_THRESHOLD
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
if @loading && buf_last >= entry_count - 1
|
|
615
|
+
lines << tui.line(spans: [
|
|
616
|
+
tui.span(content: "Thinking...", style: tui.style(fg: "yellow", modifiers: [:bold]))
|
|
617
|
+
])
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
@perf_logger.info(
|
|
621
|
+
"viewport MISS range=#{buf_first}..#{buf_last} " \
|
|
622
|
+
"of=#{entry_count} lines=#{lines.size}"
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
@viewport = {
|
|
626
|
+
version: version, loading: @loading, width: nil,
|
|
627
|
+
first: buf_first, last: buf_last,
|
|
628
|
+
lines: lines, wrapped_height: nil
|
|
629
|
+
}
|
|
630
|
+
lines
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Returns cached wrapped line count for the viewport paragraph.
|
|
634
|
+
# Avoids the expensive FFI line_count call when the viewport
|
|
635
|
+
# content and width haven't changed.
|
|
636
|
+
def cached_viewport_line_count(widget, width, version)
|
|
637
|
+
vp = @viewport
|
|
638
|
+
cached_height = vp[:wrapped_height]
|
|
639
|
+
if cached_height && version == vp[:version] && @loading == vp[:loading] && width == vp[:width]
|
|
640
|
+
return cached_height
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
height = widget.line_count(width)
|
|
644
|
+
@viewport[:width] = width
|
|
645
|
+
@viewport[:wrapped_height] = height
|
|
646
|
+
@perf_logger.info("viewport_lc MISS width=#{width} wrapped=#{height}")
|
|
647
|
+
height
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# Builds Line objects for a single message store entry.
|
|
651
|
+
# Dispatches by entry type to the appropriate line builder.
|
|
652
|
+
#
|
|
653
|
+
# @param tui [RatatuiRuby] TUI rendering API
|
|
654
|
+
# @param entry [Hash] message store entry
|
|
655
|
+
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
656
|
+
def build_entry_lines(tui, entry)
|
|
657
|
+
case entry[:type]
|
|
658
|
+
when :rendered then build_rendered_lines(tui, entry)
|
|
659
|
+
when :tool_counter then build_tool_counter_lines(tui, entry)
|
|
660
|
+
when :message then build_chat_message_lines(tui, entry)
|
|
661
|
+
else []
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# Estimates visual (wrapped) line count for a message store entry.
|
|
666
|
+
# Used only for scroll mapping (total_height, scrollbar) — the
|
|
667
|
+
# actual viewport uses real line counts from overflow building.
|
|
668
|
+
#
|
|
669
|
+
# @param entry [Hash] message store entry
|
|
670
|
+
# @param width [Integer] available terminal width
|
|
671
|
+
# @return [Integer] estimated visual lines (minimum 1)
|
|
672
|
+
def estimate_entry_height(entry, width)
|
|
673
|
+
effective_width = [width, 1].max
|
|
674
|
+
|
|
675
|
+
case entry[:type]
|
|
676
|
+
when :tool_counter
|
|
677
|
+
2 # counter line + blank separator
|
|
678
|
+
when :rendered
|
|
679
|
+
data = entry[:data]
|
|
680
|
+
text = [data["content"], data["input"]].compact.map(&:to_s).reject(&:empty?).join("\n")
|
|
681
|
+
lines = estimate_text_height(text, effective_width)
|
|
682
|
+
lines += 1 # header/label line
|
|
683
|
+
lines += 1 unless entry[:event_type] == "tool_call" # separator
|
|
684
|
+
lines
|
|
685
|
+
when :message
|
|
686
|
+
lines = estimate_text_height(entry[:content].to_s, effective_width)
|
|
687
|
+
lines + 1 # separator
|
|
688
|
+
else
|
|
689
|
+
1
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# Estimates visual line count for multi-line text after word-wrapping.
|
|
694
|
+
#
|
|
695
|
+
# @param text [String] content text with embedded newlines
|
|
696
|
+
# @param width [Integer] available width
|
|
697
|
+
# @return [Integer] estimated visual line count (minimum 1)
|
|
698
|
+
def estimate_text_height(text, width)
|
|
699
|
+
return 1 if text.empty?
|
|
700
|
+
|
|
701
|
+
text.split("\n", -1).sum { |line|
|
|
702
|
+
[(line.length.to_f / width).ceil, 1].max
|
|
703
|
+
}
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
VIEWPORT_CACHE_EMPTY = {
|
|
707
|
+
version: -1, loading: nil, width: nil,
|
|
708
|
+
first: nil, last: nil, lines: nil, wrapped_height: nil
|
|
709
|
+
}.freeze
|
|
710
|
+
|
|
711
|
+
def viewport_cache_empty
|
|
712
|
+
VIEWPORT_CACHE_EMPTY.dup
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# Builds the shared chat pane block config with focus-aware styling.
|
|
716
|
+
# @return [Hash] block configuration for tui.block
|
|
717
|
+
def chat_block_config
|
|
718
|
+
config = {
|
|
719
|
+
title: "Chat",
|
|
720
|
+
borders: [:all],
|
|
721
|
+
border_type: :rounded,
|
|
722
|
+
border_style: @chat_focused ? {fg: "yellow"} : {fg: "cyan"}
|
|
723
|
+
}
|
|
724
|
+
if @chat_focused
|
|
725
|
+
config[:titles] = [
|
|
726
|
+
{content: "\u2191\u2193 scroll Esc return", position: :bottom, alignment: :center}
|
|
727
|
+
]
|
|
416
728
|
end
|
|
729
|
+
config
|
|
417
730
|
end
|
|
418
731
|
|
|
419
732
|
# Renders a tool activity counter (e.g. "🔧 Tools: 2/2 ✓").
|
|
@@ -433,8 +746,10 @@ module TUI
|
|
|
433
746
|
]
|
|
434
747
|
end
|
|
435
748
|
|
|
436
|
-
# Renders structured event data from the server.
|
|
437
|
-
#
|
|
749
|
+
# Renders structured event data from the server. Tool-related roles
|
|
750
|
+
# (tool_call, tool_response, think) are dispatched to per-tool
|
|
751
|
+
# client-side decorators for tool-specific icons, colors, and formatting.
|
|
752
|
+
# Other roles are rendered inline.
|
|
438
753
|
# @param tui [RatatuiRuby] TUI rendering API
|
|
439
754
|
# @param entry [Hash] entry shaped `{type: :rendered, data: Hash}`
|
|
440
755
|
# @return [Array<RatatuiRuby::Widgets::Line>] rendered lines + blank separator
|
|
@@ -445,10 +760,8 @@ module TUI
|
|
|
445
760
|
lines = case role
|
|
446
761
|
when "user", "assistant"
|
|
447
762
|
render_conversation_entry(tui, data, role)
|
|
448
|
-
when "tool_call"
|
|
449
|
-
|
|
450
|
-
when "tool_response"
|
|
451
|
-
render_tool_response_entry(tui, data)
|
|
763
|
+
when "tool_call", "tool_response", "think"
|
|
764
|
+
Decorators::BaseDecorator.for(data).render(tui)
|
|
452
765
|
when "system"
|
|
453
766
|
render_system_entry(tui, data)
|
|
454
767
|
when "system_prompt"
|
|
@@ -489,42 +802,6 @@ module TUI
|
|
|
489
802
|
lines
|
|
490
803
|
end
|
|
491
804
|
|
|
492
|
-
# Renders a tool invocation with tool name, optional tool_use_id, and indented input.
|
|
493
|
-
# @param tui [RatatuiRuby] TUI rendering API
|
|
494
|
-
# @param data [Hash] structured data with "tool", "input", and optional "tool_use_id"
|
|
495
|
-
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
496
|
-
def render_tool_call_entry(tui, data)
|
|
497
|
-
style = tui.style(fg: "white")
|
|
498
|
-
header = "#{TOOL_ICON} #{data["tool"]}"
|
|
499
|
-
header += " [#{data["tool_use_id"]}]" if data["tool_use_id"]
|
|
500
|
-
|
|
501
|
-
lines = [tui.line(spans: [tui.span(content: header, style: style)])]
|
|
502
|
-
data["input"].to_s.split("\n").each do |line|
|
|
503
|
-
lines << tui.line(spans: [tui.span(content: " #{line}", style: style)])
|
|
504
|
-
end
|
|
505
|
-
lines
|
|
506
|
-
end
|
|
507
|
-
|
|
508
|
-
# Renders tool output with success/failure indicator, optional tool_use_id and token count.
|
|
509
|
-
# @param tui [RatatuiRuby] TUI rendering API
|
|
510
|
-
# @param data [Hash] structured data with "content", "success", and optional
|
|
511
|
-
# "tool_use_id", "tokens", "estimated"
|
|
512
|
-
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
513
|
-
def render_tool_response_entry(tui, data)
|
|
514
|
-
indicator = (data["success"] == false) ? ERROR_ICON : CHECKMARK
|
|
515
|
-
meta_parts = []
|
|
516
|
-
meta_parts << "[#{data["tool_use_id"]}]" if data["tool_use_id"]
|
|
517
|
-
meta_parts << indicator
|
|
518
|
-
meta_parts << format_token_label(data["tokens"], data["estimated"]) if data["tokens"]
|
|
519
|
-
prefix = " #{RETURN_ARROW} #{meta_parts.join(" ")} "
|
|
520
|
-
|
|
521
|
-
content_lines = data["content"].to_s.split("\n")
|
|
522
|
-
style = tui.style(fg: "white")
|
|
523
|
-
lines = [tui.line(spans: [tui.span(content: "#{prefix}#{content_lines.first}", style: style)])]
|
|
524
|
-
content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
|
|
525
|
-
lines
|
|
526
|
-
end
|
|
527
|
-
|
|
528
805
|
# Renders a system message with optional timestamp prefix.
|
|
529
806
|
# @param tui [RatatuiRuby] TUI rendering API
|
|
530
807
|
# @param data [Hash] structured data with "content" and optional "timestamp"
|
|
@@ -557,26 +834,6 @@ module TUI
|
|
|
557
834
|
lines
|
|
558
835
|
end
|
|
559
836
|
|
|
560
|
-
# Formats a token count for display, with tilde prefix for estimates.
|
|
561
|
-
# @param tokens [Integer, nil] token count
|
|
562
|
-
# @param estimated [Boolean] whether the count is an estimate
|
|
563
|
-
# @return [String] formatted label, e.g. "[42 tok]" or "[~28 tok]"
|
|
564
|
-
def format_token_label(tokens, estimated)
|
|
565
|
-
return "" unless tokens
|
|
566
|
-
|
|
567
|
-
label = estimated ? "~#{tokens}" : tokens.to_s
|
|
568
|
-
"[#{label} tok]"
|
|
569
|
-
end
|
|
570
|
-
|
|
571
|
-
# Converts nanosecond-precision timestamp to human-readable HH:MM:SS.
|
|
572
|
-
# @param ns [Integer, nil] nanosecond timestamp
|
|
573
|
-
# @return [String] formatted time, or "--:--:--" when nil
|
|
574
|
-
def format_ns_timestamp(ns)
|
|
575
|
-
return "--:--:--" unless ns
|
|
576
|
-
|
|
577
|
-
Time.at(ns / 1_000_000_000.0).strftime("%H:%M:%S")
|
|
578
|
-
end
|
|
579
|
-
|
|
580
837
|
def build_chat_message_lines(tui, msg)
|
|
581
838
|
role = msg[:role]
|
|
582
839
|
role_style = (role == ROLE_USER) ? tui.style(fg: "green", modifiers: [:bold]) : tui.style(fg: "cyan", modifiers: [:bold])
|
|
@@ -623,9 +880,7 @@ module TUI
|
|
|
623
880
|
scroll: [input_scroll, 0],
|
|
624
881
|
block: tui.block(
|
|
625
882
|
title: title,
|
|
626
|
-
titles: disabled
|
|
627
|
-
{content: "Enter send", position: :bottom, alignment: :center}
|
|
628
|
-
],
|
|
883
|
+
titles: input_bottom_titles(disabled),
|
|
629
884
|
borders: [:all],
|
|
630
885
|
border_type: :rounded,
|
|
631
886
|
border_style: styles[:border]
|
|
@@ -664,6 +919,16 @@ module TUI
|
|
|
664
919
|
end
|
|
665
920
|
end
|
|
666
921
|
|
|
922
|
+
def input_bottom_titles(disabled)
|
|
923
|
+
return [] if disabled
|
|
924
|
+
|
|
925
|
+
command_hint = @hud_hint ? "C-a → h HUD" : "C-a command"
|
|
926
|
+
[
|
|
927
|
+
{content: command_hint, position: :bottom, alignment: :left},
|
|
928
|
+
{content: "Enter send", position: :bottom, alignment: :center}
|
|
929
|
+
]
|
|
930
|
+
end
|
|
931
|
+
|
|
667
932
|
# Builds input text as pre-wrapped Line objects for the Paragraph widget.
|
|
668
933
|
# Lines are word-wrapped here so the Paragraph renders without its own
|
|
669
934
|
# wrapping, keeping cursor positioning in sync with the displayed text.
|