anima-core 1.1.3 → 1.3.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +10 -1
  3. data/README.md +36 -11
  4. data/agents/codebase-analyzer.md +2 -2
  5. data/agents/codebase-pattern-finder.md +2 -2
  6. data/agents/documentation-researcher.md +2 -2
  7. data/agents/thoughts-analyzer.md +2 -2
  8. data/agents/web-search-researcher.md +3 -3
  9. data/app/channels/session_channel.rb +83 -64
  10. data/app/decorators/agent_message_decorator.rb +2 -2
  11. data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
  12. data/app/decorators/system_message_decorator.rb +2 -2
  13. data/app/decorators/tool_call_decorator.rb +6 -6
  14. data/app/decorators/tool_decorator.rb +4 -4
  15. data/app/decorators/tool_response_decorator.rb +2 -2
  16. data/app/decorators/user_message_decorator.rb +5 -19
  17. data/app/decorators/web_get_tool_decorator.rb +41 -9
  18. data/app/jobs/agent_request_job.rb +33 -24
  19. data/app/jobs/count_message_tokens_job.rb +39 -0
  20. data/app/jobs/passive_recall_job.rb +4 -4
  21. data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
  22. data/app/models/goal.rb +17 -4
  23. data/app/models/goal_pinned_message.rb +11 -0
  24. data/app/models/message.rb +127 -0
  25. data/app/models/pending_message.rb +43 -0
  26. data/app/models/pinned_message.rb +41 -0
  27. data/app/models/secret.rb +72 -0
  28. data/app/models/session.rb +385 -226
  29. data/app/models/snapshot.rb +25 -25
  30. data/config/environments/test.rb +5 -0
  31. data/config/initializers/time_nanoseconds.rb +11 -0
  32. data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
  33. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  34. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  35. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  36. data/lib/agent_loop.rb +14 -41
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +40 -37
  39. data/lib/analytical_brain/tools/activate_skill.rb +5 -9
  40. data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
  42. data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
  43. data/lib/analytical_brain/tools/finish_goal.rb +5 -8
  44. data/lib/analytical_brain/tools/read_workflow.rb +5 -9
  45. data/lib/analytical_brain/tools/rename_session.rb +3 -10
  46. data/lib/analytical_brain/tools/set_goal.rb +3 -7
  47. data/lib/analytical_brain/tools/update_goal.rb +3 -7
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/installer.rb +7 -1
  51. data/lib/anima/settings.rb +46 -6
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +1 -1
  54. data/lib/credential_store.rb +17 -66
  55. data/lib/events/base.rb +1 -1
  56. data/lib/events/bounce_back.rb +7 -7
  57. data/lib/events/subscribers/persister.rb +15 -22
  58. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  59. data/lib/events/subscribers/transient_broadcaster.rb +2 -2
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +54 -20
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +57 -57
  65. data/lib/mneme/l2_runner.rb +4 -4
  66. data/lib/mneme/passive_recall.rb +2 -2
  67. data/lib/mneme/runner.rb +57 -75
  68. data/lib/mneme/search.rb +38 -38
  69. data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
  70. data/lib/mneme/tools/everything_ok.rb +1 -3
  71. data/lib/mneme/tools/save_snapshot.rb +12 -16
  72. data/lib/shell_session.rb +54 -16
  73. data/lib/tools/base.rb +23 -0
  74. data/lib/tools/bash.rb +60 -16
  75. data/lib/tools/edit.rb +6 -8
  76. data/lib/tools/mark_goal_completed.rb +86 -0
  77. data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
  78. data/lib/tools/read.rb +6 -5
  79. data/lib/tools/recall.rb +98 -0
  80. data/lib/tools/registry.rb +37 -8
  81. data/lib/tools/remember.rb +46 -55
  82. data/lib/tools/response_truncator.rb +70 -0
  83. data/lib/tools/spawn_specialist.rb +15 -25
  84. data/lib/tools/spawn_subagent.rb +14 -22
  85. data/lib/tools/subagent_prompts.rb +42 -6
  86. data/lib/tools/think.rb +26 -10
  87. data/lib/tools/web_get.rb +23 -4
  88. data/lib/tools/write.rb +4 -4
  89. data/lib/tui/app.rb +178 -13
  90. data/lib/tui/braille_spinner.rb +152 -0
  91. data/lib/tui/cable_client.rb +4 -4
  92. data/lib/tui/decorators/base_decorator.rb +17 -8
  93. data/lib/tui/decorators/bash_decorator.rb +2 -2
  94. data/lib/tui/decorators/edit_decorator.rb +5 -4
  95. data/lib/tui/decorators/read_decorator.rb +4 -8
  96. data/lib/tui/decorators/think_decorator.rb +3 -5
  97. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  98. data/lib/tui/decorators/write_decorator.rb +5 -4
  99. data/lib/tui/flash.rb +1 -1
  100. data/lib/tui/formatting.rb +22 -0
  101. data/lib/tui/message_store.rb +103 -59
  102. data/lib/tui/screens/chat.rb +293 -78
  103. data/skills/activerecord/SKILL.md +1 -1
  104. data/skills/dragonruby/SKILL.md +1 -1
  105. data/skills/draper-decorators/SKILL.md +1 -1
  106. data/skills/gh-issue.md +1 -1
  107. data/skills/mcp-server/SKILL.md +1 -1
  108. data/skills/ratatui-ruby/SKILL.md +1 -1
  109. data/skills/rspec/SKILL.md +1 -1
  110. data/templates/config.toml +42 -5
  111. data/templates/soul.md +7 -19
  112. data/workflows/create_handoff.md +1 -1
  113. data/workflows/create_note.md +1 -1
  114. data/workflows/create_plan.md +1 -1
  115. data/workflows/implement_plan.md +1 -1
  116. data/workflows/iterate_plan.md +1 -1
  117. data/workflows/research_codebase.md +1 -1
  118. data/workflows/resume_handoff.md +1 -1
  119. data/workflows/review_pr.md +78 -16
  120. data/workflows/thoughts_init.md +1 -1
  121. data/workflows/validate_plan.md +1 -1
  122. metadata +20 -9
  123. data/app/jobs/count_event_tokens_job.rb +0 -39
  124. data/app/models/event.rb +0 -129
  125. data/app/models/goal_pinned_event.rb +0 -11
  126. data/app/models/pinned_event.rb +0 -41
  127. data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
@@ -12,6 +12,8 @@ require_relative "../decorators/write_decorator"
12
12
  require_relative "../decorators/web_get_decorator"
13
13
  require_relative "../decorators/think_decorator"
14
14
  require_relative "../formatting"
15
+ require_relative "../braille_spinner"
16
+ require "toon"
15
17
 
16
18
  module TUI
17
19
  module Screens
@@ -23,13 +25,11 @@ module TUI
23
25
 
24
26
  ROLE_USER = "user"
25
27
  ROLE_ASSISTANT = "assistant"
26
- ROLE_LABELS = {ROLE_USER => "You", ROLE_ASSISTANT => "Anima"}.freeze
27
28
 
28
29
  SCROLL_STEP = 1
29
30
  MOUSE_SCROLL_STEP = 2
30
31
 
31
32
  TOOL_ICON = "\u{1F527}"
32
- CLOCK_ICON = "\u{1F552}"
33
33
  CHECKMARK = "\u2713"
34
34
 
35
35
  # Viewport virtualization tuning
@@ -37,15 +37,26 @@ module TUI
37
37
  VIEWPORT_OVERFLOW_MULTIPLIER = 2 # build this many viewports worth of lines
38
38
  VIEWPORT_BOTTOM_THRESHOLD = 10 # entries from end before we include all trailing
39
39
 
40
- ROLE_COLORS = {"user" => "green", "assistant" => "cyan"}.freeze
40
+ # Background-highlighted styles for conversation roles.
41
+ # Dark tinted backgrounds make user/assistant messages easy to scan.
42
+ # 22 = dark green (#005f00), 17 = dark navy (#00005f) in 256-color.
43
+ ROLE_STYLES = {
44
+ "user" => {fg: "white", bg: 22, modifiers: [:bold]},
45
+ "assistant" => {fg: "white", bg: 17, modifiers: [:bold]}
46
+ }.freeze
41
47
 
42
48
  # Intentionally duplicated from Session::VIEW_MODES to keep the TUI
43
49
  # independent of Rails. Must stay in sync when adding new modes.
44
50
  VIEW_MODES = %w[basic verbose debug].freeze
45
51
 
52
+ # @!attribute [r] session_loading
53
+ # Whether the TUI is waiting for session history to arrive from the server.
54
+ # Independent of cable_client.status (transport) and session_state (LLM processing).
55
+ # Set on subscribing/session_changed/view_mode_changed; cleared on first content
56
+ # message or connection failure. Drives the "Loading…" input title and yellow border.
46
57
  attr_reader :message_store, :scroll_offset, :session_info, :view_mode, :sessions_list,
47
58
  :authentication_required, :token_save_result, :parent_session_id,
48
- :chat_focused
59
+ :chat_focused, :session_state, :spinner, :session_loading
49
60
  attr_accessor :hud_hint
50
61
 
51
62
  # @param cable_client [TUI::CableClient] WebSocket client connected to the brain
@@ -57,14 +68,16 @@ module TUI
57
68
  @perf_logger = perf_logger || PerformanceLogger.new(enabled: false)
58
69
  @input_buffer = InputBuffer.new
59
70
  @flash = Flash.new
60
- @loading = false
71
+ @session_state = "idle"
72
+ @session_loading = false
73
+ @spinner = BrailleSpinner.new
61
74
  @scroll_offset = 0
62
75
  @auto_scroll = true
63
76
  @visible_height = 0
64
77
  @max_scroll = 0
65
78
  @input_scroll_offset = 0
66
79
  @view_mode = "basic"
67
- @session_info = {id: cable_client.session_id || 0, message_count: 0, active_skills: [], active_workflow: nil, goals: [], children: []}
80
+ @session_info = {id: cable_client.session_id || 0, agent_name: "Anima", message_count: 0, active_skills: [], active_workflow: nil, goals: [], children: []}
68
81
  @sessions_list = nil
69
82
  @parent_session_id = nil
70
83
  @authentication_required = false
@@ -230,8 +243,12 @@ module TUI
230
243
  def finalize
231
244
  end
232
245
 
246
+ # Whether the session is actively processing (any state other than idle).
247
+ # Used by the App's HUD and scroll calculations.
248
+ #
249
+ # @return [Boolean]
233
250
  def loading?
234
- @loading
251
+ @session_state != "idle"
235
252
  end
236
253
 
237
254
  # Switches focus to the chat pane for keyboard scrolling.
@@ -246,15 +263,49 @@ module TUI
246
263
  @chat_focused = false
247
264
  end
248
265
 
266
+ # Short label describing the current session state for HUD display.
267
+ #
268
+ # @return [String]
269
+ def spinner_label
270
+ case @session_state
271
+ when "llm_generating" then "Thinking..."
272
+ when "tool_executing" then "Executing..."
273
+ when "interrupting" then "Stopping..."
274
+ else "Working..."
275
+ end
276
+ end
277
+
278
+ # Color name for the spinner and HUD label based on session state.
279
+ # Follows the two-channel design: color = status (green = working,
280
+ # red = stopping). The braille animation pattern communicates type.
281
+ #
282
+ # @return [String]
283
+ def spinner_color
284
+ case @session_state
285
+ when "llm_generating", "tool_executing" then "green"
286
+ when "interrupting" then "red"
287
+ else "dark_gray"
288
+ end
289
+ end
290
+
249
291
  private
250
292
 
251
- # Drains the WebSocket message queue and feeds events to the message store
293
+ # Drains the WebSocket message queue and feeds events to the message store.
294
+ #
295
+ # Called once per render frame. All operations here MUST be non-blocking:
296
+ # drain_messages uses Thread::Queue#pop(true), and every handler performs
297
+ # only in-memory state updates. Network I/O (WebSocket sends, reconnection)
298
+ # happens in CableClient's background thread via Thread::Queue.
252
299
  def process_incoming_messages
253
300
  @cable_client.drain_messages.each do |msg|
254
301
  action = msg["action"]
255
302
  type = msg["type"]
256
303
 
257
304
  case action
305
+ when "session_state"
306
+ handle_session_state(msg)
307
+ when "child_state"
308
+ handle_child_state(msg)
258
309
  when "session_changed"
259
310
  handle_session_changed(msg)
260
311
  when "view_mode_changed"
@@ -273,8 +324,10 @@ module TUI
273
324
  handle_children_updated(msg)
274
325
  when "sessions_list"
275
326
  @sessions_list = msg["sessions"]
276
- when "user_message_recalled"
277
- @message_store.remove_by_id(msg["event_id"]) if msg["event_id"]
327
+ when "pending_message_created"
328
+ @message_store.add_pending(msg["pending_message_id"], msg["content"]) if msg["pending_message_id"]
329
+ when "pending_message_removed"
330
+ @message_store.remove_pending(msg["pending_message_id"]) if msg["pending_message_id"]
278
331
  when "authentication_required"
279
332
  @authentication_required = true
280
333
  when "token_saved"
@@ -282,6 +335,8 @@ module TUI
282
335
  @token_save_result = {success: true, warning: msg["warning"]}.compact
283
336
  when "token_error"
284
337
  @token_save_result = {success: false, message: msg["message"]}
338
+ when "interrupt_acknowledged"
339
+ @flash.info("Interrupting...")
285
340
  when "error"
286
341
  @flash.error(msg["message"]) if msg["message"]
287
342
  else
@@ -291,18 +346,19 @@ module TUI
291
346
  when "connection"
292
347
  handle_connection_status(msg)
293
348
  when "user_message"
349
+ @session_loading = false
294
350
  @message_store.process_event(msg)
295
351
  unless action == "update"
296
352
  @session_info[:message_count] += 1
297
- @loading = true
298
353
  end
299
354
  when "agent_message"
355
+ @session_loading = false
300
356
  @message_store.process_event(msg)
301
357
  unless action == "update"
302
358
  @session_info[:message_count] += 1
303
- @loading = false
304
359
  end
305
360
  else # tool_call, tool_response, and other event types
361
+ @session_loading = false
306
362
  @message_store.process_event(msg)
307
363
  end
308
364
  end
@@ -311,13 +367,13 @@ module TUI
311
367
  end
312
368
  end
313
369
 
314
- # Removes messages that left the LLM's context window. Event broadcasts
315
- # include `evicted_event_ids` when old events are pushed out of the
370
+ # Removes messages that left the LLM's context window. Broadcasts
371
+ # include `evicted_message_ids` when old messages are pushed out of the
316
372
  # viewport by new ones.
317
373
  #
318
374
  # @param msg [Hash] incoming WebSocket message
319
375
  def handle_viewport_evictions(msg)
320
- evicted_ids = msg["evicted_event_ids"]
376
+ evicted_ids = msg["evicted_message_ids"]
321
377
  return unless evicted_ids.is_a?(Array) && evicted_ids.any?
322
378
 
323
379
  @message_store.remove_by_ids(evicted_ids)
@@ -342,23 +398,25 @@ module TUI
342
398
  case msg["status"]
343
399
  when "subscribing"
344
400
  @message_store.clear
345
- @loading = false
401
+ @session_loading = true
402
+ update_session_state("idle")
346
403
  @session_info[:message_count] = 0
347
404
  when "disconnected", "failed"
348
- @loading = false
405
+ @session_loading = false
406
+ update_session_state("idle")
349
407
  end
350
408
  end
351
409
 
352
- # Handles a Bounce Back event: the server rolled back the user event
410
+ # Handles a Bounce Back: the server rolled back the user message
353
411
  # because LLM delivery failed. Removes the phantom message from the
354
412
  # chat, restores the text to the input field, and shows a flash.
355
413
  def handle_bounce_back(msg)
356
- event_id = msg["event_id"]
414
+ message_id = msg["message_id"]
357
415
  content = msg["content"]
358
416
  error = msg["error"]
359
417
 
360
- @message_store.remove_by_id(event_id) if event_id
361
- @loading = false
418
+ @message_store.remove_by_id(message_id) if message_id
419
+ update_session_state("idle")
362
420
 
363
421
  if content
364
422
  @input_buffer.clear
@@ -372,13 +430,17 @@ module TUI
372
430
  new_id = msg["session_id"]
373
431
  @cable_client.update_session_id(new_id)
374
432
  @message_store.clear
433
+ # Only enter loading state when the session has messages to replay.
434
+ # Empty sessions send no history events, so the flag would never clear.
435
+ @session_loading = (msg["message_count"] || 0) > 0
375
436
  @view_mode = msg["view_mode"] if msg["view_mode"]
376
- @session_info = {id: new_id, name: msg["name"], message_count: msg["message_count"] || 0,
437
+ @session_info = {id: new_id, name: msg["name"], agent_name: msg["agent_name"] || "Anima",
438
+ message_count: msg["message_count"] || 0,
377
439
  active_skills: msg["active_skills"] || [], active_workflow: msg["active_workflow"],
378
440
  goals: msg["goals"] || [], children: msg["children"] || []}
379
441
  @parent_session_id = msg["parent_session_id"]
380
442
  @input_buffer.clear
381
- @loading = false
443
+ update_session_state("idle")
382
444
  @scroll_offset = 0
383
445
  @auto_scroll = true
384
446
  @input_scroll_offset = 0
@@ -426,6 +488,58 @@ module TUI
426
488
  @session_info[:children] = msg["children"] || []
427
489
  end
428
490
 
491
+ # Handles explicit session state transitions from the server.
492
+ # Drives the braille spinner animation. Only processes broadcasts
493
+ # matching the current session.
494
+ #
495
+ # @param msg [Hash] ActionCable payload with "session_id" and "state" keys
496
+ # @return [void]
497
+ def handle_session_state(msg)
498
+ return unless msg["session_id"] == @session_info[:id]
499
+
500
+ update_session_state(msg["state"])
501
+ end
502
+
503
+ # Handles a child session's state change broadcast from the
504
+ # parent stream. Merges the state into the children list so
505
+ # HUD icons update without a full children_updated query.
506
+ #
507
+ # @param msg [Hash] ActionCable payload with "child_id" and "state" keys
508
+ # @return [void]
509
+ def handle_child_state(msg)
510
+ child_id = msg["child_id"]
511
+ return unless child_id
512
+
513
+ child = @session_info[:children]&.find { |c| c["id"] == child_id }
514
+ child["session_state"] = msg["state"] if child
515
+ end
516
+
517
+ # Updates the session state and synchronizes the spinner.
518
+ #
519
+ # @param state [String] one of "idle", "llm_generating",
520
+ # "tool_executing", "interrupting"
521
+ def update_session_state(state)
522
+ @session_state = state
523
+ @spinner.state = state
524
+ end
525
+
526
+ # Builds the animated spinner line for the current session state.
527
+ # The braille character communicates state through its animation
528
+ # pattern; a short label follows for clarity.
529
+ #
530
+ # @param tui [RatatuiRuby] TUI rendering API
531
+ # @return [RatatuiRuby::Widgets::Line]
532
+ def spinner_line(tui)
533
+ char = @spinner.tick || "\u2800"
534
+ label = spinner_label
535
+ color = spinner_color
536
+
537
+ tui.line(spans: [
538
+ tui.span(content: "#{char} ", style: tui.style(fg: color, modifiers: [:bold])),
539
+ tui.span(content: label, style: tui.style(fg: color))
540
+ ])
541
+ end
542
+
429
543
  # Handles server broadcast of view mode change. Clears the message store
430
544
  # in preparation for the re-decorated viewport events that follow.
431
545
  def handle_view_mode_changed(msg)
@@ -434,7 +548,10 @@ module TUI
434
548
 
435
549
  @view_mode = new_mode
436
550
  @message_store.clear
437
- @loading = false
551
+ # Only enter loading state when there are messages to re-decorate.
552
+ # Empty sessions send no viewport events after a mode change.
553
+ @session_loading = (@session_info[:message_count] || 0) > 0
554
+ update_session_state("idle")
438
555
  @scroll_offset = 0
439
556
  @auto_scroll = true
440
557
  end
@@ -468,9 +585,7 @@ module TUI
468
585
  # from actual viewport height. Clamping here would cap scroll_offset
469
586
  # to the (under)estimated total, making the bottom unreachable.
470
587
  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
588
+ @scroll_offset = [@height_map.total_height - @visible_height, 0].max
474
589
  end
475
590
 
476
591
  # Phase 3: Find approximate first visible entry
@@ -495,8 +610,7 @@ module TUI
495
610
  vp_last = @viewport[:last]
496
611
  est_before = @height_map.cumulative_height(vp_first)
497
612
  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)
613
+ corrected_total = est_before + wrapped_height + est_after
500
614
 
501
615
  @max_scroll = [corrected_total - @visible_height, 0].max
502
616
  @scroll_offset = @max_scroll if @auto_scroll
@@ -514,20 +628,45 @@ module TUI
514
628
  }
515
629
  @perf_logger.measure(:render_widget) { frame.render_widget(widget, area) }
516
630
 
517
- return unless @max_scroll > 0
631
+ if @max_scroll > 0
632
+ scrollbar = tui.scrollbar(
633
+ content_length: @max_scroll,
634
+ position: @scroll_offset,
635
+ orientation: :vertical_right,
636
+ thumb_style: {fg: "cyan"},
637
+ track_symbol: "\u2502",
638
+ track_style: {fg: "dark_gray"}
639
+ )
640
+ frame.render_widget(scrollbar, area)
641
+ end
642
+
643
+ # Spinner overlay: rendered on top of the last line inside the
644
+ # chat border, rebuilt every frame. Independent of the viewport
645
+ # cache — the braille animation advances without cache invalidation.
646
+ render_spinner_overlay(frame, area, tui) if loading?
647
+ end
648
+
649
+ # Renders the spinner as a 1-line overlay at the bottom of the chat
650
+ # pane, inside the border. Painted on top of whatever the messages
651
+ # paragraph rendered there — same pattern as the token setup popup.
652
+ def render_spinner_overlay(frame, area, tui)
653
+ inner = tui.block(**chat_block_config).inner(area)
654
+ return if inner.height < 1
518
655
 
519
- scrollbar = tui.scrollbar(
520
- content_length: @max_scroll,
521
- position: @scroll_offset,
522
- orientation: :vertical_right,
523
- thumb_style: {fg: "cyan"},
524
- track_symbol: "\u2502",
525
- track_style: {fg: "dark_gray"}
656
+ spinner_rect = tui.rect(
657
+ x: inner.x,
658
+ y: inner.y + inner.height - 1,
659
+ width: inner.width,
660
+ height: 1
526
661
  )
527
- frame.render_widget(scrollbar, area)
662
+ frame.render_widget(tui.clear, spinner_rect)
663
+ widget = tui.paragraph(text: [spinner_line(tui)])
664
+ frame.render_widget(widget, spinner_rect)
528
665
  end
529
666
 
530
667
  # Renders the empty or loading state placeholder when no messages exist.
668
+ # Three states: active processing (spinner), session loading (loading
669
+ # indicator), and truly empty (start chatting prompt).
531
670
  # Resets scroll state since there is no scrollable content.
532
671
  #
533
672
  # @param frame [RatatuiRuby::Frame] current render frame
@@ -535,9 +674,11 @@ module TUI
535
674
  # @param tui [RatatuiRuby] TUI rendering API
536
675
  # @return [void]
537
676
  def render_empty_or_loading(frame, area, tui)
538
- lines = if @loading
677
+ lines = if loading?
678
+ [spinner_line(tui)]
679
+ elsif @session_loading
539
680
  [tui.line(spans: [
540
- tui.span(content: "Thinking...", style: tui.style(fg: "yellow", modifiers: [:bold]))
681
+ tui.span(content: "Loading session\u2026", style: tui.style(fg: "yellow"))
541
682
  ])]
542
683
  else
543
684
  [tui.line(spans: [
@@ -555,19 +696,18 @@ module TUI
555
696
  # Re-estimates entry heights when content or width changes.
556
697
  # Height estimation is O(n) string-length math — orders of
557
698
  # magnitude cheaper than building Line/Span objects. Skips
558
- # re-estimation when version, width, and loading state are unchanged.
699
+ # re-estimation when version and width are unchanged.
559
700
  #
560
701
  # @param entries [Array<Hash>] message store entries
561
702
  # @param width [Integer] available terminal width
562
703
  # @param version [Integer] message store version counter
563
704
  # @return [void]
564
705
  def update_height_map(entries, width, version)
565
- return if version == @height_map_version && width == @height_map_width && @loading == @height_map_loading
706
+ return if version == @height_map_version && width == @height_map_width
566
707
 
567
708
  @height_map.update(entries, width) { |entry, avail_width| estimate_entry_height(entry, avail_width) }
568
709
  @height_map_version = version
569
710
  @height_map_width = width
570
- @height_map_loading = @loading
571
711
  end
572
712
 
573
713
  # Returns cached viewport lines, rebuilding only when content
@@ -581,7 +721,7 @@ module TUI
581
721
  vp_first = vp[:first]
582
722
 
583
723
  # Cache hit: content unchanged and scroll target within the built range
584
- if version == vp[:version] && @loading == vp[:loading] &&
724
+ if version == vp[:version] &&
585
725
  vp_first && first_visible_est >= vp_first && first_visible_est <= vp[:last]
586
726
  return vp[:lines]
587
727
  end
@@ -611,19 +751,13 @@ module TUI
611
751
  break if pre_wrap_count >= target && entry_count - idx > VIEWPORT_BOTTOM_THRESHOLD
612
752
  end
613
753
 
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
754
  @perf_logger.info(
621
755
  "viewport MISS range=#{buf_first}..#{buf_last} " \
622
756
  "of=#{entry_count} lines=#{lines.size}"
623
757
  )
624
758
 
625
759
  @viewport = {
626
- version: version, loading: @loading, width: nil,
760
+ version: version, width: nil,
627
761
  first: buf_first, last: buf_last,
628
762
  lines: lines, wrapped_height: nil
629
763
  }
@@ -636,7 +770,7 @@ module TUI
636
770
  def cached_viewport_line_count(widget, width, version)
637
771
  vp = @viewport
638
772
  cached_height = vp[:wrapped_height]
639
- if cached_height && version == vp[:version] && @loading == vp[:loading] && width == vp[:width]
773
+ if cached_height && version == vp[:version] && width == vp[:width]
640
774
  return cached_height
641
775
  end
642
776
 
@@ -680,7 +814,7 @@ module TUI
680
814
  text = [data["content"], data["input"]].compact.map(&:to_s).reject(&:empty?).join("\n")
681
815
  lines = estimate_text_height(text, effective_width)
682
816
  lines += 1 # header/label line
683
- lines += 1 unless entry[:event_type] == "tool_call" # separator
817
+ lines += 1 unless entry[:message_type] == "tool_call" # separator
684
818
  lines
685
819
  when :message
686
820
  lines = estimate_text_height(entry[:content].to_s, effective_width)
@@ -772,32 +906,62 @@ module TUI
772
906
 
773
907
  # Tool calls and their responses are visually one unit — no separator
774
908
  # between them. Separator appears after the response completes the pair.
775
- lines << tui.line(spans: [tui.span(content: "")]) unless entry[:event_type] == "tool_call"
909
+ lines << tui.line(spans: [tui.span(content: "")]) unless entry[:message_type] == "tool_call"
776
910
  lines
777
911
  end
778
912
 
779
913
  # Renders a user or assistant message with optional timestamp and token count.
780
- # Pending messages are dimmed with a clock icon to indicate they haven't
781
- # been sent to the LLM yet.
914
+ # Pending messages are dimmed to indicate they haven't been sent to the
915
+ # LLM yet.
782
916
  # @param tui [RatatuiRuby] TUI rendering API
783
917
  # @param data [Hash] structured data with "role", "content", and optional
918
+ # Display label for a conversation role. Uses the agent name from
919
+ # Settings (delivered via session_changed) for the assistant role.
920
+ #
921
+ # @param role [String] "user" or "assistant"
922
+ # @return [String] display label
923
+ def role_label(role)
924
+ return "You" if role == ROLE_USER
925
+ return @session_info[:agent_name] || "Anima" if role == ROLE_ASSISTANT
926
+
927
+ role
928
+ end
929
+
784
930
  # "timestamp", "tokens", "estimated", "status"
785
931
  # @param role [String] "user" or "assistant"
786
932
  # @return [Array<RatatuiRuby::Widgets::Line>]
787
933
  def render_conversation_entry(tui, data, role)
788
934
  pending = data["status"] == "pending"
789
- color = pending ? "dark_gray" : ROLE_COLORS.fetch(role, "white")
790
- prefix = ROLE_LABELS.fetch(role, role)
791
- prefix = "#{CLOCK_ICON} #{prefix}" if pending
792
- style = tui.style(fg: color)
935
+ label = role_label(role)
793
936
 
794
- meta = []
795
- meta << "[#{format_ns_timestamp(data["timestamp"])}]" if data["timestamp"]
796
- meta << format_token_label(data["tokens"], data["estimated"]) if data["tokens"]
797
- header = meta.empty? ? "#{prefix}:" : "#{meta.join(" ")} #{prefix}:"
937
+ if pending
938
+ style = tui.style(fg: "gray")
939
+ else
940
+ role_cfg = ROLE_STYLES.fetch(role, {fg: "white"})
941
+ style = tui.style(**role_cfg)
942
+ end
798
943
 
944
+ tokens = data["tokens"]
799
945
  content_lines = data["content"].to_s.split("\n", -1)
800
- lines = [tui.line(spans: [tui.span(content: "#{header} #{content_lines.first}", style: style)])]
946
+ first_content = content_lines.first
947
+ ts = data["timestamp"]
948
+ ts_prefix = ts ? "[#{format_ns_timestamp(ts)}] " : ""
949
+
950
+ first_spans = if tokens && !pending
951
+ tok_style = {fg: token_count_color(tokens)}
952
+ role_bg = ROLE_STYLES.dig(role, :bg)
953
+ tok_style[:bg] = role_bg if role_bg
954
+ [
955
+ tui.span(content: ts_prefix, style: style),
956
+ tui.span(content: "#{format_token_label(tokens, data["estimated"])} ", style: tui.style(**tok_style)),
957
+ tui.span(content: "#{label}: #{first_content}", style: style)
958
+ ]
959
+ else
960
+ header = ts_prefix.empty? ? "#{label}:" : "#{ts_prefix}#{label}:"
961
+ [tui.span(content: "#{header} #{first_content}", style: style)]
962
+ end
963
+
964
+ lines = [tui.line(spans: first_spans)]
801
965
  content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: line, style: style)]) }
802
966
  lines
803
967
  end
@@ -817,28 +981,57 @@ module TUI
817
981
  lines
818
982
  end
819
983
 
820
- # Renders the assembled system prompt block in debug mode.
984
+ # Renders the assembled system prompt and tool schemas in debug mode.
985
+ # Tool schemas are converted to TOON format for readability.
821
986
  # @param tui [RatatuiRuby] TUI rendering API
822
- # @param data [Hash] structured data with "content", "tokens", "estimated"
987
+ # @param data [Hash] structured data with "content", "tokens", "estimated",
988
+ # and optionally "tools" (Array<Hash> of tool schemas)
823
989
  # @return [Array<RatatuiRuby::Widgets::Line>]
824
990
  def render_system_prompt_entry(tui, data)
825
- token_label = format_token_label(data["tokens"], data["estimated"])
826
- header = "[SYSTEM] (#{token_label})"
827
- style = tui.style(fg: "magenta")
991
+ tokens = data["tokens"]
828
992
  bold_style = tui.style(fg: "magenta", modifiers: [:bold])
993
+ style = tui.style(fg: "magenta")
994
+ tool_style = tui.style(fg: "cyan")
995
+
996
+ header_spans = [tui.span(content: "[SYSTEM] ", style: bold_style)]
997
+ if tokens
998
+ tok_label = format_token_label(tokens, data["estimated"])
999
+ header_spans << tui.span(content: "(#{tok_label})", style: tui.style(fg: token_count_color(tokens)))
1000
+ end
829
1001
 
830
- lines = [tui.line(spans: [tui.span(content: header, style: bold_style)])]
1002
+ lines = [tui.line(spans: header_spans)]
831
1003
  data["content"].to_s.split("\n").each do |line|
832
1004
  lines << tui.line(spans: [tui.span(content: " #{line}", style: style)])
833
1005
  end
1006
+
1007
+ if data["tools"].is_a?(Array) && data["tools"].any?
1008
+ lines << tui.line(spans: [tui.span(content: "", style: style)])
1009
+ lines << tui.line(spans: [tui.span(content: "\u00a0\u00a0## Tools (#{data["tools"].size})", style: bold_style)])
1010
+ tools_toon(data).split("\n").each do |line|
1011
+ lines << tui.line(spans: [tui.span(content: line, style: tool_style)])
1012
+ end
1013
+ end
1014
+
834
1015
  lines
835
1016
  end
836
1017
 
1018
+ # Converts tool schemas to TOON format for display. Caches the result
1019
+ # on the data hash so the conversion runs once per broadcast, not per
1020
+ # frame. Uses non-breaking spaces for indentation because ratatui's
1021
+ # Paragraph widget with wrap:true trims regular leading spaces.
1022
+ # @param data [Hash] entry data containing "tools" array
1023
+ # @return [String] TOON-formatted tool schemas
1024
+ def tools_toon(data)
1025
+ data["tools_toon"] ||= Toon.encode(data["tools"])
1026
+ .gsub(/^( +)/) { "\u00a0" * _1.length }
1027
+ end
1028
+
837
1029
  def build_chat_message_lines(tui, msg)
838
1030
  role = msg[:role]
839
- role_style = (role == ROLE_USER) ? tui.style(fg: "green", modifiers: [:bold]) : tui.style(fg: "cyan", modifiers: [:bold])
1031
+ role_cfg = ROLE_STYLES.fetch(role, {fg: "white"})
1032
+ role_style = tui.style(**role_cfg)
840
1033
 
841
- label = ROLE_LABELS.fetch(role, role)
1034
+ label = role_label(role)
842
1035
  content_lines = msg[:content].to_s.split("\n", -1)
843
1036
 
844
1037
  lines = [tui.line(spans: [
@@ -901,6 +1094,8 @@ module TUI
901
1094
  def input_styles(tui, disabled)
902
1095
  border_color = if disabled || @chat_focused
903
1096
  "dark_gray"
1097
+ elsif @session_loading
1098
+ "yellow"
904
1099
  else
905
1100
  "green"
906
1101
  end
@@ -911,11 +1106,25 @@ module TUI
911
1106
  }
912
1107
  end
913
1108
 
1109
+ # Returns the input field title reflecting the current transport and
1110
+ # session state. Only shows "Disconnected" when the WebSocket is
1111
+ # truly down — not during the subscribe handshake or session loading.
1112
+ #
1113
+ # :connected (pre-subscription) still shows "Connecting…" because
1114
+ # the user can't interact until the Action Cable subscription handshake
1115
+ # completes and the server starts streaming events.
914
1116
  def input_title
915
- if !connected?
1117
+ case @cable_client.status
1118
+ when :disconnected
916
1119
  "Disconnected"
1120
+ when :reconnecting
1121
+ "Reconnecting\u2026"
1122
+ when :connecting, :connected
1123
+ "Connecting\u2026"
1124
+ when :subscribed
1125
+ @session_loading ? "Loading\u2026" : "Input"
917
1126
  else
918
- "Input"
1127
+ "Disconnected"
919
1128
  end
920
1129
  end
921
1130
 
@@ -1034,17 +1243,17 @@ module TUI
1034
1243
 
1035
1244
  # Recalls the last pending user message for editing. Removes it from
1036
1245
  # the message store, puts its content back in the input buffer, and
1037
- # tells the server to delete the event.
1246
+ # tells the server to delete the {PendingMessage}.
1038
1247
  #
1039
1248
  # @return [Boolean] true if a message was recalled
1040
1249
  def recall_pending_message
1041
1250
  pending = @message_store.last_pending_user_message
1042
1251
  return false unless pending
1043
1252
 
1044
- @message_store.remove_by_id(pending[:id])
1253
+ @message_store.remove_pending(pending[:pending_message_id])
1045
1254
  @input_buffer.clear
1046
1255
  @input_buffer.insert(pending[:content])
1047
- @cable_client.recall_pending(pending[:id])
1256
+ @cable_client.recall_pending(pending[:pending_message_id])
1048
1257
  true
1049
1258
  end
1050
1259
 
@@ -1059,6 +1268,12 @@ module TUI
1059
1268
  elsif event.down?
1060
1269
  scroll_down(SCROLL_STEP)
1061
1270
  true
1271
+ elsif event.home?
1272
+ scroll_up(@max_scroll)
1273
+ true
1274
+ elsif event.end?
1275
+ scroll_down(@max_scroll)
1276
+ true
1062
1277
  else
1063
1278
  false
1064
1279
  end