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.
- checksums.yaml +4 -4
- data/.reek.yml +8 -1
- data/README.md +36 -11
- data/agents/codebase-analyzer.md +1 -1
- data/agents/codebase-pattern-finder.md +1 -1
- data/agents/documentation-researcher.md +1 -1
- data/agents/thoughts-analyzer.md +1 -1
- data/agents/web-search-researcher.md +2 -2
- data/app/channels/session_channel.rb +53 -35
- data/app/decorators/tool_call_decorator.rb +4 -4
- data/app/decorators/user_message_decorator.rb +3 -17
- data/app/jobs/agent_request_job.rb +13 -4
- data/app/models/goal.rb +13 -0
- data/app/models/message.rb +13 -18
- data/app/models/pending_message.rb +43 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +194 -43
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/lib/agent_loop.rb +13 -40
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +7 -4
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/installer.rb +7 -1
- data/lib/anima/settings.rb +31 -2
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/base.rb +1 -1
- data/lib/events/subscribers/persister.rb +11 -18
- data/lib/events/subscribers/subagent_message_router.rb +20 -8
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +54 -20
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +1 -1
- data/lib/shell_session.rb +54 -16
- data/lib/tools/base.rb +23 -0
- data/lib/tools/bash.rb +56 -4
- data/lib/tools/edit.rb +2 -2
- data/lib/tools/mark_goal_completed.rb +86 -0
- data/lib/tools/read.rb +2 -1
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +36 -7
- data/lib/tools/remember.rb +1 -1
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +6 -5
- data/lib/tools/spawn_subagent.rb +8 -6
- data/lib/tools/subagent_prompts.rb +43 -5
- data/lib/tools/think.rb +23 -0
- data/lib/tools/write.rb +1 -1
- data/lib/tui/app.rb +178 -13
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +4 -4
- data/lib/tui/decorators/base_decorator.rb +17 -8
- data/lib/tui/decorators/bash_decorator.rb +2 -2
- data/lib/tui/decorators/edit_decorator.rb +5 -4
- data/lib/tui/decorators/read_decorator.rb +4 -8
- data/lib/tui/decorators/think_decorator.rb +3 -5
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +5 -4
- data/lib/tui/flash.rb +1 -1
- data/lib/tui/formatting.rb +22 -0
- data/lib/tui/message_store.rb +70 -26
- data/lib/tui/screens/chat.rb +269 -66
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +26 -0
- metadata +11 -1
data/lib/tui/screens/chat.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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 "
|
|
276
|
-
@message_store.
|
|
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
|
-
@
|
|
401
|
+
@session_loading = true
|
|
402
|
+
update_session_state("idle")
|
|
345
403
|
@session_info[:message_count] = 0
|
|
346
404
|
when "disconnected", "failed"
|
|
347
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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(
|
|
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
|
|
677
|
+
lines = if loading?
|
|
678
|
+
[spinner_line(tui)]
|
|
679
|
+
elsif @session_loading
|
|
539
680
|
[tui.line(spans: [
|
|
540
|
-
tui.span(content: "
|
|
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
|
|
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
|
|
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] &&
|
|
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,
|
|
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] &&
|
|
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
|
|
781
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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.
|
|
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[:
|
|
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
|
|
3
|
+
description: "Associations, validations, queries, migrations, eager loading, N+1 queries, scopes, callbacks, app/models/, db/migrate/."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# ActiveRecord
|
data/skills/dragonruby/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: dragonruby
|
|
3
|
-
description: "2D game development — game loops, sprites, input, collisions, scenes
|
|
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
|
|
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
|
|
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
|
data/skills/mcp-server/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp-server
|
|
3
|
-
description: "Ruby server development — tools, prompts, resources, transport
|
|
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
|
|
3
|
+
description: "Terminal UI development — widgets, layouts, events, Tea MVU, terminal rendering."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# RatatuiRuby
|
data/skills/rspec/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: rspec
|
|
3
|
-
description: "Testing with FactoryBot — matchers, test doubles, shared examples
|
|
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
|