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/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,10 +31,11 @@ module TUI
|
|
|
18
31
|
TOOL_ICON = "\u{1F527}"
|
|
19
32
|
CLOCK_ICON = "\u{1F552}"
|
|
20
33
|
CHECKMARK = "\u2713"
|
|
21
|
-
RETURN_ARROW = "\u21A9"
|
|
22
|
-
ERROR_ICON = "\u274C"
|
|
23
34
|
|
|
24
|
-
|
|
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
|
|
25
39
|
|
|
26
40
|
ROLE_COLORS = {"user" => "green", "assistant" => "cyan"}.freeze
|
|
27
41
|
|
|
@@ -32,13 +46,17 @@ module TUI
|
|
|
32
46
|
attr_reader :message_store, :scroll_offset, :session_info, :view_mode, :sessions_list,
|
|
33
47
|
:authentication_required, :token_save_result, :parent_session_id,
|
|
34
48
|
:chat_focused
|
|
49
|
+
attr_accessor :hud_hint
|
|
35
50
|
|
|
36
51
|
# @param cable_client [TUI::CableClient] WebSocket client connected to the brain
|
|
37
52
|
# @param message_store [TUI::MessageStore, nil] injectable for testing
|
|
38
|
-
|
|
53
|
+
# @param perf_logger [TUI::PerformanceLogger, nil] optional performance logger
|
|
54
|
+
def initialize(cable_client:, message_store: nil, perf_logger: nil)
|
|
39
55
|
@cable_client = cable_client
|
|
40
56
|
@message_store = message_store || MessageStore.new
|
|
57
|
+
@perf_logger = perf_logger || PerformanceLogger.new(enabled: false)
|
|
41
58
|
@input_buffer = InputBuffer.new
|
|
59
|
+
@flash = Flash.new
|
|
42
60
|
@loading = false
|
|
43
61
|
@scroll_offset = 0
|
|
44
62
|
@auto_scroll = true
|
|
@@ -46,7 +64,7 @@ module TUI
|
|
|
46
64
|
@max_scroll = 0
|
|
47
65
|
@input_scroll_offset = 0
|
|
48
66
|
@view_mode = "basic"
|
|
49
|
-
@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: []}
|
|
50
68
|
@sessions_list = nil
|
|
51
69
|
@parent_session_id = nil
|
|
52
70
|
@authentication_required = false
|
|
@@ -55,6 +73,14 @@ module TUI
|
|
|
55
73
|
@input_history = []
|
|
56
74
|
@history_index = nil
|
|
57
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
|
|
58
84
|
end
|
|
59
85
|
|
|
60
86
|
def messages
|
|
@@ -86,6 +112,8 @@ module TUI
|
|
|
86
112
|
)
|
|
87
113
|
|
|
88
114
|
render_messages(frame, chat_area, tui)
|
|
115
|
+
render_flash(frame, chat_area, tui)
|
|
116
|
+
|
|
89
117
|
render_input(frame, input_area, tui)
|
|
90
118
|
end
|
|
91
119
|
|
|
@@ -101,6 +129,9 @@ module TUI
|
|
|
101
129
|
return handle_paste_event(event) if event.paste?
|
|
102
130
|
return handle_scroll_key(event) if event.page_up? || event.page_down?
|
|
103
131
|
|
|
132
|
+
# Dismiss flash on any keypress (flash auto-expires too)
|
|
133
|
+
@flash.dismiss! if @flash.any?
|
|
134
|
+
|
|
104
135
|
return handle_chat_focused_event(event) if @chat_focused
|
|
105
136
|
|
|
106
137
|
if event.up?
|
|
@@ -238,6 +269,8 @@ module TUI
|
|
|
238
269
|
handle_active_workflow_updated(msg)
|
|
239
270
|
when "goals_updated"
|
|
240
271
|
handle_goals_updated(msg)
|
|
272
|
+
when "children_updated"
|
|
273
|
+
handle_children_updated(msg)
|
|
241
274
|
when "sessions_list"
|
|
242
275
|
@sessions_list = msg["sessions"]
|
|
243
276
|
when "user_message_recalled"
|
|
@@ -246,13 +279,15 @@ module TUI
|
|
|
246
279
|
@authentication_required = true
|
|
247
280
|
when "token_saved"
|
|
248
281
|
@authentication_required = false
|
|
249
|
-
@token_save_result = {success: true}
|
|
282
|
+
@token_save_result = {success: true, warning: msg["warning"]}.compact
|
|
250
283
|
when "token_error"
|
|
251
284
|
@token_save_result = {success: false, message: msg["message"]}
|
|
252
285
|
when "error"
|
|
253
|
-
|
|
286
|
+
@flash.error(msg["message"]) if msg["message"]
|
|
254
287
|
else
|
|
255
288
|
case type
|
|
289
|
+
when "bounce_back"
|
|
290
|
+
handle_bounce_back(msg)
|
|
256
291
|
when "connection"
|
|
257
292
|
handle_connection_status(msg)
|
|
258
293
|
when "user_message"
|
|
@@ -288,6 +323,16 @@ module TUI
|
|
|
288
323
|
@message_store.remove_by_ids(evicted_ids)
|
|
289
324
|
end
|
|
290
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
|
+
|
|
291
336
|
# Reacts to connection lifecycle changes from the WebSocket client.
|
|
292
337
|
# Clears stale state when subscription begins so the store is empty
|
|
293
338
|
# before history arrives. Action Cable sends confirm_subscription
|
|
@@ -304,6 +349,25 @@ module TUI
|
|
|
304
349
|
end
|
|
305
350
|
end
|
|
306
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
|
+
|
|
307
371
|
def handle_session_changed(msg)
|
|
308
372
|
new_id = msg["session_id"]
|
|
309
373
|
@cable_client.update_session_id(new_id)
|
|
@@ -311,7 +375,7 @@ module TUI
|
|
|
311
375
|
@view_mode = msg["view_mode"] if msg["view_mode"]
|
|
312
376
|
@session_info = {id: new_id, name: msg["name"], message_count: msg["message_count"] || 0,
|
|
313
377
|
active_skills: msg["active_skills"] || [], active_workflow: msg["active_workflow"],
|
|
314
|
-
goals: msg["goals"] || []}
|
|
378
|
+
goals: msg["goals"] || [], children: msg["children"] || []}
|
|
315
379
|
@parent_session_id = msg["parent_session_id"]
|
|
316
380
|
@input_buffer.clear
|
|
317
381
|
@loading = false
|
|
@@ -354,6 +418,14 @@ module TUI
|
|
|
354
418
|
@session_info[:goals] = msg["goals"] || []
|
|
355
419
|
end
|
|
356
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
|
+
|
|
357
429
|
# Handles server broadcast of view mode change. Clears the message store
|
|
358
430
|
# in preparation for the re-decorated viewport events that follow.
|
|
359
431
|
def handle_view_mode_changed(msg)
|
|
@@ -367,45 +439,80 @@ module TUI
|
|
|
367
439
|
@auto_scroll = true
|
|
368
440
|
end
|
|
369
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.
|
|
370
451
|
def render_messages(frame, area, tui)
|
|
371
|
-
|
|
452
|
+
inner_width = [area.width - 2, 1].max
|
|
453
|
+
@visible_height = [area.height - 2, 0].max
|
|
454
|
+
entries = messages
|
|
455
|
+
version = @message_store.version
|
|
372
456
|
|
|
373
|
-
if
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
])
|
|
457
|
+
if entries.empty?
|
|
458
|
+
render_empty_or_loading(frame, area, tui)
|
|
459
|
+
return
|
|
377
460
|
end
|
|
378
461
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
|
383
474
|
end
|
|
384
475
|
|
|
385
|
-
|
|
386
|
-
|
|
476
|
+
# Phase 3: Find approximate first visible entry
|
|
477
|
+
first_vis, = @height_map.visible_range(@scroll_offset, @visible_height)
|
|
387
478
|
|
|
388
|
-
|
|
389
|
-
|
|
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
|
+
}
|
|
483
|
+
|
|
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)
|
|
390
500
|
|
|
391
|
-
@max_scroll = [
|
|
501
|
+
@max_scroll = [corrected_total - @visible_height, 0].max
|
|
392
502
|
@scroll_offset = @max_scroll if @auto_scroll
|
|
393
503
|
@scroll_offset = @scroll_offset.clamp(0, @max_scroll)
|
|
394
504
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if @chat_focused
|
|
402
|
-
chat_block[:titles] = [
|
|
403
|
-
{content: "\u2191\u2193 scroll Esc return", position: :bottom, alignment: :center}
|
|
404
|
-
]
|
|
405
|
-
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)
|
|
406
511
|
|
|
407
|
-
widget =
|
|
408
|
-
|
|
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) }
|
|
409
516
|
|
|
410
517
|
return unless @max_scroll > 0
|
|
411
518
|
|
|
@@ -420,17 +527,206 @@ module TUI
|
|
|
420
527
|
frame.render_widget(scrollbar, area)
|
|
421
528
|
end
|
|
422
529
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
+
])]
|
|
433
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
|
+
]
|
|
728
|
+
end
|
|
729
|
+
config
|
|
434
730
|
end
|
|
435
731
|
|
|
436
732
|
# Renders a tool activity counter (e.g. "🔧 Tools: 2/2 ✓").
|
|
@@ -450,8 +746,10 @@ module TUI
|
|
|
450
746
|
]
|
|
451
747
|
end
|
|
452
748
|
|
|
453
|
-
# Renders structured event data from the server.
|
|
454
|
-
#
|
|
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.
|
|
455
753
|
# @param tui [RatatuiRuby] TUI rendering API
|
|
456
754
|
# @param entry [Hash] entry shaped `{type: :rendered, data: Hash}`
|
|
457
755
|
# @return [Array<RatatuiRuby::Widgets::Line>] rendered lines + blank separator
|
|
@@ -462,12 +760,8 @@ module TUI
|
|
|
462
760
|
lines = case role
|
|
463
761
|
when "user", "assistant"
|
|
464
762
|
render_conversation_entry(tui, data, role)
|
|
465
|
-
when "tool_call"
|
|
466
|
-
|
|
467
|
-
when "tool_response"
|
|
468
|
-
render_tool_response_entry(tui, data)
|
|
469
|
-
when "think"
|
|
470
|
-
render_think_entry(tui, data)
|
|
763
|
+
when "tool_call", "tool_response", "think"
|
|
764
|
+
Decorators::BaseDecorator.for(data).render(tui)
|
|
471
765
|
when "system"
|
|
472
766
|
render_system_entry(tui, data)
|
|
473
767
|
when "system_prompt"
|
|
@@ -508,42 +802,6 @@ module TUI
|
|
|
508
802
|
lines
|
|
509
803
|
end
|
|
510
804
|
|
|
511
|
-
# Renders a tool invocation with tool name, optional tool_use_id, and indented input.
|
|
512
|
-
# @param tui [RatatuiRuby] TUI rendering API
|
|
513
|
-
# @param data [Hash] structured data with "tool", "input", and optional "tool_use_id"
|
|
514
|
-
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
515
|
-
def render_tool_call_entry(tui, data)
|
|
516
|
-
style = tui.style(fg: "white")
|
|
517
|
-
header = "#{TOOL_ICON} #{data["tool"]}"
|
|
518
|
-
header += " [#{data["tool_use_id"]}]" if data["tool_use_id"]
|
|
519
|
-
|
|
520
|
-
lines = [tui.line(spans: [tui.span(content: header, style: style)])]
|
|
521
|
-
data["input"].to_s.split("\n").each do |line|
|
|
522
|
-
lines << tui.line(spans: [tui.span(content: " #{line}", style: style)])
|
|
523
|
-
end
|
|
524
|
-
lines
|
|
525
|
-
end
|
|
526
|
-
|
|
527
|
-
# Renders tool output with success/failure indicator, optional tool_use_id and token count.
|
|
528
|
-
# @param tui [RatatuiRuby] TUI rendering API
|
|
529
|
-
# @param data [Hash] structured data with "content", "success", and optional
|
|
530
|
-
# "tool_use_id", "tokens", "estimated"
|
|
531
|
-
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
532
|
-
def render_tool_response_entry(tui, data)
|
|
533
|
-
indicator = (data["success"] == false) ? ERROR_ICON : CHECKMARK
|
|
534
|
-
meta_parts = []
|
|
535
|
-
meta_parts << "[#{data["tool_use_id"]}]" if data["tool_use_id"]
|
|
536
|
-
meta_parts << indicator
|
|
537
|
-
meta_parts << format_token_label(data["tokens"], data["estimated"]) if data["tokens"]
|
|
538
|
-
prefix = " #{RETURN_ARROW} #{meta_parts.join(" ")} "
|
|
539
|
-
|
|
540
|
-
content_lines = data["content"].to_s.split("\n")
|
|
541
|
-
style = tui.style(fg: "white")
|
|
542
|
-
lines = [tui.line(spans: [tui.span(content: "#{prefix}#{content_lines.first}", style: style)])]
|
|
543
|
-
content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
|
|
544
|
-
lines
|
|
545
|
-
end
|
|
546
|
-
|
|
547
805
|
# Renders a system message with optional timestamp prefix.
|
|
548
806
|
# @param tui [RatatuiRuby] TUI rendering API
|
|
549
807
|
# @param data [Hash] structured data with "content" and optional "timestamp"
|
|
@@ -576,47 +834,6 @@ module TUI
|
|
|
576
834
|
lines
|
|
577
835
|
end
|
|
578
836
|
|
|
579
|
-
# Renders a think event — the agent's inner reasoning between tool calls.
|
|
580
|
-
# "aloud" thoughts use yellow (narration for the user), "inner" thoughts
|
|
581
|
-
# use dark_gray (visible only in verbose/debug, dimmed to signal internality).
|
|
582
|
-
# @param tui [RatatuiRuby] TUI rendering API
|
|
583
|
-
# @param data [Hash] structured data with "content", "visibility", optional "timestamp", "tool_use_id"
|
|
584
|
-
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
585
|
-
def render_think_entry(tui, data)
|
|
586
|
-
aloud = data["visibility"] == "aloud"
|
|
587
|
-
color = aloud ? "yellow" : "dark_gray"
|
|
588
|
-
style = tui.style(fg: color)
|
|
589
|
-
|
|
590
|
-
meta = []
|
|
591
|
-
meta << "[#{format_ns_timestamp(data["timestamp"])}]" if data["timestamp"]
|
|
592
|
-
header = meta.empty? ? THOUGHT_BUBBLE : "#{meta.join(" ")} #{THOUGHT_BUBBLE}"
|
|
593
|
-
|
|
594
|
-
content_lines = data["content"].to_s.split("\n", -1)
|
|
595
|
-
lines = [tui.line(spans: [tui.span(content: "#{header} #{content_lines.first}", style: style)])]
|
|
596
|
-
content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
|
|
597
|
-
lines
|
|
598
|
-
end
|
|
599
|
-
|
|
600
|
-
# Formats a token count for display, with tilde prefix for estimates.
|
|
601
|
-
# @param tokens [Integer, nil] token count
|
|
602
|
-
# @param estimated [Boolean] whether the count is an estimate
|
|
603
|
-
# @return [String] formatted label, e.g. "[42 tok]" or "[~28 tok]"
|
|
604
|
-
def format_token_label(tokens, estimated)
|
|
605
|
-
return "" unless tokens
|
|
606
|
-
|
|
607
|
-
label = estimated ? "~#{tokens}" : tokens.to_s
|
|
608
|
-
"[#{label} tok]"
|
|
609
|
-
end
|
|
610
|
-
|
|
611
|
-
# Converts nanosecond-precision timestamp to human-readable HH:MM:SS.
|
|
612
|
-
# @param ns [Integer, nil] nanosecond timestamp
|
|
613
|
-
# @return [String] formatted time, or "--:--:--" when nil
|
|
614
|
-
def format_ns_timestamp(ns)
|
|
615
|
-
return "--:--:--" unless ns
|
|
616
|
-
|
|
617
|
-
Time.at(ns / 1_000_000_000.0).strftime("%H:%M:%S")
|
|
618
|
-
end
|
|
619
|
-
|
|
620
837
|
def build_chat_message_lines(tui, msg)
|
|
621
838
|
role = msg[:role]
|
|
622
839
|
role_style = (role == ROLE_USER) ? tui.style(fg: "green", modifiers: [:bold]) : tui.style(fg: "cyan", modifiers: [:bold])
|
|
@@ -663,9 +880,7 @@ module TUI
|
|
|
663
880
|
scroll: [input_scroll, 0],
|
|
664
881
|
block: tui.block(
|
|
665
882
|
title: title,
|
|
666
|
-
titles: disabled
|
|
667
|
-
{content: "Enter send", position: :bottom, alignment: :center}
|
|
668
|
-
],
|
|
883
|
+
titles: input_bottom_titles(disabled),
|
|
669
884
|
borders: [:all],
|
|
670
885
|
border_type: :rounded,
|
|
671
886
|
border_style: styles[:border]
|
|
@@ -704,6 +919,16 @@ module TUI
|
|
|
704
919
|
end
|
|
705
920
|
end
|
|
706
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
|
+
|
|
707
932
|
# Builds input text as pre-wrapped Line objects for the Paragraph widget.
|
|
708
933
|
# Lines are word-wrapped here so the Paragraph renders without its own
|
|
709
934
|
# wrapping, keeping cursor positioning in sync with the displayed text.
|