anima-core 1.2.0 → 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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +8 -1
  3. data/README.md +36 -11
  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 +4 -4
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +13 -4
  13. data/app/models/goal.rb +13 -0
  14. data/app/models/message.rb +13 -18
  15. data/app/models/pending_message.rb +43 -0
  16. data/app/models/secret.rb +72 -0
  17. data/app/models/session.rb +194 -43
  18. data/config/environments/test.rb +5 -0
  19. data/config/initializers/time_nanoseconds.rb +11 -0
  20. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  21. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  22. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  23. data/lib/agent_loop.rb +13 -40
  24. data/lib/agents/definition.rb +1 -1
  25. data/lib/analytical_brain/runner.rb +7 -4
  26. data/lib/anima/cli/mcp/secrets.rb +4 -4
  27. data/lib/anima/cli/mcp.rb +4 -4
  28. data/lib/anima/installer.rb +7 -1
  29. data/lib/anima/settings.rb +31 -2
  30. data/lib/anima/version.rb +1 -1
  31. data/lib/anima.rb +1 -1
  32. data/lib/credential_store.rb +17 -66
  33. data/lib/events/base.rb +1 -1
  34. data/lib/events/subscribers/persister.rb +11 -18
  35. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  36. data/lib/events/user_message.rb +2 -13
  37. data/lib/llm/client.rb +54 -20
  38. data/lib/mcp/config.rb +2 -2
  39. data/lib/mcp/secrets.rb +7 -8
  40. data/lib/mneme/compressed_viewport.rb +1 -1
  41. data/lib/shell_session.rb +54 -16
  42. data/lib/tools/base.rb +23 -0
  43. data/lib/tools/bash.rb +56 -4
  44. data/lib/tools/edit.rb +2 -2
  45. data/lib/tools/mark_goal_completed.rb +86 -0
  46. data/lib/tools/read.rb +2 -1
  47. data/lib/tools/recall.rb +98 -0
  48. data/lib/tools/registry.rb +36 -7
  49. data/lib/tools/remember.rb +1 -1
  50. data/lib/tools/response_truncator.rb +70 -0
  51. data/lib/tools/spawn_specialist.rb +6 -5
  52. data/lib/tools/spawn_subagent.rb +8 -6
  53. data/lib/tools/subagent_prompts.rb +43 -5
  54. data/lib/tools/think.rb +23 -0
  55. data/lib/tools/write.rb +1 -1
  56. data/lib/tui/app.rb +178 -13
  57. data/lib/tui/braille_spinner.rb +152 -0
  58. data/lib/tui/cable_client.rb +4 -4
  59. data/lib/tui/decorators/base_decorator.rb +17 -8
  60. data/lib/tui/decorators/bash_decorator.rb +2 -2
  61. data/lib/tui/decorators/edit_decorator.rb +5 -4
  62. data/lib/tui/decorators/read_decorator.rb +4 -8
  63. data/lib/tui/decorators/think_decorator.rb +3 -5
  64. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  65. data/lib/tui/decorators/write_decorator.rb +5 -4
  66. data/lib/tui/flash.rb +1 -1
  67. data/lib/tui/formatting.rb +22 -0
  68. data/lib/tui/message_store.rb +70 -26
  69. data/lib/tui/screens/chat.rb +269 -66
  70. data/skills/activerecord/SKILL.md +1 -1
  71. data/skills/dragonruby/SKILL.md +1 -1
  72. data/skills/draper-decorators/SKILL.md +1 -1
  73. data/skills/gh-issue.md +1 -1
  74. data/skills/mcp-server/SKILL.md +1 -1
  75. data/skills/ratatui-ruby/SKILL.md +1 -1
  76. data/skills/rspec/SKILL.md +1 -1
  77. data/templates/config.toml +26 -0
  78. metadata +11 -1
@@ -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
@@ -28,7 +30,6 @@ module TUI
28
30
  MOUSE_SCROLL_STEP = 2
29
31
 
30
32
  TOOL_ICON = "\u{1F527}"
31
- CLOCK_ICON = "\u{1F552}"
32
33
  CHECKMARK = "\u2713"
33
34
 
34
35
  # Viewport virtualization tuning
@@ -36,15 +37,26 @@ module TUI
36
37
  VIEWPORT_OVERFLOW_MULTIPLIER = 2 # build this many viewports worth of lines
37
38
  VIEWPORT_BOTTOM_THRESHOLD = 10 # entries from end before we include all trailing
38
39
 
39
- 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
40
47
 
41
48
  # Intentionally duplicated from Session::VIEW_MODES to keep the TUI
42
49
  # independent of Rails. Must stay in sync when adding new modes.
43
50
  VIEW_MODES = %w[basic verbose debug].freeze
44
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.
45
57
  attr_reader :message_store, :scroll_offset, :session_info, :view_mode, :sessions_list,
46
58
  :authentication_required, :token_save_result, :parent_session_id,
47
- :chat_focused
59
+ :chat_focused, :session_state, :spinner, :session_loading
48
60
  attr_accessor :hud_hint
49
61
 
50
62
  # @param cable_client [TUI::CableClient] WebSocket client connected to the brain
@@ -56,7 +68,9 @@ module TUI
56
68
  @perf_logger = perf_logger || PerformanceLogger.new(enabled: false)
57
69
  @input_buffer = InputBuffer.new
58
70
  @flash = Flash.new
59
- @loading = false
71
+ @session_state = "idle"
72
+ @session_loading = false
73
+ @spinner = BrailleSpinner.new
60
74
  @scroll_offset = 0
61
75
  @auto_scroll = true
62
76
  @visible_height = 0
@@ -229,8 +243,12 @@ module TUI
229
243
  def finalize
230
244
  end
231
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]
232
250
  def loading?
233
- @loading
251
+ @session_state != "idle"
234
252
  end
235
253
 
236
254
  # Switches focus to the chat pane for keyboard scrolling.
@@ -245,15 +263,49 @@ module TUI
245
263
  @chat_focused = false
246
264
  end
247
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
+
248
291
  private
249
292
 
250
- # 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.
251
299
  def process_incoming_messages
252
300
  @cable_client.drain_messages.each do |msg|
253
301
  action = msg["action"]
254
302
  type = msg["type"]
255
303
 
256
304
  case action
305
+ when "session_state"
306
+ handle_session_state(msg)
307
+ when "child_state"
308
+ handle_child_state(msg)
257
309
  when "session_changed"
258
310
  handle_session_changed(msg)
259
311
  when "view_mode_changed"
@@ -272,8 +324,10 @@ module TUI
272
324
  handle_children_updated(msg)
273
325
  when "sessions_list"
274
326
  @sessions_list = msg["sessions"]
275
- when "user_message_recalled"
276
- @message_store.remove_by_id(msg["message_id"]) if msg["message_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"]
277
331
  when "authentication_required"
278
332
  @authentication_required = true
279
333
  when "token_saved"
@@ -281,6 +335,8 @@ module TUI
281
335
  @token_save_result = {success: true, warning: msg["warning"]}.compact
282
336
  when "token_error"
283
337
  @token_save_result = {success: false, message: msg["message"]}
338
+ when "interrupt_acknowledged"
339
+ @flash.info("Interrupting...")
284
340
  when "error"
285
341
  @flash.error(msg["message"]) if msg["message"]
286
342
  else
@@ -290,18 +346,19 @@ module TUI
290
346
  when "connection"
291
347
  handle_connection_status(msg)
292
348
  when "user_message"
349
+ @session_loading = false
293
350
  @message_store.process_event(msg)
294
351
  unless action == "update"
295
352
  @session_info[:message_count] += 1
296
- @loading = true
297
353
  end
298
354
  when "agent_message"
355
+ @session_loading = false
299
356
  @message_store.process_event(msg)
300
357
  unless action == "update"
301
358
  @session_info[:message_count] += 1
302
- @loading = false
303
359
  end
304
360
  else # tool_call, tool_response, and other event types
361
+ @session_loading = false
305
362
  @message_store.process_event(msg)
306
363
  end
307
364
  end
@@ -341,10 +398,12 @@ module TUI
341
398
  case msg["status"]
342
399
  when "subscribing"
343
400
  @message_store.clear
344
- @loading = false
401
+ @session_loading = true
402
+ update_session_state("idle")
345
403
  @session_info[:message_count] = 0
346
404
  when "disconnected", "failed"
347
- @loading = false
405
+ @session_loading = false
406
+ update_session_state("idle")
348
407
  end
349
408
  end
350
409
 
@@ -357,7 +416,7 @@ module TUI
357
416
  error = msg["error"]
358
417
 
359
418
  @message_store.remove_by_id(message_id) if message_id
360
- @loading = false
419
+ update_session_state("idle")
361
420
 
362
421
  if content
363
422
  @input_buffer.clear
@@ -371,6 +430,9 @@ module TUI
371
430
  new_id = msg["session_id"]
372
431
  @cable_client.update_session_id(new_id)
373
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
374
436
  @view_mode = msg["view_mode"] if msg["view_mode"]
375
437
  @session_info = {id: new_id, name: msg["name"], agent_name: msg["agent_name"] || "Anima",
376
438
  message_count: msg["message_count"] || 0,
@@ -378,7 +440,7 @@ module TUI
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
 
@@ -777,8 +911,8 @@ module TUI
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
784
918
  # Display label for a conversation role. Uses the agent name from
@@ -798,18 +932,36 @@ module TUI
798
932
  # @return [Array<RatatuiRuby::Widgets::Line>]
799
933
  def render_conversation_entry(tui, data, role)
800
934
  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)
935
+ label = role_label(role)
805
936
 
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}:"
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
810
943
 
944
+ tokens = data["tokens"]
811
945
  content_lines = data["content"].to_s.split("\n", -1)
812
- 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)]
813
965
  content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: line, style: style)]) }
814
966
  lines
815
967
  end
@@ -829,26 +981,55 @@ module TUI
829
981
  lines
830
982
  end
831
983
 
832
- # 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.
833
986
  # @param tui [RatatuiRuby] TUI rendering API
834
- # @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)
835
989
  # @return [Array<RatatuiRuby::Widgets::Line>]
836
990
  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")
991
+ tokens = data["tokens"]
840
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
841
1001
 
842
- lines = [tui.line(spans: [tui.span(content: header, style: bold_style)])]
1002
+ lines = [tui.line(spans: header_spans)]
843
1003
  data["content"].to_s.split("\n").each do |line|
844
1004
  lines << tui.line(spans: [tui.span(content: " #{line}", style: style)])
845
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
+
846
1015
  lines
847
1016
  end
848
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
+
849
1029
  def build_chat_message_lines(tui, msg)
850
1030
  role = msg[:role]
851
- 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)
852
1033
 
853
1034
  label = role_label(role)
854
1035
  content_lines = msg[:content].to_s.split("\n", -1)
@@ -913,6 +1094,8 @@ module TUI
913
1094
  def input_styles(tui, disabled)
914
1095
  border_color = if disabled || @chat_focused
915
1096
  "dark_gray"
1097
+ elsif @session_loading
1098
+ "yellow"
916
1099
  else
917
1100
  "green"
918
1101
  end
@@ -923,11 +1106,25 @@ module TUI
923
1106
  }
924
1107
  end
925
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.
926
1116
  def input_title
927
- if !connected?
1117
+ case @cable_client.status
1118
+ when :disconnected
928
1119
  "Disconnected"
1120
+ when :reconnecting
1121
+ "Reconnecting\u2026"
1122
+ when :connecting, :connected
1123
+ "Connecting\u2026"
1124
+ when :subscribed
1125
+ @session_loading ? "Loading\u2026" : "Input"
929
1126
  else
930
- "Input"
1127
+ "Disconnected"
931
1128
  end
932
1129
  end
933
1130
 
@@ -1046,17 +1243,17 @@ module TUI
1046
1243
 
1047
1244
  # Recalls the last pending user message for editing. Removes it from
1048
1245
  # the message store, puts its content back in the input buffer, and
1049
- # tells the server to delete the message.
1246
+ # tells the server to delete the {PendingMessage}.
1050
1247
  #
1051
1248
  # @return [Boolean] true if a message was recalled
1052
1249
  def recall_pending_message
1053
1250
  pending = @message_store.last_pending_user_message
1054
1251
  return false unless pending
1055
1252
 
1056
- @message_store.remove_by_id(pending[:id])
1253
+ @message_store.remove_pending(pending[:pending_message_id])
1057
1254
  @input_buffer.clear
1058
1255
  @input_buffer.insert(pending[:content])
1059
- @cable_client.recall_pending(pending[:id])
1256
+ @cable_client.recall_pending(pending[:pending_message_id])
1060
1257
  true
1061
1258
  end
1062
1259
 
@@ -1071,6 +1268,12 @@ module TUI
1071
1268
  elsif event.down?
1072
1269
  scroll_down(SCROLL_STEP)
1073
1270
  true
1271
+ elsif event.home?
1272
+ scroll_up(@max_scroll)
1273
+ true
1274
+ elsif event.end?
1275
+ scroll_down(@max_scroll)
1276
+ true
1074
1277
  else
1075
1278
  false
1076
1279
  end
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: activerecord
3
- description: "Associations, validations, queries, migrations, eager loading. Activate when working with models, database schema, N+1 queries, scopes, includes/preload/eager_load, callbacks, or editing app/models/ or db/migrate/."
3
+ description: "Associations, validations, queries, migrations, eager loading, N+1 queries, scopes, callbacks, app/models/, db/migrate/."
4
4
  ---
5
5
 
6
6
  # ActiveRecord
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: dragonruby
3
- description: "2D game development — game loops, sprites, input, collisions, scenes. Activate when building games with DRGTK, working with args.outputs/state/inputs, or editing game files."
3
+ description: "2D game development — game loops, sprites, input, collisions, scenes, DRGTK, args.outputs/state/inputs."
4
4
  ---
5
5
 
6
6
  # DragonRuby Game Toolkit
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: draper-decorators
3
- description: "Decorator patterns for Rails views — presentation logic separated from models. Activate when creating or testing decorators, moving formatting logic out of models/views, editing *_decorator.rb files, or working in app/decorators/."
3
+ description: "Decorator patterns for Rails views — presentation logic separated from models, *_decorator.rb, app/decorators/."
4
4
  ---
5
5
 
6
6
  # Draper Decorators for Rails
data/skills/gh-issue.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: gh-issue
3
- description: "Issue writing with WHAT/WHY/HOW framework. Activate when creating issues, writing tickets, drafting issue descriptions, or when issues lack clear rationale."
3
+ description: "Issue writing with WHAT/WHY/HOW framework tickets, bug reports, feature requests, issue descriptions, unclear rationale in existing issues."
4
4
  ---
5
5
 
6
6
  # GitHub Issue Writing
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: mcp-server
3
- description: "Ruby server development — tools, prompts, resources, transport. Activate when building Model Context Protocol servers, defining MCP tools/prompts/resources, working with the mcp gem, or discussing LLM tool integrations."
3
+ description: "Ruby MCP server development — tools, prompts, resources, transport, the mcp gem, LLM tool integrations."
4
4
  ---
5
5
 
6
6
  # MCP Ruby SDK - Server Development Guide
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ratatui-ruby
3
- description: "Terminal UI development — widgets, layouts, events, Tea MVU. Activate when building TUI apps, working with terminal rendering, or editing TUI application files."
3
+ description: "Terminal UI development — widgets, layouts, events, Tea MVU, terminal rendering."
4
4
  ---
5
5
 
6
6
  # RatatuiRuby
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: rspec
3
- description: "Testing with FactoryBot — matchers, test doubles, shared examples. Activate when writing specs, fixing failing tests, working with describe/it/expect blocks, editing *_spec.rb files, planning test strategy, or discussing unit/integration tests."
3
+ description: "Testing with FactoryBot — matchers, test doubles, shared examples, describe/it/expect blocks, *_spec.rb files, test strategy."
4
4
  ---
5
5
 
6
6
  # RSpec Testing