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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +51 -0
  4. data/README.md +63 -29
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +30 -11
  7. data/app/decorators/tool_call_decorator.rb +32 -3
  8. data/app/decorators/tool_decorator.rb +57 -0
  9. data/app/decorators/tool_response_decorator.rb +12 -4
  10. data/app/decorators/web_get_tool_decorator.rb +102 -0
  11. data/app/jobs/agent_request_job.rb +93 -23
  12. data/app/jobs/mneme_job.rb +51 -0
  13. data/app/jobs/passive_recall_job.rb +29 -0
  14. data/app/models/concerns/event/broadcasting.rb +4 -0
  15. data/app/models/event.rb +10 -0
  16. data/app/models/goal.rb +27 -0
  17. data/app/models/goal_pinned_event.rb +11 -0
  18. data/app/models/pinned_event.rb +41 -0
  19. data/app/models/session.rb +402 -6
  20. data/app/models/snapshot.rb +76 -0
  21. data/bin/jobs +5 -0
  22. data/config/initializers/event_subscribers.rb +12 -3
  23. data/config/initializers/fts5_schema_dump.rb +21 -0
  24. data/config/queue.yml +0 -1
  25. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  26. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  27. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  28. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  29. data/lib/agent_loop.rb +63 -20
  30. data/lib/analytical_brain/runner.rb +158 -65
  31. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  32. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  33. data/lib/anima/cli.rb +32 -9
  34. data/lib/anima/installer.rb +11 -24
  35. data/lib/anima/settings.rb +59 -0
  36. data/lib/anima/spinner.rb +75 -0
  37. data/lib/anima/version.rb +1 -1
  38. data/lib/environment_probe.rb +4 -4
  39. data/lib/events/bounce_back.rb +37 -0
  40. data/lib/events/subscribers/persister.rb +19 -0
  41. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  42. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  43. data/lib/events/tool_call.rb +5 -3
  44. data/lib/llm/client.rb +19 -9
  45. data/lib/mneme/compressed_viewport.rb +200 -0
  46. data/lib/mneme/l2_runner.rb +138 -0
  47. data/lib/mneme/passive_recall.rb +69 -0
  48. data/lib/mneme/runner.rb +254 -0
  49. data/lib/mneme/search.rb +150 -0
  50. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  51. data/lib/mneme/tools/everything_ok.rb +24 -0
  52. data/lib/mneme/tools/save_snapshot.rb +68 -0
  53. data/lib/mneme.rb +29 -0
  54. data/lib/providers/anthropic.rb +57 -13
  55. data/lib/shell_session.rb +194 -63
  56. data/lib/tasks/fts5.rake +6 -0
  57. data/lib/tools/base.rb +2 -1
  58. data/lib/tools/bash.rb +4 -2
  59. data/lib/tools/registry.rb +22 -3
  60. data/lib/tools/remember.rb +179 -0
  61. data/lib/tools/request_feature.rb +3 -1
  62. data/lib/tools/spawn_specialist.rb +21 -9
  63. data/lib/tools/spawn_subagent.rb +22 -11
  64. data/lib/tools/subagent_prompts.rb +20 -3
  65. data/lib/tools/web_get.rb +21 -10
  66. data/lib/tui/app.rb +222 -125
  67. data/lib/tui/decorators/base_decorator.rb +165 -0
  68. data/lib/tui/decorators/bash_decorator.rb +20 -0
  69. data/lib/tui/decorators/edit_decorator.rb +19 -0
  70. data/lib/tui/decorators/read_decorator.rb +24 -0
  71. data/lib/tui/decorators/think_decorator.rb +36 -0
  72. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  73. data/lib/tui/decorators/write_decorator.rb +19 -0
  74. data/lib/tui/flash.rb +139 -0
  75. data/lib/tui/formatting.rb +28 -0
  76. data/lib/tui/height_map.rb +93 -0
  77. data/lib/tui/message_store.rb +97 -8
  78. data/lib/tui/performance_logger.rb +90 -0
  79. data/lib/tui/screens/chat.rb +358 -133
  80. data/templates/config.toml +47 -0
  81. data/templates/soul.md +1 -1
  82. metadata +83 -4
  83. data/CHANGELOG.md +0 -80
  84. data/Gemfile +0 -17
  85. data/lib/tools/return_result.rb +0 -81
@@ -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
- THOUGHT_BUBBLE = "\u{1F4AD}"
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
- def initialize(cable_client:, message_store: nil)
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
- # Silently ignored — no user-facing error display yet
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
- lines = build_message_lines(tui)
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 @loading
374
- lines << tui.line(spans: [
375
- tui.span(content: "Thinking...", style: tui.style(fg: "yellow", modifiers: [:bold]))
376
- ])
457
+ if entries.empty?
458
+ render_empty_or_loading(frame, area, tui)
459
+ return
377
460
  end
378
461
 
379
- if lines.empty?
380
- lines << tui.line(spans: [
381
- tui.span(content: "Type a message to start chatting.", style: tui.style(fg: "dark_gray"))
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
- inner_width = [area.width - 2, 1].max
386
- @visible_height = [area.height - 2, 0].max
476
+ # Phase 3: Find approximate first visible entry
477
+ first_vis, = @height_map.visible_range(@scroll_offset, @visible_height)
387
478
 
388
- base_widget = tui.paragraph(text: lines, wrap: true, style: tui.style(fg: "white"))
389
- content_height = base_widget.line_count(inner_width)
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 = [content_height - @visible_height, 0].max
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
- chat_block = {
396
- title: "Chat",
397
- borders: [:all],
398
- border_type: :rounded,
399
- border_style: @chat_focused ? {fg: "yellow"} : {fg: "cyan"}
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 = base_widget.with(scroll: [@scroll_offset, 0], block: tui.block(**chat_block))
408
- frame.render_widget(widget, area)
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
- def build_message_lines(tui)
424
- messages.flat_map do |entry|
425
- case entry[:type]
426
- when :rendered
427
- build_rendered_lines(tui, entry)
428
- when :tool_counter
429
- build_tool_counter_lines(tui, entry)
430
- when :message
431
- build_chat_message_lines(tui, entry)
432
- end
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. Uses the role field
454
- # for styling no string parsing or regex needed.
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
- render_tool_call_entry(tui, data)
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.