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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +61 -0
  4. data/README.md +202 -116
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +44 -10
  7. data/app/decorators/agent_message_decorator.rb +6 -0
  8. data/app/decorators/event_decorator.rb +41 -7
  9. data/app/decorators/tool_call_decorator.rb +66 -5
  10. data/app/decorators/tool_decorator.rb +57 -0
  11. data/app/decorators/tool_response_decorator.rb +35 -5
  12. data/app/decorators/user_message_decorator.rb +6 -0
  13. data/app/decorators/web_get_tool_decorator.rb +102 -0
  14. data/app/jobs/agent_request_job.rb +95 -20
  15. data/app/jobs/mneme_job.rb +51 -0
  16. data/app/jobs/passive_recall_job.rb +29 -0
  17. data/app/models/concerns/event/broadcasting.rb +18 -0
  18. data/app/models/event.rb +10 -0
  19. data/app/models/goal.rb +27 -0
  20. data/app/models/goal_pinned_event.rb +11 -0
  21. data/app/models/pinned_event.rb +41 -0
  22. data/app/models/session.rb +335 -6
  23. data/app/models/snapshot.rb +76 -0
  24. data/config/initializers/event_subscribers.rb +14 -3
  25. data/config/initializers/fts5_schema_dump.rb +21 -0
  26. data/db/migrate/20260316094817_add_interrupt_requested_to_sessions.rb +5 -0
  27. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  28. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  29. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  30. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  31. data/lib/agent_loop.rb +67 -18
  32. data/lib/analytical_brain/runner.rb +159 -84
  33. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  34. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  35. data/lib/anima/cli.rb +34 -1
  36. data/lib/anima/config_migrator.rb +205 -0
  37. data/lib/anima/installer.rb +13 -130
  38. data/lib/anima/settings.rb +42 -1
  39. data/lib/anima/version.rb +1 -1
  40. data/lib/events/bounce_back.rb +37 -0
  41. data/lib/events/subscribers/agent_dispatcher.rb +29 -0
  42. data/lib/events/subscribers/persister.rb +17 -0
  43. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  44. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  45. data/lib/llm/client.rb +99 -14
  46. data/lib/mneme/compressed_viewport.rb +200 -0
  47. data/lib/mneme/l2_runner.rb +138 -0
  48. data/lib/mneme/passive_recall.rb +69 -0
  49. data/lib/mneme/runner.rb +254 -0
  50. data/lib/mneme/search.rb +150 -0
  51. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  52. data/lib/mneme/tools/everything_ok.rb +24 -0
  53. data/lib/mneme/tools/save_snapshot.rb +68 -0
  54. data/lib/mneme.rb +29 -0
  55. data/lib/providers/anthropic.rb +57 -13
  56. data/lib/shell_session.rb +188 -59
  57. data/lib/tasks/fts5.rake +6 -0
  58. data/lib/tools/remember.rb +179 -0
  59. data/lib/tools/spawn_specialist.rb +21 -9
  60. data/lib/tools/spawn_subagent.rb +22 -11
  61. data/lib/tools/subagent_prompts.rb +20 -3
  62. data/lib/tools/think.rb +57 -0
  63. data/lib/tools/web_get.rb +15 -6
  64. data/lib/tui/app.rb +230 -127
  65. data/lib/tui/cable_client.rb +8 -0
  66. data/lib/tui/decorators/base_decorator.rb +165 -0
  67. data/lib/tui/decorators/bash_decorator.rb +20 -0
  68. data/lib/tui/decorators/edit_decorator.rb +19 -0
  69. data/lib/tui/decorators/read_decorator.rb +24 -0
  70. data/lib/tui/decorators/think_decorator.rb +36 -0
  71. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  72. data/lib/tui/decorators/write_decorator.rb +19 -0
  73. data/lib/tui/flash.rb +139 -0
  74. data/lib/tui/formatting.rb +28 -0
  75. data/lib/tui/height_map.rb +93 -0
  76. data/lib/tui/message_store.rb +25 -1
  77. data/lib/tui/performance_logger.rb +90 -0
  78. data/lib/tui/screens/chat.rb +374 -109
  79. data/templates/config.toml +156 -0
  80. metadata +87 -4
  81. data/CHANGELOG.md +0 -79
  82. data/Gemfile +0 -17
  83. 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,8 +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"
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
- 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)
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
- # Silently ignored — no user-facing error display yet
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
- 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
355
456
 
356
- if @loading
357
- lines << tui.line(spans: [
358
- tui.span(content: "Thinking...", style: tui.style(fg: "yellow", modifiers: [:bold]))
359
- ])
457
+ if entries.empty?
458
+ render_empty_or_loading(frame, area, tui)
459
+ return
360
460
  end
361
461
 
362
- if lines.empty?
363
- lines << tui.line(spans: [
364
- tui.span(content: "Type a message to start chatting.", style: tui.style(fg: "dark_gray"))
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
- inner_width = [area.width - 2, 1].max
369
- @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)
370
478
 
371
- base_widget = tui.paragraph(text: lines, wrap: true, style: tui.style(fg: "white"))
372
- 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
+ }
373
483
 
374
- @max_scroll = [content_height - @visible_height, 0].max
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
- chat_block = {
379
- title: "Chat",
380
- borders: [:all],
381
- border_type: :rounded,
382
- border_style: @chat_focused ? {fg: "yellow"} : {fg: "cyan"}
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 = base_widget.with(scroll: [@scroll_offset, 0], block: tui.block(**chat_block))
391
- 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) }
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
- def build_message_lines(tui)
407
- messages.flat_map do |entry|
408
- case entry[:type]
409
- when :rendered
410
- build_rendered_lines(tui, entry)
411
- when :tool_counter
412
- build_tool_counter_lines(tui, entry)
413
- when :message
414
- build_chat_message_lines(tui, entry)
415
- 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
+ ])]
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. Uses the role field
437
- # 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.
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
- render_tool_call_entry(tui, data)
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.