anima-core 1.1.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +10 -1
- data/README.md +36 -11
- data/agents/codebase-analyzer.md +2 -2
- data/agents/codebase-pattern-finder.md +2 -2
- data/agents/documentation-researcher.md +2 -2
- data/agents/thoughts-analyzer.md +2 -2
- data/agents/web-search-researcher.md +3 -3
- data/app/channels/session_channel.rb +83 -64
- data/app/decorators/agent_message_decorator.rb +2 -2
- data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
- data/app/decorators/system_message_decorator.rb +2 -2
- data/app/decorators/tool_call_decorator.rb +6 -6
- data/app/decorators/tool_decorator.rb +4 -4
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +5 -19
- data/app/decorators/web_get_tool_decorator.rb +41 -9
- data/app/jobs/agent_request_job.rb +33 -24
- data/app/jobs/count_message_tokens_job.rb +39 -0
- data/app/jobs/passive_recall_job.rb +4 -4
- data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
- data/app/models/goal.rb +17 -4
- data/app/models/goal_pinned_message.rb +11 -0
- data/app/models/message.rb +127 -0
- data/app/models/pending_message.rb +43 -0
- data/app/models/pinned_message.rb +41 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +385 -226
- data/app/models/snapshot.rb +25 -25
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/migrate/20260326180000_rename_event_to_message.rb +172 -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 +14 -41
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +40 -37
- data/lib/analytical_brain/tools/activate_skill.rb +5 -9
- data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
- data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
- data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
- data/lib/analytical_brain/tools/finish_goal.rb +5 -8
- data/lib/analytical_brain/tools/read_workflow.rb +5 -9
- data/lib/analytical_brain/tools/rename_session.rb +3 -10
- data/lib/analytical_brain/tools/set_goal.rb +3 -7
- data/lib/analytical_brain/tools/update_goal.rb +3 -7
- 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 +46 -6
- 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/bounce_back.rb +7 -7
- data/lib/events/subscribers/persister.rb +15 -22
- data/lib/events/subscribers/subagent_message_router.rb +20 -8
- data/lib/events/subscribers/transient_broadcaster.rb +2 -2
- 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 +57 -57
- data/lib/mneme/l2_runner.rb +4 -4
- data/lib/mneme/passive_recall.rb +2 -2
- data/lib/mneme/runner.rb +57 -75
- data/lib/mneme/search.rb +38 -38
- data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
- data/lib/mneme/tools/everything_ok.rb +1 -3
- data/lib/mneme/tools/save_snapshot.rb +12 -16
- data/lib/shell_session.rb +54 -16
- data/lib/tools/base.rb +23 -0
- data/lib/tools/bash.rb +60 -16
- data/lib/tools/edit.rb +6 -8
- data/lib/tools/mark_goal_completed.rb +86 -0
- data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
- data/lib/tools/read.rb +6 -5
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +37 -8
- data/lib/tools/remember.rb +46 -55
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +15 -25
- data/lib/tools/spawn_subagent.rb +14 -22
- data/lib/tools/subagent_prompts.rb +42 -6
- data/lib/tools/think.rb +26 -10
- data/lib/tools/web_get.rb +23 -4
- data/lib/tools/write.rb +4 -4
- 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 +103 -59
- data/lib/tui/screens/chat.rb +293 -78
- 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 +42 -5
- data/templates/soul.md +7 -19
- data/workflows/create_handoff.md +1 -1
- data/workflows/create_note.md +1 -1
- data/workflows/create_plan.md +1 -1
- data/workflows/implement_plan.md +1 -1
- data/workflows/iterate_plan.md +1 -1
- data/workflows/research_codebase.md +1 -1
- data/workflows/resume_handoff.md +1 -1
- data/workflows/review_pr.md +78 -16
- data/workflows/thoughts_init.md +1 -1
- data/workflows/validate_plan.md +1 -1
- metadata +20 -9
- data/app/jobs/count_event_tokens_job.rb +0 -39
- data/app/models/event.rb +0 -129
- data/app/models/goal_pinned_event.rb +0 -11
- data/app/models/pinned_event.rb +0 -41
- data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
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
|
|
@@ -23,13 +25,11 @@ module TUI
|
|
|
23
25
|
|
|
24
26
|
ROLE_USER = "user"
|
|
25
27
|
ROLE_ASSISTANT = "assistant"
|
|
26
|
-
ROLE_LABELS = {ROLE_USER => "You", ROLE_ASSISTANT => "Anima"}.freeze
|
|
27
28
|
|
|
28
29
|
SCROLL_STEP = 1
|
|
29
30
|
MOUSE_SCROLL_STEP = 2
|
|
30
31
|
|
|
31
32
|
TOOL_ICON = "\u{1F527}"
|
|
32
|
-
CLOCK_ICON = "\u{1F552}"
|
|
33
33
|
CHECKMARK = "\u2713"
|
|
34
34
|
|
|
35
35
|
# Viewport virtualization tuning
|
|
@@ -37,15 +37,26 @@ module TUI
|
|
|
37
37
|
VIEWPORT_OVERFLOW_MULTIPLIER = 2 # build this many viewports worth of lines
|
|
38
38
|
VIEWPORT_BOTTOM_THRESHOLD = 10 # entries from end before we include all trailing
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
# Background-highlighted styles for conversation roles.
|
|
41
|
+
# Dark tinted backgrounds make user/assistant messages easy to scan.
|
|
42
|
+
# 22 = dark green (#005f00), 17 = dark navy (#00005f) in 256-color.
|
|
43
|
+
ROLE_STYLES = {
|
|
44
|
+
"user" => {fg: "white", bg: 22, modifiers: [:bold]},
|
|
45
|
+
"assistant" => {fg: "white", bg: 17, modifiers: [:bold]}
|
|
46
|
+
}.freeze
|
|
41
47
|
|
|
42
48
|
# Intentionally duplicated from Session::VIEW_MODES to keep the TUI
|
|
43
49
|
# independent of Rails. Must stay in sync when adding new modes.
|
|
44
50
|
VIEW_MODES = %w[basic verbose debug].freeze
|
|
45
51
|
|
|
52
|
+
# @!attribute [r] session_loading
|
|
53
|
+
# Whether the TUI is waiting for session history to arrive from the server.
|
|
54
|
+
# Independent of cable_client.status (transport) and session_state (LLM processing).
|
|
55
|
+
# Set on subscribing/session_changed/view_mode_changed; cleared on first content
|
|
56
|
+
# message or connection failure. Drives the "Loading…" input title and yellow border.
|
|
46
57
|
attr_reader :message_store, :scroll_offset, :session_info, :view_mode, :sessions_list,
|
|
47
58
|
:authentication_required, :token_save_result, :parent_session_id,
|
|
48
|
-
:chat_focused
|
|
59
|
+
:chat_focused, :session_state, :spinner, :session_loading
|
|
49
60
|
attr_accessor :hud_hint
|
|
50
61
|
|
|
51
62
|
# @param cable_client [TUI::CableClient] WebSocket client connected to the brain
|
|
@@ -57,14 +68,16 @@ module TUI
|
|
|
57
68
|
@perf_logger = perf_logger || PerformanceLogger.new(enabled: false)
|
|
58
69
|
@input_buffer = InputBuffer.new
|
|
59
70
|
@flash = Flash.new
|
|
60
|
-
@
|
|
71
|
+
@session_state = "idle"
|
|
72
|
+
@session_loading = false
|
|
73
|
+
@spinner = BrailleSpinner.new
|
|
61
74
|
@scroll_offset = 0
|
|
62
75
|
@auto_scroll = true
|
|
63
76
|
@visible_height = 0
|
|
64
77
|
@max_scroll = 0
|
|
65
78
|
@input_scroll_offset = 0
|
|
66
79
|
@view_mode = "basic"
|
|
67
|
-
@session_info = {id: cable_client.session_id || 0, message_count: 0, active_skills: [], active_workflow: nil, goals: [], children: []}
|
|
80
|
+
@session_info = {id: cable_client.session_id || 0, agent_name: "Anima", message_count: 0, active_skills: [], active_workflow: nil, goals: [], children: []}
|
|
68
81
|
@sessions_list = nil
|
|
69
82
|
@parent_session_id = nil
|
|
70
83
|
@authentication_required = false
|
|
@@ -230,8 +243,12 @@ module TUI
|
|
|
230
243
|
def finalize
|
|
231
244
|
end
|
|
232
245
|
|
|
246
|
+
# Whether the session is actively processing (any state other than idle).
|
|
247
|
+
# Used by the App's HUD and scroll calculations.
|
|
248
|
+
#
|
|
249
|
+
# @return [Boolean]
|
|
233
250
|
def loading?
|
|
234
|
-
@
|
|
251
|
+
@session_state != "idle"
|
|
235
252
|
end
|
|
236
253
|
|
|
237
254
|
# Switches focus to the chat pane for keyboard scrolling.
|
|
@@ -246,15 +263,49 @@ module TUI
|
|
|
246
263
|
@chat_focused = false
|
|
247
264
|
end
|
|
248
265
|
|
|
266
|
+
# Short label describing the current session state for HUD display.
|
|
267
|
+
#
|
|
268
|
+
# @return [String]
|
|
269
|
+
def spinner_label
|
|
270
|
+
case @session_state
|
|
271
|
+
when "llm_generating" then "Thinking..."
|
|
272
|
+
when "tool_executing" then "Executing..."
|
|
273
|
+
when "interrupting" then "Stopping..."
|
|
274
|
+
else "Working..."
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Color name for the spinner and HUD label based on session state.
|
|
279
|
+
# Follows the two-channel design: color = status (green = working,
|
|
280
|
+
# red = stopping). The braille animation pattern communicates type.
|
|
281
|
+
#
|
|
282
|
+
# @return [String]
|
|
283
|
+
def spinner_color
|
|
284
|
+
case @session_state
|
|
285
|
+
when "llm_generating", "tool_executing" then "green"
|
|
286
|
+
when "interrupting" then "red"
|
|
287
|
+
else "dark_gray"
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
249
291
|
private
|
|
250
292
|
|
|
251
|
-
# Drains the WebSocket message queue and feeds events to the message store
|
|
293
|
+
# Drains the WebSocket message queue and feeds events to the message store.
|
|
294
|
+
#
|
|
295
|
+
# Called once per render frame. All operations here MUST be non-blocking:
|
|
296
|
+
# drain_messages uses Thread::Queue#pop(true), and every handler performs
|
|
297
|
+
# only in-memory state updates. Network I/O (WebSocket sends, reconnection)
|
|
298
|
+
# happens in CableClient's background thread via Thread::Queue.
|
|
252
299
|
def process_incoming_messages
|
|
253
300
|
@cable_client.drain_messages.each do |msg|
|
|
254
301
|
action = msg["action"]
|
|
255
302
|
type = msg["type"]
|
|
256
303
|
|
|
257
304
|
case action
|
|
305
|
+
when "session_state"
|
|
306
|
+
handle_session_state(msg)
|
|
307
|
+
when "child_state"
|
|
308
|
+
handle_child_state(msg)
|
|
258
309
|
when "session_changed"
|
|
259
310
|
handle_session_changed(msg)
|
|
260
311
|
when "view_mode_changed"
|
|
@@ -273,8 +324,10 @@ module TUI
|
|
|
273
324
|
handle_children_updated(msg)
|
|
274
325
|
when "sessions_list"
|
|
275
326
|
@sessions_list = msg["sessions"]
|
|
276
|
-
when "
|
|
277
|
-
@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"]
|
|
278
331
|
when "authentication_required"
|
|
279
332
|
@authentication_required = true
|
|
280
333
|
when "token_saved"
|
|
@@ -282,6 +335,8 @@ module TUI
|
|
|
282
335
|
@token_save_result = {success: true, warning: msg["warning"]}.compact
|
|
283
336
|
when "token_error"
|
|
284
337
|
@token_save_result = {success: false, message: msg["message"]}
|
|
338
|
+
when "interrupt_acknowledged"
|
|
339
|
+
@flash.info("Interrupting...")
|
|
285
340
|
when "error"
|
|
286
341
|
@flash.error(msg["message"]) if msg["message"]
|
|
287
342
|
else
|
|
@@ -291,18 +346,19 @@ module TUI
|
|
|
291
346
|
when "connection"
|
|
292
347
|
handle_connection_status(msg)
|
|
293
348
|
when "user_message"
|
|
349
|
+
@session_loading = false
|
|
294
350
|
@message_store.process_event(msg)
|
|
295
351
|
unless action == "update"
|
|
296
352
|
@session_info[:message_count] += 1
|
|
297
|
-
@loading = true
|
|
298
353
|
end
|
|
299
354
|
when "agent_message"
|
|
355
|
+
@session_loading = false
|
|
300
356
|
@message_store.process_event(msg)
|
|
301
357
|
unless action == "update"
|
|
302
358
|
@session_info[:message_count] += 1
|
|
303
|
-
@loading = false
|
|
304
359
|
end
|
|
305
360
|
else # tool_call, tool_response, and other event types
|
|
361
|
+
@session_loading = false
|
|
306
362
|
@message_store.process_event(msg)
|
|
307
363
|
end
|
|
308
364
|
end
|
|
@@ -311,13 +367,13 @@ module TUI
|
|
|
311
367
|
end
|
|
312
368
|
end
|
|
313
369
|
|
|
314
|
-
# Removes messages that left the LLM's context window.
|
|
315
|
-
# include `
|
|
370
|
+
# Removes messages that left the LLM's context window. Broadcasts
|
|
371
|
+
# include `evicted_message_ids` when old messages are pushed out of the
|
|
316
372
|
# viewport by new ones.
|
|
317
373
|
#
|
|
318
374
|
# @param msg [Hash] incoming WebSocket message
|
|
319
375
|
def handle_viewport_evictions(msg)
|
|
320
|
-
evicted_ids = msg["
|
|
376
|
+
evicted_ids = msg["evicted_message_ids"]
|
|
321
377
|
return unless evicted_ids.is_a?(Array) && evicted_ids.any?
|
|
322
378
|
|
|
323
379
|
@message_store.remove_by_ids(evicted_ids)
|
|
@@ -342,23 +398,25 @@ module TUI
|
|
|
342
398
|
case msg["status"]
|
|
343
399
|
when "subscribing"
|
|
344
400
|
@message_store.clear
|
|
345
|
-
@
|
|
401
|
+
@session_loading = true
|
|
402
|
+
update_session_state("idle")
|
|
346
403
|
@session_info[:message_count] = 0
|
|
347
404
|
when "disconnected", "failed"
|
|
348
|
-
@
|
|
405
|
+
@session_loading = false
|
|
406
|
+
update_session_state("idle")
|
|
349
407
|
end
|
|
350
408
|
end
|
|
351
409
|
|
|
352
|
-
# Handles a Bounce Back
|
|
410
|
+
# Handles a Bounce Back: the server rolled back the user message
|
|
353
411
|
# because LLM delivery failed. Removes the phantom message from the
|
|
354
412
|
# chat, restores the text to the input field, and shows a flash.
|
|
355
413
|
def handle_bounce_back(msg)
|
|
356
|
-
|
|
414
|
+
message_id = msg["message_id"]
|
|
357
415
|
content = msg["content"]
|
|
358
416
|
error = msg["error"]
|
|
359
417
|
|
|
360
|
-
@message_store.remove_by_id(
|
|
361
|
-
|
|
418
|
+
@message_store.remove_by_id(message_id) if message_id
|
|
419
|
+
update_session_state("idle")
|
|
362
420
|
|
|
363
421
|
if content
|
|
364
422
|
@input_buffer.clear
|
|
@@ -372,13 +430,17 @@ module TUI
|
|
|
372
430
|
new_id = msg["session_id"]
|
|
373
431
|
@cable_client.update_session_id(new_id)
|
|
374
432
|
@message_store.clear
|
|
433
|
+
# Only enter loading state when the session has messages to replay.
|
|
434
|
+
# Empty sessions send no history events, so the flag would never clear.
|
|
435
|
+
@session_loading = (msg["message_count"] || 0) > 0
|
|
375
436
|
@view_mode = msg["view_mode"] if msg["view_mode"]
|
|
376
|
-
@session_info = {id: new_id, name: msg["name"],
|
|
437
|
+
@session_info = {id: new_id, name: msg["name"], agent_name: msg["agent_name"] || "Anima",
|
|
438
|
+
message_count: msg["message_count"] || 0,
|
|
377
439
|
active_skills: msg["active_skills"] || [], active_workflow: msg["active_workflow"],
|
|
378
440
|
goals: msg["goals"] || [], children: msg["children"] || []}
|
|
379
441
|
@parent_session_id = msg["parent_session_id"]
|
|
380
442
|
@input_buffer.clear
|
|
381
|
-
|
|
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
|
|
|
@@ -680,7 +814,7 @@ module TUI
|
|
|
680
814
|
text = [data["content"], data["input"]].compact.map(&:to_s).reject(&:empty?).join("\n")
|
|
681
815
|
lines = estimate_text_height(text, effective_width)
|
|
682
816
|
lines += 1 # header/label line
|
|
683
|
-
lines += 1 unless entry[:
|
|
817
|
+
lines += 1 unless entry[:message_type] == "tool_call" # separator
|
|
684
818
|
lines
|
|
685
819
|
when :message
|
|
686
820
|
lines = estimate_text_height(entry[:content].to_s, effective_width)
|
|
@@ -772,32 +906,62 @@ module TUI
|
|
|
772
906
|
|
|
773
907
|
# Tool calls and their responses are visually one unit — no separator
|
|
774
908
|
# between them. Separator appears after the response completes the pair.
|
|
775
|
-
lines << tui.line(spans: [tui.span(content: "")]) unless entry[:
|
|
909
|
+
lines << tui.line(spans: [tui.span(content: "")]) unless entry[:message_type] == "tool_call"
|
|
776
910
|
lines
|
|
777
911
|
end
|
|
778
912
|
|
|
779
913
|
# Renders a user or assistant message with optional timestamp and token count.
|
|
780
|
-
# Pending messages are dimmed
|
|
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
|
|
918
|
+
# Display label for a conversation role. Uses the agent name from
|
|
919
|
+
# Settings (delivered via session_changed) for the assistant role.
|
|
920
|
+
#
|
|
921
|
+
# @param role [String] "user" or "assistant"
|
|
922
|
+
# @return [String] display label
|
|
923
|
+
def role_label(role)
|
|
924
|
+
return "You" if role == ROLE_USER
|
|
925
|
+
return @session_info[:agent_name] || "Anima" if role == ROLE_ASSISTANT
|
|
926
|
+
|
|
927
|
+
role
|
|
928
|
+
end
|
|
929
|
+
|
|
784
930
|
# "timestamp", "tokens", "estimated", "status"
|
|
785
931
|
# @param role [String] "user" or "assistant"
|
|
786
932
|
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
787
933
|
def render_conversation_entry(tui, data, role)
|
|
788
934
|
pending = data["status"] == "pending"
|
|
789
|
-
|
|
790
|
-
prefix = ROLE_LABELS.fetch(role, role)
|
|
791
|
-
prefix = "#{CLOCK_ICON} #{prefix}" if pending
|
|
792
|
-
style = tui.style(fg: color)
|
|
935
|
+
label = role_label(role)
|
|
793
936
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
937
|
+
if pending
|
|
938
|
+
style = tui.style(fg: "gray")
|
|
939
|
+
else
|
|
940
|
+
role_cfg = ROLE_STYLES.fetch(role, {fg: "white"})
|
|
941
|
+
style = tui.style(**role_cfg)
|
|
942
|
+
end
|
|
798
943
|
|
|
944
|
+
tokens = data["tokens"]
|
|
799
945
|
content_lines = data["content"].to_s.split("\n", -1)
|
|
800
|
-
|
|
946
|
+
first_content = content_lines.first
|
|
947
|
+
ts = data["timestamp"]
|
|
948
|
+
ts_prefix = ts ? "[#{format_ns_timestamp(ts)}] " : ""
|
|
949
|
+
|
|
950
|
+
first_spans = if tokens && !pending
|
|
951
|
+
tok_style = {fg: token_count_color(tokens)}
|
|
952
|
+
role_bg = ROLE_STYLES.dig(role, :bg)
|
|
953
|
+
tok_style[:bg] = role_bg if role_bg
|
|
954
|
+
[
|
|
955
|
+
tui.span(content: ts_prefix, style: style),
|
|
956
|
+
tui.span(content: "#{format_token_label(tokens, data["estimated"])} ", style: tui.style(**tok_style)),
|
|
957
|
+
tui.span(content: "#{label}: #{first_content}", style: style)
|
|
958
|
+
]
|
|
959
|
+
else
|
|
960
|
+
header = ts_prefix.empty? ? "#{label}:" : "#{ts_prefix}#{label}:"
|
|
961
|
+
[tui.span(content: "#{header} #{first_content}", style: style)]
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
lines = [tui.line(spans: first_spans)]
|
|
801
965
|
content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: line, style: style)]) }
|
|
802
966
|
lines
|
|
803
967
|
end
|
|
@@ -817,28 +981,57 @@ module TUI
|
|
|
817
981
|
lines
|
|
818
982
|
end
|
|
819
983
|
|
|
820
|
-
# Renders the assembled system prompt
|
|
984
|
+
# Renders the assembled system prompt and tool schemas in debug mode.
|
|
985
|
+
# Tool schemas are converted to TOON format for readability.
|
|
821
986
|
# @param tui [RatatuiRuby] TUI rendering API
|
|
822
|
-
# @param data [Hash] structured data with "content", "tokens", "estimated"
|
|
987
|
+
# @param data [Hash] structured data with "content", "tokens", "estimated",
|
|
988
|
+
# and optionally "tools" (Array<Hash> of tool schemas)
|
|
823
989
|
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
824
990
|
def render_system_prompt_entry(tui, data)
|
|
825
|
-
|
|
826
|
-
header = "[SYSTEM] (#{token_label})"
|
|
827
|
-
style = tui.style(fg: "magenta")
|
|
991
|
+
tokens = data["tokens"]
|
|
828
992
|
bold_style = tui.style(fg: "magenta", modifiers: [:bold])
|
|
993
|
+
style = tui.style(fg: "magenta")
|
|
994
|
+
tool_style = tui.style(fg: "cyan")
|
|
995
|
+
|
|
996
|
+
header_spans = [tui.span(content: "[SYSTEM] ", style: bold_style)]
|
|
997
|
+
if tokens
|
|
998
|
+
tok_label = format_token_label(tokens, data["estimated"])
|
|
999
|
+
header_spans << tui.span(content: "(#{tok_label})", style: tui.style(fg: token_count_color(tokens)))
|
|
1000
|
+
end
|
|
829
1001
|
|
|
830
|
-
lines = [tui.line(spans:
|
|
1002
|
+
lines = [tui.line(spans: header_spans)]
|
|
831
1003
|
data["content"].to_s.split("\n").each do |line|
|
|
832
1004
|
lines << tui.line(spans: [tui.span(content: " #{line}", style: style)])
|
|
833
1005
|
end
|
|
1006
|
+
|
|
1007
|
+
if data["tools"].is_a?(Array) && data["tools"].any?
|
|
1008
|
+
lines << tui.line(spans: [tui.span(content: "", style: style)])
|
|
1009
|
+
lines << tui.line(spans: [tui.span(content: "\u00a0\u00a0## Tools (#{data["tools"].size})", style: bold_style)])
|
|
1010
|
+
tools_toon(data).split("\n").each do |line|
|
|
1011
|
+
lines << tui.line(spans: [tui.span(content: line, style: tool_style)])
|
|
1012
|
+
end
|
|
1013
|
+
end
|
|
1014
|
+
|
|
834
1015
|
lines
|
|
835
1016
|
end
|
|
836
1017
|
|
|
1018
|
+
# Converts tool schemas to TOON format for display. Caches the result
|
|
1019
|
+
# on the data hash so the conversion runs once per broadcast, not per
|
|
1020
|
+
# frame. Uses non-breaking spaces for indentation because ratatui's
|
|
1021
|
+
# Paragraph widget with wrap:true trims regular leading spaces.
|
|
1022
|
+
# @param data [Hash] entry data containing "tools" array
|
|
1023
|
+
# @return [String] TOON-formatted tool schemas
|
|
1024
|
+
def tools_toon(data)
|
|
1025
|
+
data["tools_toon"] ||= Toon.encode(data["tools"])
|
|
1026
|
+
.gsub(/^( +)/) { "\u00a0" * _1.length }
|
|
1027
|
+
end
|
|
1028
|
+
|
|
837
1029
|
def build_chat_message_lines(tui, msg)
|
|
838
1030
|
role = msg[:role]
|
|
839
|
-
|
|
1031
|
+
role_cfg = ROLE_STYLES.fetch(role, {fg: "white"})
|
|
1032
|
+
role_style = tui.style(**role_cfg)
|
|
840
1033
|
|
|
841
|
-
label =
|
|
1034
|
+
label = role_label(role)
|
|
842
1035
|
content_lines = msg[:content].to_s.split("\n", -1)
|
|
843
1036
|
|
|
844
1037
|
lines = [tui.line(spans: [
|
|
@@ -901,6 +1094,8 @@ module TUI
|
|
|
901
1094
|
def input_styles(tui, disabled)
|
|
902
1095
|
border_color = if disabled || @chat_focused
|
|
903
1096
|
"dark_gray"
|
|
1097
|
+
elsif @session_loading
|
|
1098
|
+
"yellow"
|
|
904
1099
|
else
|
|
905
1100
|
"green"
|
|
906
1101
|
end
|
|
@@ -911,11 +1106,25 @@ module TUI
|
|
|
911
1106
|
}
|
|
912
1107
|
end
|
|
913
1108
|
|
|
1109
|
+
# Returns the input field title reflecting the current transport and
|
|
1110
|
+
# session state. Only shows "Disconnected" when the WebSocket is
|
|
1111
|
+
# truly down — not during the subscribe handshake or session loading.
|
|
1112
|
+
#
|
|
1113
|
+
# :connected (pre-subscription) still shows "Connecting…" because
|
|
1114
|
+
# the user can't interact until the Action Cable subscription handshake
|
|
1115
|
+
# completes and the server starts streaming events.
|
|
914
1116
|
def input_title
|
|
915
|
-
|
|
1117
|
+
case @cable_client.status
|
|
1118
|
+
when :disconnected
|
|
916
1119
|
"Disconnected"
|
|
1120
|
+
when :reconnecting
|
|
1121
|
+
"Reconnecting\u2026"
|
|
1122
|
+
when :connecting, :connected
|
|
1123
|
+
"Connecting\u2026"
|
|
1124
|
+
when :subscribed
|
|
1125
|
+
@session_loading ? "Loading\u2026" : "Input"
|
|
917
1126
|
else
|
|
918
|
-
"
|
|
1127
|
+
"Disconnected"
|
|
919
1128
|
end
|
|
920
1129
|
end
|
|
921
1130
|
|
|
@@ -1034,17 +1243,17 @@ module TUI
|
|
|
1034
1243
|
|
|
1035
1244
|
# Recalls the last pending user message for editing. Removes it from
|
|
1036
1245
|
# the message store, puts its content back in the input buffer, and
|
|
1037
|
-
# tells the server to delete the
|
|
1246
|
+
# tells the server to delete the {PendingMessage}.
|
|
1038
1247
|
#
|
|
1039
1248
|
# @return [Boolean] true if a message was recalled
|
|
1040
1249
|
def recall_pending_message
|
|
1041
1250
|
pending = @message_store.last_pending_user_message
|
|
1042
1251
|
return false unless pending
|
|
1043
1252
|
|
|
1044
|
-
@message_store.
|
|
1253
|
+
@message_store.remove_pending(pending[:pending_message_id])
|
|
1045
1254
|
@input_buffer.clear
|
|
1046
1255
|
@input_buffer.insert(pending[:content])
|
|
1047
|
-
@cable_client.recall_pending(pending[:
|
|
1256
|
+
@cable_client.recall_pending(pending[:pending_message_id])
|
|
1048
1257
|
true
|
|
1049
1258
|
end
|
|
1050
1259
|
|
|
@@ -1059,6 +1268,12 @@ module TUI
|
|
|
1059
1268
|
elsif event.down?
|
|
1060
1269
|
scroll_down(SCROLL_STEP)
|
|
1061
1270
|
true
|
|
1271
|
+
elsif event.home?
|
|
1272
|
+
scroll_up(@max_scroll)
|
|
1273
|
+
true
|
|
1274
|
+
elsif event.end?
|
|
1275
|
+
scroll_down(@max_scroll)
|
|
1276
|
+
true
|
|
1062
1277
|
else
|
|
1063
1278
|
false
|
|
1064
1279
|
end
|