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