anima-core 1.2.0 → 1.4.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +14 -8
  3. data/README.md +96 -23
  4. data/agents/codebase-analyzer.md +1 -1
  5. data/agents/codebase-pattern-finder.md +1 -1
  6. data/agents/documentation-researcher.md +1 -1
  7. data/agents/thoughts-analyzer.md +1 -1
  8. data/agents/web-search-researcher.md +2 -2
  9. data/app/channels/session_channel.rb +53 -35
  10. data/app/decorators/tool_call_decorator.rb +7 -7
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +15 -6
  13. data/app/jobs/passive_recall_job.rb +6 -11
  14. data/app/models/concerns/message/broadcasting.rb +1 -0
  15. data/app/models/goal.rb +14 -0
  16. data/app/models/message.rb +13 -31
  17. data/app/models/pending_message.rb +191 -0
  18. data/app/models/secret.rb +72 -0
  19. data/app/models/session.rb +480 -271
  20. data/bin/inspect-cassette +144 -0
  21. data/bin/release +212 -0
  22. data/bin/with-llms +20 -0
  23. data/config/database.yml +1 -0
  24. data/config/environments/test.rb +5 -0
  25. data/config/initializers/time_nanoseconds.rb +11 -0
  26. data/db/cable_structure.sql +9 -0
  27. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  28. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  29. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  30. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  31. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  32. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  33. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  34. data/db/queue_structure.sql +61 -0
  35. data/db/structure.sql +120 -0
  36. data/lib/agent_loop.rb +53 -51
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +19 -6
  39. data/lib/analytical_brain/tools/activate_skill.rb +2 -2
  40. data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
  42. data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
  43. data/lib/analytical_brain/tools/finish_goal.rb +3 -0
  44. data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
  45. data/lib/analytical_brain/tools/read_workflow.rb +2 -2
  46. data/lib/analytical_brain/tools/set_goal.rb +5 -1
  47. data/lib/analytical_brain/tools/update_goal.rb +5 -1
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/cli.rb +41 -13
  51. data/lib/anima/installer.rb +20 -1
  52. data/lib/anima/settings.rb +37 -2
  53. data/lib/anima/version.rb +1 -1
  54. data/lib/anima.rb +1 -1
  55. data/lib/credential_store.rb +17 -66
  56. data/lib/events/agent_message.rb +14 -0
  57. data/lib/events/base.rb +1 -1
  58. data/lib/events/subscribers/persister.rb +12 -18
  59. data/lib/events/subscribers/subagent_message_router.rb +18 -9
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +91 -50
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +9 -5
  65. data/lib/mneme/passive_recall.rb +85 -16
  66. data/lib/mneme/runner.rb +15 -4
  67. data/lib/providers/anthropic.rb +112 -7
  68. data/lib/shell_session.rb +239 -18
  69. data/lib/tools/base.rb +22 -0
  70. data/lib/tools/bash.rb +61 -7
  71. data/lib/tools/edit.rb +2 -2
  72. data/lib/tools/mark_goal_completed.rb +85 -0
  73. data/lib/tools/read.rb +2 -1
  74. data/lib/tools/recall.rb +98 -0
  75. data/lib/tools/registry.rb +41 -7
  76. data/lib/tools/remember.rb +1 -1
  77. data/lib/tools/response_truncator.rb +70 -0
  78. data/lib/tools/spawn_specialist.rb +11 -8
  79. data/lib/tools/spawn_subagent.rb +19 -13
  80. data/lib/tools/subagent_prompts.rb +41 -5
  81. data/lib/tools/think.rb +23 -0
  82. data/lib/tools/write.rb +1 -1
  83. data/lib/tui/app.rb +545 -137
  84. data/lib/tui/braille_spinner.rb +152 -0
  85. data/lib/tui/cable_client.rb +13 -20
  86. data/lib/tui/decorators/base_decorator.rb +40 -11
  87. data/lib/tui/decorators/bash_decorator.rb +3 -3
  88. data/lib/tui/decorators/edit_decorator.rb +7 -4
  89. data/lib/tui/decorators/read_decorator.rb +6 -8
  90. data/lib/tui/decorators/think_decorator.rb +4 -6
  91. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  92. data/lib/tui/decorators/write_decorator.rb +7 -4
  93. data/lib/tui/flash.rb +19 -14
  94. data/lib/tui/formatting.rb +33 -0
  95. data/lib/tui/input_buffer.rb +6 -6
  96. data/lib/tui/message_store.rb +159 -27
  97. data/lib/tui/performance_logger.rb +2 -3
  98. data/lib/tui/screens/chat.rb +302 -103
  99. data/lib/tui/settings.rb +86 -0
  100. data/skills/activerecord/SKILL.md +1 -1
  101. data/skills/dragonruby/SKILL.md +1 -1
  102. data/skills/draper-decorators/SKILL.md +1 -1
  103. data/skills/gh-issue.md +1 -1
  104. data/skills/mcp-server/SKILL.md +1 -1
  105. data/skills/ratatui-ruby/SKILL.md +1 -1
  106. data/skills/rspec/SKILL.md +1 -1
  107. data/templates/config.toml +30 -1
  108. data/templates/tui.toml +209 -0
  109. metadata +24 -3
  110. data/config/initializers/fts5_schema_dump.rb +0 -21
  111. data/lib/environment_probe.rb +0 -232
@@ -12,39 +12,44 @@ 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
18
20
  class Chat
19
21
  include Formatting
20
22
 
21
- MIN_INPUT_HEIGHT = 3
22
23
  PRINTABLE_CHAR = /\A[[:print:]]\z/
23
24
 
24
25
  ROLE_USER = "user"
25
26
  ROLE_ASSISTANT = "assistant"
26
27
 
27
- SCROLL_STEP = 1
28
- MOUSE_SCROLL_STEP = 2
29
-
30
28
  TOOL_ICON = "\u{1F527}"
31
- CLOCK_ICON = "\u{1F552}"
32
29
  CHECKMARK = "\u2713"
33
30
 
34
- # Viewport virtualization tuning
35
- VIEWPORT_BACK_BUFFER = 3 # entries before scroll target for upward scroll margin
36
- VIEWPORT_OVERFLOW_MULTIPLIER = 2 # build this many viewports worth of lines
37
- VIEWPORT_BOTTOM_THRESHOLD = 10 # entries from end before we include all trailing
38
-
39
- ROLE_COLORS = {"user" => "green", "assistant" => "cyan"}.freeze
31
+ # Background-highlighted styles for conversation roles.
32
+ # Dark tinted backgrounds make user/assistant messages easy to scan.
33
+ # Colors configured via [theme] user_message_bg / assistant_message_bg.
34
+ def self.role_styles
35
+ {
36
+ "user" => {fg: Settings.theme_color_text, bg: Settings.theme_user_message_bg, modifiers: [:bold]},
37
+ "assistant" => {fg: Settings.theme_color_text, bg: Settings.theme_assistant_message_bg, modifiers: [:bold]}
38
+ }
39
+ end
40
40
 
41
41
  # Intentionally duplicated from Session::VIEW_MODES to keep the TUI
42
42
  # independent of Rails. Must stay in sync when adding new modes.
43
43
  VIEW_MODES = %w[basic verbose debug].freeze
44
44
 
45
+ # @!attribute [r] session_loading
46
+ # Whether the TUI is waiting for session history to arrive from the server.
47
+ # Independent of cable_client.status (transport) and session_state (LLM processing).
48
+ # Set on subscribing/session_changed/view_mode_changed; cleared on first content
49
+ # message or connection failure. Drives the "Loading…" input title and yellow border.
45
50
  attr_reader :message_store, :scroll_offset, :session_info, :view_mode, :sessions_list,
46
51
  :authentication_required, :token_save_result, :parent_session_id,
47
- :chat_focused
52
+ :chat_focused, :session_state, :spinner, :session_loading
48
53
  attr_accessor :hud_hint
49
54
 
50
55
  # @param cable_client [TUI::CableClient] WebSocket client connected to the brain
@@ -56,7 +61,9 @@ module TUI
56
61
  @perf_logger = perf_logger || PerformanceLogger.new(enabled: false)
57
62
  @input_buffer = InputBuffer.new
58
63
  @flash = Flash.new
59
- @loading = false
64
+ @session_state = "idle"
65
+ @session_loading = false
66
+ @spinner = BrailleSpinner.new
60
67
  @scroll_offset = 0
61
68
  @auto_scroll = true
62
69
  @visible_height = 0
@@ -229,8 +236,12 @@ module TUI
229
236
  def finalize
230
237
  end
231
238
 
239
+ # Whether the session is actively processing (any state other than idle).
240
+ # Used by the App's HUD and scroll calculations.
241
+ #
242
+ # @return [Boolean]
232
243
  def loading?
233
- @loading
244
+ @session_state != "idle"
234
245
  end
235
246
 
236
247
  # Switches focus to the chat pane for keyboard scrolling.
@@ -245,15 +256,49 @@ module TUI
245
256
  @chat_focused = false
246
257
  end
247
258
 
259
+ # Short label describing the current session state for HUD display.
260
+ #
261
+ # @return [String]
262
+ def spinner_label
263
+ case @session_state
264
+ when "llm_generating" then "Thinking..."
265
+ when "tool_executing" then "Executing..."
266
+ when "interrupting" then "Stopping..."
267
+ else "Working..."
268
+ end
269
+ end
270
+
271
+ # Color name for the spinner and HUD label based on session state.
272
+ # Follows the two-channel design: color = status (green = working,
273
+ # red = stopping). The braille animation pattern communicates type.
274
+ #
275
+ # @return [String]
276
+ def spinner_color
277
+ case @session_state
278
+ when "llm_generating", "tool_executing" then Settings.theme_color_success
279
+ when "interrupting" then Settings.theme_color_error
280
+ else Settings.theme_color_muted
281
+ end
282
+ end
283
+
248
284
  private
249
285
 
250
- # Drains the WebSocket message queue and feeds events to the message store
286
+ # Drains the WebSocket message queue and feeds events to the message store.
287
+ #
288
+ # Called once per render frame. All operations here MUST be non-blocking:
289
+ # drain_messages uses Thread::Queue#pop(true), and every handler performs
290
+ # only in-memory state updates. Network I/O (WebSocket sends, reconnection)
291
+ # happens in CableClient's background thread via Thread::Queue.
251
292
  def process_incoming_messages
252
293
  @cable_client.drain_messages.each do |msg|
253
294
  action = msg["action"]
254
295
  type = msg["type"]
255
296
 
256
297
  case action
298
+ when "session_state"
299
+ handle_session_state(msg)
300
+ when "child_state"
301
+ handle_child_state(msg)
257
302
  when "session_changed"
258
303
  handle_session_changed(msg)
259
304
  when "view_mode_changed"
@@ -272,8 +317,10 @@ module TUI
272
317
  handle_children_updated(msg)
273
318
  when "sessions_list"
274
319
  @sessions_list = msg["sessions"]
275
- when "user_message_recalled"
276
- @message_store.remove_by_id(msg["message_id"]) if msg["message_id"]
320
+ when "pending_message_created"
321
+ @message_store.add_pending(msg["pending_message_id"], msg["content"]) if msg["pending_message_id"]
322
+ when "pending_message_removed"
323
+ @message_store.remove_pending(msg["pending_message_id"]) if msg["pending_message_id"]
277
324
  when "authentication_required"
278
325
  @authentication_required = true
279
326
  when "token_saved"
@@ -281,6 +328,8 @@ module TUI
281
328
  @token_save_result = {success: true, warning: msg["warning"]}.compact
282
329
  when "token_error"
283
330
  @token_save_result = {success: false, message: msg["message"]}
331
+ when "interrupt_acknowledged"
332
+ @flash.info("Interrupting...")
284
333
  when "error"
285
334
  @flash.error(msg["message"]) if msg["message"]
286
335
  else
@@ -290,18 +339,19 @@ module TUI
290
339
  when "connection"
291
340
  handle_connection_status(msg)
292
341
  when "user_message"
342
+ @session_loading = false
293
343
  @message_store.process_event(msg)
294
344
  unless action == "update"
295
345
  @session_info[:message_count] += 1
296
- @loading = true
297
346
  end
298
347
  when "agent_message"
348
+ @session_loading = false
299
349
  @message_store.process_event(msg)
300
350
  unless action == "update"
301
351
  @session_info[:message_count] += 1
302
- @loading = false
303
352
  end
304
353
  else # tool_call, tool_response, and other event types
354
+ @session_loading = false
305
355
  @message_store.process_event(msg)
306
356
  end
307
357
  end
@@ -341,10 +391,12 @@ module TUI
341
391
  case msg["status"]
342
392
  when "subscribing"
343
393
  @message_store.clear
344
- @loading = false
394
+ @session_loading = true
395
+ update_session_state("idle")
345
396
  @session_info[:message_count] = 0
346
397
  when "disconnected", "failed"
347
- @loading = false
398
+ @session_loading = false
399
+ update_session_state("idle")
348
400
  end
349
401
  end
350
402
 
@@ -357,7 +409,7 @@ module TUI
357
409
  error = msg["error"]
358
410
 
359
411
  @message_store.remove_by_id(message_id) if message_id
360
- @loading = false
412
+ update_session_state("idle")
361
413
 
362
414
  if content
363
415
  @input_buffer.clear
@@ -371,6 +423,9 @@ module TUI
371
423
  new_id = msg["session_id"]
372
424
  @cable_client.update_session_id(new_id)
373
425
  @message_store.clear
426
+ # Only enter loading state when the session has messages to replay.
427
+ # Empty sessions send no history events, so the flag would never clear.
428
+ @session_loading = (msg["message_count"] || 0) > 0
374
429
  @view_mode = msg["view_mode"] if msg["view_mode"]
375
430
  @session_info = {id: new_id, name: msg["name"], agent_name: msg["agent_name"] || "Anima",
376
431
  message_count: msg["message_count"] || 0,
@@ -378,7 +433,7 @@ module TUI
378
433
  goals: msg["goals"] || [], children: msg["children"] || []}
379
434
  @parent_session_id = msg["parent_session_id"]
380
435
  @input_buffer.clear
381
- @loading = false
436
+ update_session_state("idle")
382
437
  @scroll_offset = 0
383
438
  @auto_scroll = true
384
439
  @input_scroll_offset = 0
@@ -426,6 +481,58 @@ module TUI
426
481
  @session_info[:children] = msg["children"] || []
427
482
  end
428
483
 
484
+ # Handles explicit session state transitions from the server.
485
+ # Drives the braille spinner animation. Only processes broadcasts
486
+ # matching the current session.
487
+ #
488
+ # @param msg [Hash] ActionCable payload with "session_id" and "state" keys
489
+ # @return [void]
490
+ def handle_session_state(msg)
491
+ return unless msg["session_id"] == @session_info[:id]
492
+
493
+ update_session_state(msg["state"])
494
+ end
495
+
496
+ # Handles a child session's state change broadcast from the
497
+ # parent stream. Merges the state into the children list so
498
+ # HUD icons update without a full children_updated query.
499
+ #
500
+ # @param msg [Hash] ActionCable payload with "child_id" and "state" keys
501
+ # @return [void]
502
+ def handle_child_state(msg)
503
+ child_id = msg["child_id"]
504
+ return unless child_id
505
+
506
+ child = @session_info[:children]&.find { |c| c["id"] == child_id }
507
+ child["session_state"] = msg["state"] if child
508
+ end
509
+
510
+ # Updates the session state and synchronizes the spinner.
511
+ #
512
+ # @param state [String] one of "idle", "llm_generating",
513
+ # "tool_executing", "interrupting"
514
+ def update_session_state(state)
515
+ @session_state = state
516
+ @spinner.state = state
517
+ end
518
+
519
+ # Builds the animated spinner line for the current session state.
520
+ # The braille character communicates state through its animation
521
+ # pattern; a short label follows for clarity.
522
+ #
523
+ # @param tui [RatatuiRuby] TUI rendering API
524
+ # @return [RatatuiRuby::Widgets::Line]
525
+ def spinner_line(tui)
526
+ char = @spinner.tick || "\u2800"
527
+ label = spinner_label
528
+ color = spinner_color
529
+
530
+ tui.line(spans: [
531
+ tui.span(content: "#{char} ", style: tui.style(fg: color, modifiers: [:bold])),
532
+ tui.span(content: label, style: tui.style(fg: color))
533
+ ])
534
+ end
535
+
429
536
  # Handles server broadcast of view mode change. Clears the message store
430
537
  # in preparation for the re-decorated viewport events that follow.
431
538
  def handle_view_mode_changed(msg)
@@ -434,7 +541,10 @@ module TUI
434
541
 
435
542
  @view_mode = new_mode
436
543
  @message_store.clear
437
- @loading = false
544
+ # Only enter loading state when there are messages to re-decorate.
545
+ # Empty sessions send no viewport events after a mode change.
546
+ @session_loading = (@session_info[:message_count] || 0) > 0
547
+ update_session_state("idle")
438
548
  @scroll_offset = 0
439
549
  @auto_scroll = true
440
550
  end
@@ -468,9 +578,7 @@ module TUI
468
578
  # from actual viewport height. Clamping here would cap scroll_offset
469
579
  # to the (under)estimated total, making the bottom unreachable.
470
580
  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
581
+ @scroll_offset = [@height_map.total_height - @visible_height, 0].max
474
582
  end
475
583
 
476
584
  # Phase 3: Find approximate first visible entry
@@ -483,7 +591,7 @@ module TUI
483
591
 
484
592
  # Phase 5: Paragraph widget + wrapped line count
485
593
  base_widget = @perf_logger.measure(:paragraph) {
486
- tui.paragraph(text: lines, wrap: true, style: tui.style(fg: "white"))
594
+ tui.paragraph(text: lines, wrap: true, style: tui.style(fg: Settings.theme_color_text))
487
595
  }
488
596
  wrapped_height = @perf_logger.measure(:line_count) {
489
597
  cached_viewport_line_count(base_widget, inner_width, version)
@@ -495,8 +603,7 @@ module TUI
495
603
  vp_last = @viewport[:last]
496
604
  est_before = @height_map.cumulative_height(vp_first)
497
605
  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)
606
+ corrected_total = est_before + wrapped_height + est_after
500
607
 
501
608
  @max_scroll = [corrected_total - @visible_height, 0].max
502
609
  @scroll_offset = @max_scroll if @auto_scroll
@@ -514,20 +621,45 @@ module TUI
514
621
  }
515
622
  @perf_logger.measure(:render_widget) { frame.render_widget(widget, area) }
516
623
 
517
- return unless @max_scroll > 0
624
+ if @max_scroll > 0
625
+ scrollbar = tui.scrollbar(
626
+ content_length: @max_scroll,
627
+ position: @scroll_offset,
628
+ orientation: :vertical_right,
629
+ thumb_style: {fg: Settings.theme_scrollbar_thumb},
630
+ track_symbol: "\u2502",
631
+ track_style: {fg: Settings.theme_scrollbar_track}
632
+ )
633
+ frame.render_widget(scrollbar, area)
634
+ end
635
+
636
+ # Spinner overlay: rendered on top of the last line inside the
637
+ # chat border, rebuilt every frame. Independent of the viewport
638
+ # cache — the braille animation advances without cache invalidation.
639
+ render_spinner_overlay(frame, area, tui) if loading?
640
+ end
641
+
642
+ # Renders the spinner as a 1-line overlay at the bottom of the chat
643
+ # pane, inside the border. Painted on top of whatever the messages
644
+ # paragraph rendered there — same pattern as the token setup popup.
645
+ def render_spinner_overlay(frame, area, tui)
646
+ inner = tui.block(**chat_block_config).inner(area)
647
+ return if inner.height < 1
518
648
 
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"}
649
+ spinner_rect = tui.rect(
650
+ x: inner.x,
651
+ y: inner.y + inner.height - 1,
652
+ width: inner.width,
653
+ height: 1
526
654
  )
527
- frame.render_widget(scrollbar, area)
655
+ frame.render_widget(tui.clear, spinner_rect)
656
+ widget = tui.paragraph(text: [spinner_line(tui)])
657
+ frame.render_widget(widget, spinner_rect)
528
658
  end
529
659
 
530
660
  # Renders the empty or loading state placeholder when no messages exist.
661
+ # Three states: active processing (spinner), session loading (loading
662
+ # indicator), and truly empty (start chatting prompt).
531
663
  # Resets scroll state since there is no scrollable content.
532
664
  #
533
665
  # @param frame [RatatuiRuby::Frame] current render frame
@@ -535,17 +667,19 @@ module TUI
535
667
  # @param tui [RatatuiRuby] TUI rendering API
536
668
  # @return [void]
537
669
  def render_empty_or_loading(frame, area, tui)
538
- lines = if @loading
670
+ lines = if loading?
671
+ [spinner_line(tui)]
672
+ elsif @session_loading
539
673
  [tui.line(spans: [
540
- tui.span(content: "Thinking...", style: tui.style(fg: "yellow", modifiers: [:bold]))
674
+ tui.span(content: "Loading session\u2026", style: tui.style(fg: Settings.theme_color_warning))
541
675
  ])]
542
676
  else
543
677
  [tui.line(spans: [
544
- tui.span(content: "Type a message to start chatting.", style: tui.style(fg: "dark_gray"))
678
+ tui.span(content: "Type a message to start chatting.", style: tui.style(fg: Settings.theme_color_muted))
545
679
  ])]
546
680
  end
547
681
 
548
- widget = tui.paragraph(text: lines, wrap: true, style: tui.style(fg: "white"))
682
+ widget = tui.paragraph(text: lines, wrap: true, style: tui.style(fg: Settings.theme_color_text))
549
683
  .with(scroll: [0, 0], block: tui.block(**chat_block_config))
550
684
  frame.render_widget(widget, area)
551
685
  @max_scroll = 0
@@ -555,19 +689,18 @@ module TUI
555
689
  # Re-estimates entry heights when content or width changes.
556
690
  # Height estimation is O(n) string-length math — orders of
557
691
  # magnitude cheaper than building Line/Span objects. Skips
558
- # re-estimation when version, width, and loading state are unchanged.
692
+ # re-estimation when version and width are unchanged.
559
693
  #
560
694
  # @param entries [Array<Hash>] message store entries
561
695
  # @param width [Integer] available terminal width
562
696
  # @param version [Integer] message store version counter
563
697
  # @return [void]
564
698
  def update_height_map(entries, width, version)
565
- return if version == @height_map_version && width == @height_map_width && @loading == @height_map_loading
699
+ return if version == @height_map_version && width == @height_map_width
566
700
 
567
701
  @height_map.update(entries, width) { |entry, avail_width| estimate_entry_height(entry, avail_width) }
568
702
  @height_map_version = version
569
703
  @height_map_width = width
570
- @height_map_loading = @loading
571
704
  end
572
705
 
573
706
  # Returns cached viewport lines, rebuilding only when content
@@ -581,7 +714,7 @@ module TUI
581
714
  vp_first = vp[:first]
582
715
 
583
716
  # Cache hit: content unchanged and scroll target within the built range
584
- if version == vp[:version] && @loading == vp[:loading] &&
717
+ if version == vp[:version] &&
585
718
  vp_first && first_visible_est >= vp_first && first_visible_est <= vp[:last]
586
719
  return vp[:lines]
587
720
  end
@@ -589,12 +722,12 @@ module TUI
589
722
  entry_count = entries.size
590
723
 
591
724
  # Start a few entries before the scroll target for upward buffer
592
- buf_first = [first_visible_est - VIEWPORT_BACK_BUFFER, 0].max
725
+ buf_first = [first_visible_est - Settings.chat_viewport_back_buffer, 0].max
593
726
 
594
727
  # Build forward until we've accumulated enough lines to fill the
595
728
  # viewport with margin. Pre-wrap count is a lower bound on visual
596
729
  # height (wrapping only adds lines), so 2x guarantees coverage.
597
- target = @visible_height * VIEWPORT_OVERFLOW_MULTIPLIER
730
+ target = @visible_height * Settings.chat_viewport_overflow_multiplier
598
731
  lines = []
599
732
  pre_wrap_count = 0
600
733
  buf_last = buf_first
@@ -608,13 +741,7 @@ module TUI
608
741
  # the bottom. Near the bottom, always include trailing entries
609
742
  # so the viewport covers the actual end of content — otherwise
610
743
  # 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
- ])
744
+ break if pre_wrap_count >= target && entry_count - idx > Settings.chat_viewport_bottom_threshold
618
745
  end
619
746
 
620
747
  @perf_logger.info(
@@ -623,7 +750,7 @@ module TUI
623
750
  )
624
751
 
625
752
  @viewport = {
626
- version: version, loading: @loading, width: nil,
753
+ version: version, width: nil,
627
754
  first: buf_first, last: buf_last,
628
755
  lines: lines, wrapped_height: nil
629
756
  }
@@ -636,7 +763,7 @@ module TUI
636
763
  def cached_viewport_line_count(widget, width, version)
637
764
  vp = @viewport
638
765
  cached_height = vp[:wrapped_height]
639
- if cached_height && version == vp[:version] && @loading == vp[:loading] && width == vp[:width]
766
+ if cached_height && version == vp[:version] && width == vp[:width]
640
767
  return cached_height
641
768
  end
642
769
 
@@ -681,6 +808,10 @@ module TUI
681
808
  lines = estimate_text_height(text, effective_width)
682
809
  lines += 1 # header/label line
683
810
  lines += 1 unless entry[:message_type] == "tool_call" # separator
811
+ if data["tools"].is_a?(Array) && data["tools"].any?
812
+ lines += 2 # blank line + "## Tools (N)" header
813
+ lines += estimate_text_height(tools_toon(data), effective_width)
814
+ end
684
815
  lines
685
816
  when :message
686
817
  lines = estimate_text_height(entry[:content].to_s, effective_width)
@@ -719,7 +850,7 @@ module TUI
719
850
  title: "Chat",
720
851
  borders: [:all],
721
852
  border_type: :rounded,
722
- border_style: @chat_focused ? {fg: "yellow"} : {fg: "cyan"}
853
+ border_style: @chat_focused ? {fg: Settings.theme_border_focused} : {fg: Settings.theme_color_info}
723
854
  }
724
855
  if @chat_focused
725
856
  config[:titles] = [
@@ -739,7 +870,7 @@ module TUI
739
870
  responses = counter[:responses]
740
871
  complete = calls == responses
741
872
  label = "#{TOOL_ICON} Tools: #{calls}/#{responses}#{" #{CHECKMARK}" if complete}"
742
- color = complete ? "green" : "yellow"
873
+ color = complete ? Settings.theme_color_success : Settings.theme_color_warning
743
874
  [
744
875
  tui.line(spans: [tui.span(content: label, style: tui.style(fg: color))]),
745
876
  tui.line(spans: [tui.span(content: "")])
@@ -767,7 +898,7 @@ module TUI
767
898
  when "system_prompt"
768
899
  render_system_prompt_entry(tui, data)
769
900
  else
770
- [tui.line(spans: [tui.span(content: data["content"].to_s, style: tui.style(fg: "white"))])]
901
+ [tui.line(spans: [tui.span(content: data["content"].to_s, style: tui.style(fg: Settings.theme_color_text))])]
771
902
  end
772
903
 
773
904
  # Tool calls and their responses are visually one unit — no separator
@@ -777,8 +908,8 @@ module TUI
777
908
  end
778
909
 
779
910
  # 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.
911
+ # Pending messages are dimmed to indicate they haven't been sent to the
912
+ # LLM yet.
782
913
  # @param tui [RatatuiRuby] TUI rendering API
783
914
  # @param data [Hash] structured data with "role", "content", and optional
784
915
  # Display label for a conversation role. Uses the agent name from
@@ -798,19 +929,37 @@ module TUI
798
929
  # @return [Array<RatatuiRuby::Widgets::Line>]
799
930
  def render_conversation_entry(tui, data, role)
800
931
  pending = data["status"] == "pending"
801
- color = pending ? "dark_gray" : ROLE_COLORS.fetch(role, "white")
802
- prefix = role_label(role)
803
- prefix = "#{CLOCK_ICON} #{prefix}" if pending
804
- style = tui.style(fg: color)
932
+ label = role_label(role)
805
933
 
806
- meta = []
807
- meta << "[#{format_ns_timestamp(data["timestamp"])}]" if data["timestamp"]
808
- meta << format_token_label(data["tokens"], data["estimated"]) if data["tokens"]
809
- header = meta.empty? ? "#{prefix}:" : "#{meta.join(" ")} #{prefix}:"
934
+ if pending
935
+ style = tui.style(fg: Settings.theme_color_muted)
936
+ else
937
+ role_cfg = self.class.role_styles.fetch(role, {fg: Settings.theme_color_text})
938
+ style = tui.style(**role_cfg)
939
+ end
810
940
 
941
+ tokens = data["tokens"]
811
942
  content_lines = data["content"].to_s.split("\n", -1)
812
- lines = [tui.line(spans: [tui.span(content: "#{header} #{content_lines.first}", style: style)])]
813
- content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: line, style: style)]) }
943
+ first_content = content_lines.first
944
+ ts = data["timestamp"]
945
+ ts_prefix = ts ? "[#{format_ns_timestamp(ts)}] " : ""
946
+
947
+ first_spans = if tokens && !pending
948
+ tok_style = {fg: token_count_color(tokens)}
949
+ role_bg = self.class.role_styles.dig(role, :bg)
950
+ tok_style[:bg] = role_bg if role_bg
951
+ [
952
+ tui.span(content: ts_prefix, style: style),
953
+ tui.span(content: "#{format_token_label(tokens, data["estimated"])} ", style: tui.style(**tok_style)),
954
+ tui.span(content: "#{label}: #{first_content}", style: style)
955
+ ]
956
+ else
957
+ header = ts_prefix.empty? ? "#{label}:" : "#{ts_prefix}#{label}:"
958
+ [tui.span(content: "#{header} #{first_content}", style: style)]
959
+ end
960
+
961
+ lines = [tui.line(spans: first_spans)]
962
+ content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: preserve_indentation(line), style: style)]) }
814
963
  lines
815
964
  end
816
965
 
@@ -821,48 +970,76 @@ module TUI
821
970
  def render_system_entry(tui, data)
822
971
  ts = data["timestamp"]
823
972
  header = ts ? "[#{format_ns_timestamp(ts)}] [system]" : "[system]"
824
- style = tui.style(fg: "white")
973
+ style = tui.style(fg: Settings.theme_color_text)
825
974
 
826
975
  content_lines = data["content"].to_s.split("\n", -1)
827
976
  lines = [tui.line(spans: [tui.span(content: "#{header} #{content_lines.first}", style: style)])]
828
- content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
977
+ content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)]) }
829
978
  lines
830
979
  end
831
980
 
832
- # Renders the assembled system prompt block in debug mode.
981
+ # Renders the assembled system prompt and tool schemas in debug mode.
982
+ # Tool schemas are converted to TOON format for readability.
833
983
  # @param tui [RatatuiRuby] TUI rendering API
834
- # @param data [Hash] structured data with "content", "tokens", "estimated"
984
+ # @param data [Hash] structured data with "content", "tokens", "estimated",
985
+ # and optionally "tools" (Array<Hash> of tool schemas)
835
986
  # @return [Array<RatatuiRuby::Widgets::Line>]
836
987
  def render_system_prompt_entry(tui, data)
837
- token_label = format_token_label(data["tokens"], data["estimated"])
838
- header = "[SYSTEM] (#{token_label})"
839
- style = tui.style(fg: "magenta")
840
- bold_style = tui.style(fg: "magenta", modifiers: [:bold])
988
+ tokens = data["tokens"]
989
+ bold_style = tui.style(fg: Settings.theme_color_accent, modifiers: [:bold])
990
+ style = tui.style(fg: Settings.theme_color_accent)
991
+ tool_style = tui.style(fg: Settings.theme_color_info)
992
+
993
+ header_spans = [tui.span(content: "[SYSTEM] ", style: bold_style)]
994
+ if tokens
995
+ tok_label = format_token_label(tokens, data["estimated"])
996
+ header_spans << tui.span(content: "(#{tok_label})", style: tui.style(fg: token_count_color(tokens)))
997
+ end
841
998
 
842
- lines = [tui.line(spans: [tui.span(content: header, style: bold_style)])]
999
+ lines = [tui.line(spans: header_spans)]
843
1000
  data["content"].to_s.split("\n").each do |line|
844
- lines << tui.line(spans: [tui.span(content: " #{line}", style: style)])
1001
+ lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)])
845
1002
  end
1003
+
1004
+ if data["tools"].is_a?(Array) && data["tools"].any?
1005
+ lines << tui.line(spans: [tui.span(content: "", style: style)])
1006
+ lines << tui.line(spans: [tui.span(content: preserve_indentation(" ## Tools (#{data["tools"].size})"), style: bold_style)])
1007
+ tools_toon(data).split("\n").each do |line|
1008
+ lines << tui.line(spans: [tui.span(content: preserve_indentation(line), style: tool_style)])
1009
+ end
1010
+ end
1011
+
846
1012
  lines
847
1013
  end
848
1014
 
1015
+ # Converts tool schemas to TOON format for display. Caches the result
1016
+ # on the data hash so the conversion runs once per broadcast, not per
1017
+ # frame. NBSP substitution is applied at the span level via
1018
+ # preserve_indentation, not here.
1019
+ # @param data [Hash] entry data containing "tools" array
1020
+ # @return [String] TOON-formatted tool schemas
1021
+ def tools_toon(data)
1022
+ data["tools_toon"] ||= Toon.encode(data["tools"])
1023
+ end
1024
+
849
1025
  def build_chat_message_lines(tui, msg)
850
1026
  role = msg[:role]
851
- role_style = (role == ROLE_USER) ? tui.style(fg: "green", modifiers: [:bold]) : tui.style(fg: "cyan", modifiers: [:bold])
1027
+ role_cfg = self.class.role_styles.fetch(role, {fg: Settings.theme_color_text})
1028
+ role_style = tui.style(**role_cfg)
852
1029
 
853
1030
  label = role_label(role)
854
1031
  content_lines = msg[:content].to_s.split("\n", -1)
855
1032
 
856
1033
  lines = [tui.line(spans: [
857
1034
  tui.span(content: "#{label}: ", style: role_style),
858
- tui.span(content: content_lines.first.to_s)
1035
+ tui.span(content: preserve_indentation(content_lines.first.to_s))
859
1036
  ])]
860
- content_lines.drop(1).each { |text| lines << tui.line(spans: [tui.span(content: text)]) }
1037
+ content_lines.drop(1).each { |text| lines << tui.line(spans: [tui.span(content: preserve_indentation(text))]) }
861
1038
  lines << tui.line(spans: [tui.span(content: "")])
862
1039
  end
863
1040
 
864
1041
  # Dynamically calculates input area height based on wrapped content.
865
- # Clamped between MIN_INPUT_HEIGHT and 50% of available height.
1042
+ # Clamped between Settings.chat_min_input_height and 50% of available height.
866
1043
  def calculate_input_height(_tui, area_width, area_height)
867
1044
  inner_width = [area_width - 2, 1].max
868
1045
 
@@ -871,8 +1048,8 @@ module TUI
871
1048
  }
872
1049
  desired = content_height + 2 # top + bottom border
873
1050
 
874
- max_height = [area_height / 2, MIN_INPUT_HEIGHT].max
875
- desired.clamp(MIN_INPUT_HEIGHT, max_height)
1051
+ max_height = [area_height / 2, Settings.chat_min_input_height].max
1052
+ desired.clamp(Settings.chat_min_input_height, max_height)
876
1053
  end
877
1054
 
878
1055
  def render_input(frame, area, tui)
@@ -912,22 +1089,38 @@ module TUI
912
1089
 
913
1090
  def input_styles(tui, disabled)
914
1091
  border_color = if disabled || @chat_focused
915
- "dark_gray"
1092
+ Settings.theme_border_input_disconnected
1093
+ elsif @session_loading
1094
+ Settings.theme_border_input_connecting
916
1095
  else
917
- "green"
1096
+ Settings.theme_border_input_connected
918
1097
  end
919
1098
 
920
1099
  {
921
- text: disabled ? tui.style(fg: "dark_gray") : tui.style(fg: "white"),
1100
+ text: disabled ? tui.style(fg: Settings.theme_color_muted) : tui.style(fg: Settings.theme_color_text),
922
1101
  border: {fg: border_color}
923
1102
  }
924
1103
  end
925
1104
 
1105
+ # Returns the input field title reflecting the current transport and
1106
+ # session state. Only shows "Disconnected" when the WebSocket is
1107
+ # truly down — not during the subscribe handshake or session loading.
1108
+ #
1109
+ # :connected (pre-subscription) still shows "Connecting…" because
1110
+ # the user can't interact until the Action Cable subscription handshake
1111
+ # completes and the server starts streaming events.
926
1112
  def input_title
927
- if !connected?
1113
+ case @cable_client.status
1114
+ when :disconnected
928
1115
  "Disconnected"
1116
+ when :reconnecting
1117
+ "Reconnecting\u2026"
1118
+ when :connecting, :connected
1119
+ "Connecting\u2026"
1120
+ when :subscribed
1121
+ @session_loading ? "Loading\u2026" : "Input"
929
1122
  else
930
- "Input"
1123
+ "Disconnected"
931
1124
  end
932
1125
  end
933
1126
 
@@ -1046,17 +1239,17 @@ module TUI
1046
1239
 
1047
1240
  # Recalls the last pending user message for editing. Removes it from
1048
1241
  # the message store, puts its content back in the input buffer, and
1049
- # tells the server to delete the message.
1242
+ # tells the server to delete the {PendingMessage}.
1050
1243
  #
1051
1244
  # @return [Boolean] true if a message was recalled
1052
1245
  def recall_pending_message
1053
1246
  pending = @message_store.last_pending_user_message
1054
1247
  return false unless pending
1055
1248
 
1056
- @message_store.remove_by_id(pending[:id])
1249
+ @message_store.remove_pending(pending[:pending_message_id])
1057
1250
  @input_buffer.clear
1058
1251
  @input_buffer.insert(pending[:content])
1059
- @cable_client.recall_pending(pending[:id])
1252
+ @cable_client.recall_pending(pending[:pending_message_id])
1060
1253
  true
1061
1254
  end
1062
1255
 
@@ -1066,10 +1259,16 @@ module TUI
1066
1259
  # @return [Boolean] true if the event was handled
1067
1260
  def handle_chat_focused_event(event)
1068
1261
  if event.up?
1069
- scroll_up(SCROLL_STEP)
1262
+ scroll_up(Settings.chat_scroll_step)
1070
1263
  true
1071
1264
  elsif event.down?
1072
- scroll_down(SCROLL_STEP)
1265
+ scroll_down(Settings.chat_scroll_step)
1266
+ true
1267
+ elsif event.home?
1268
+ scroll_up(@max_scroll)
1269
+ true
1270
+ elsif event.end?
1271
+ scroll_down(@max_scroll)
1073
1272
  true
1074
1273
  else
1075
1274
  false
@@ -1148,9 +1347,9 @@ module TUI
1148
1347
  # @return [true] always redraws after scrolling
1149
1348
  def handle_scroll_key(event)
1150
1349
  if event.up?
1151
- scroll_up(SCROLL_STEP)
1350
+ scroll_up(Settings.chat_scroll_step)
1152
1351
  elsif event.down?
1153
- scroll_down(SCROLL_STEP)
1352
+ scroll_down(Settings.chat_scroll_step)
1154
1353
  elsif event.page_up?
1155
1354
  scroll_up(@visible_height)
1156
1355
  elsif event.page_down?
@@ -1163,10 +1362,10 @@ module TUI
1163
1362
  # @return [Boolean] true if the event was a scroll wheel event
1164
1363
  def handle_mouse_event(event)
1165
1364
  if event.scroll_up?
1166
- scroll_up(MOUSE_SCROLL_STEP)
1365
+ scroll_up(Settings.chat_mouse_scroll_step)
1167
1366
  true
1168
1367
  elsif event.scroll_down?
1169
- scroll_down(MOUSE_SCROLL_STEP)
1368
+ scroll_down(Settings.chat_mouse_scroll_step)
1170
1369
  true
1171
1370
  else
1172
1371
  false