anima-core 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +8 -1
  3. data/README.md +36 -11
  4. data/agents/codebase-analyzer.md +1 -1
  5. data/agents/codebase-pattern-finder.md +1 -1
  6. data/agents/documentation-researcher.md +1 -1
  7. data/agents/thoughts-analyzer.md +1 -1
  8. data/agents/web-search-researcher.md +2 -2
  9. data/app/channels/session_channel.rb +53 -35
  10. data/app/decorators/tool_call_decorator.rb +4 -4
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +13 -4
  13. data/app/models/goal.rb +13 -0
  14. data/app/models/message.rb +13 -18
  15. data/app/models/pending_message.rb +43 -0
  16. data/app/models/secret.rb +72 -0
  17. data/app/models/session.rb +194 -43
  18. data/config/environments/test.rb +5 -0
  19. data/config/initializers/time_nanoseconds.rb +11 -0
  20. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  21. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  22. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  23. data/lib/agent_loop.rb +13 -40
  24. data/lib/agents/definition.rb +1 -1
  25. data/lib/analytical_brain/runner.rb +7 -4
  26. data/lib/anima/cli/mcp/secrets.rb +4 -4
  27. data/lib/anima/cli/mcp.rb +4 -4
  28. data/lib/anima/installer.rb +7 -1
  29. data/lib/anima/settings.rb +31 -2
  30. data/lib/anima/version.rb +1 -1
  31. data/lib/anima.rb +1 -1
  32. data/lib/credential_store.rb +17 -66
  33. data/lib/events/base.rb +1 -1
  34. data/lib/events/subscribers/persister.rb +11 -18
  35. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  36. data/lib/events/user_message.rb +2 -13
  37. data/lib/llm/client.rb +54 -20
  38. data/lib/mcp/config.rb +2 -2
  39. data/lib/mcp/secrets.rb +7 -8
  40. data/lib/mneme/compressed_viewport.rb +1 -1
  41. data/lib/shell_session.rb +54 -16
  42. data/lib/tools/base.rb +23 -0
  43. data/lib/tools/bash.rb +56 -4
  44. data/lib/tools/edit.rb +2 -2
  45. data/lib/tools/mark_goal_completed.rb +86 -0
  46. data/lib/tools/read.rb +2 -1
  47. data/lib/tools/recall.rb +98 -0
  48. data/lib/tools/registry.rb +36 -7
  49. data/lib/tools/remember.rb +1 -1
  50. data/lib/tools/response_truncator.rb +70 -0
  51. data/lib/tools/spawn_specialist.rb +6 -5
  52. data/lib/tools/spawn_subagent.rb +8 -6
  53. data/lib/tools/subagent_prompts.rb +43 -5
  54. data/lib/tools/think.rb +23 -0
  55. data/lib/tools/write.rb +1 -1
  56. data/lib/tui/app.rb +178 -13
  57. data/lib/tui/braille_spinner.rb +152 -0
  58. data/lib/tui/cable_client.rb +4 -4
  59. data/lib/tui/decorators/base_decorator.rb +17 -8
  60. data/lib/tui/decorators/bash_decorator.rb +2 -2
  61. data/lib/tui/decorators/edit_decorator.rb +5 -4
  62. data/lib/tui/decorators/read_decorator.rb +4 -8
  63. data/lib/tui/decorators/think_decorator.rb +3 -5
  64. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  65. data/lib/tui/decorators/write_decorator.rb +5 -4
  66. data/lib/tui/flash.rb +1 -1
  67. data/lib/tui/formatting.rb +22 -0
  68. data/lib/tui/message_store.rb +70 -26
  69. data/lib/tui/screens/chat.rb +269 -66
  70. data/skills/activerecord/SKILL.md +1 -1
  71. data/skills/dragonruby/SKILL.md +1 -1
  72. data/skills/draper-decorators/SKILL.md +1 -1
  73. data/skills/gh-issue.md +1 -1
  74. data/skills/mcp-server/SKILL.md +1 -1
  75. data/skills/ratatui-ruby/SKILL.md +1 -1
  76. data/skills/rspec/SKILL.md +1 -1
  77. data/templates/config.toml +26 -0
  78. metadata +11 -1
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ module Tools
6
+ # Truncates oversized tool results to protect the agent's context window.
7
+ #
8
+ # When a tool returns more characters than the configured threshold,
9
+ # saves the full output to a temp file and returns a truncated version:
10
+ # first 10 lines + notice + last 10 lines. The agent can use the
11
+ # +read_file+ tool with offset/limit to inspect the full output.
12
+ #
13
+ # Two thresholds exist:
14
+ # - **Tool threshold** (~3000 chars) — for raw tool output (bash, web, etc.)
15
+ # - **Sub-agent threshold** (~24000 chars) — for curated sub-agent results
16
+ #
17
+ # @example Truncating a tool result
18
+ # ResponseTruncator.truncate(huge_string, threshold: 3000)
19
+ # # => "line 1\nline 2\n...\n---\n⚠️ Response truncated..."
20
+ module ResponseTruncator
21
+ HEAD_LINES = 10
22
+ TAIL_LINES = 10
23
+
24
+ # Attribution prefix for messages routed from sub-agent to parent.
25
+ # Shared by {Events::Subscribers::SubagentMessageRouter} and
26
+ # {Tools::MarkGoalCompleted} to keep formatting consistent.
27
+ ATTRIBUTION_FORMAT = "[sub-agent @%s]: %s"
28
+
29
+ NOTICE = <<~NOTICE.strip
30
+ ---
31
+ ⚠️ Response truncated (%<total>d lines total%<reason>s). Full output saved to: %<path>s
32
+ Use `read_file` tool with offset/limit to inspect specific sections.
33
+ ---
34
+ NOTICE
35
+
36
+ # Truncates content that exceeds the character threshold.
37
+ #
38
+ # @param content [Object] the tool result to (maybe) truncate; non-strings pass through unchanged
39
+ # @param threshold [Integer] character limit before truncation kicks in
40
+ # @param reason [String, nil] why truncation occurred (e.g. "bash output displays first/last 10 lines")
41
+ # @return [Object] original value if non-String/under threshold/few lines, truncated String otherwise
42
+ def self.truncate(content, threshold:, reason: nil)
43
+ return content unless content.is_a?(String)
44
+ return content if content.length <= threshold
45
+
46
+ lines = content.lines
47
+ total = lines.size
48
+ return content if total <= HEAD_LINES + TAIL_LINES
49
+
50
+ path = save_full_output(content)
51
+ head = lines.first(HEAD_LINES).join
52
+ tail = lines.last(TAIL_LINES).join
53
+ reason_text = reason ? " — #{reason}" : ""
54
+ notice = format(NOTICE, total: total, path: path, reason: reason_text)
55
+
56
+ "#{head}\n#{notice}\n\n#{tail}"
57
+ end
58
+
59
+ # Saves full content to a temp file that persists until system cleanup.
60
+ #
61
+ # @param content [String] the full tool result
62
+ # @return [String] absolute path to the saved file
63
+ def self.save_full_output(content)
64
+ file = Tempfile.create(["tool_result_", ".txt"])
65
+ file.write(content)
66
+ file.close
67
+ file.path
68
+ end
69
+ end
70
+ end
@@ -21,9 +21,9 @@ module Tools
21
21
 
22
22
  # Builds description dynamically to include available specialists.
23
23
  def self.description
24
- base = "Spawn a specialist to work on a task. " \
25
- "Its messages are forwarded to you. " \
26
- "Address it via @name to send follow-up instructions."
24
+ base = "Need a specific skill set for the job? Bring in a specialist. " \
25
+ "Its messages appear in yours; any message containing " \
26
+ "@nickname is forwarded even casual mentions will wake it."
27
27
 
28
28
  registry = Agents::Registry.instance
29
29
  return base unless registry.any?
@@ -85,7 +85,8 @@ module Tools
85
85
  nickname = child.name
86
86
  "Specialist @#{nickname} spawned (session #{child.id}). " \
87
87
  "Its messages will appear in your conversation. " \
88
- "Reply with @#{nickname} to send it instructions."
88
+ "Reply with @#{nickname} to send it instructions" \
89
+ "any message mentioning @#{nickname} is forwarded, even in narration."
89
90
  end
90
91
 
91
92
  private
@@ -96,7 +97,7 @@ module Tools
96
97
  prompt: build_prompt(definition),
97
98
  granted_tools: definition.tools
98
99
  )
99
- child.create_user_message(task)
100
+ pin_goal_and_frame(child, task)
100
101
  assign_nickname_via_brain(child)
101
102
  child.broadcast_children_update_to_parent
102
103
  AgentRequestJob.perform_later(child.id)
@@ -14,14 +14,15 @@ module Tools
14
14
  class SpawnSubagent < Base
15
15
  include SubagentPrompts
16
16
 
17
- GENERIC_PROMPT = "You are a focused sub-agent. #{COMMUNICATION_INSTRUCTION}\n"
17
+ GENERIC_PROMPT = "#{COMMUNICATION_INSTRUCTION}\n"
18
18
 
19
19
  def self.tool_name = "spawn_subagent"
20
20
 
21
21
  def self.description
22
- "Spawn a sub-agent to work on a task. " \
23
- "It inherits your conversation context and its messages are forwarded to you. " \
24
- "Address it via @nickname to send follow-up instructions."
22
+ "Task feels like a sidequest or a context-switch? Hand it off. " \
23
+ "Inherits your context; its messages appear in yours. " \
24
+ "Any message containing @nickname is forwarded " \
25
+ "even casual mentions will wake the sub-agent."
25
26
  end
26
27
 
27
28
  def self.input_schema
@@ -67,7 +68,8 @@ module Tools
67
68
  nickname = child.name
68
69
  "Sub-agent @#{nickname} spawned (session #{child.id}). " \
69
70
  "Its messages will appear in your conversation. " \
70
- "Reply with @#{nickname} to send it instructions."
71
+ "Reply with @#{nickname} to send it instructions" \
72
+ "any message mentioning @#{nickname} is forwarded, even in narration."
71
73
  end
72
74
 
73
75
  private
@@ -78,7 +80,7 @@ module Tools
78
80
  prompt: GENERIC_PROMPT,
79
81
  granted_tools: granted_tools
80
82
  )
81
- child.create_user_message(task)
83
+ pin_goal_and_frame(child, task)
82
84
  assign_nickname_via_brain(child)
83
85
  child.broadcast_children_update_to_parent
84
86
  AgentRequestJob.perform_later(child.id)
@@ -1,23 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tools
4
- # Shared prompt fragments and nickname logic for tools that spawn sub-agent sessions.
4
+ # Shared prompt fragments and spawn logic for tools that create sub-agent sessions.
5
5
  # Included by {SpawnSubagent} and {SpawnSpecialist} to avoid duplication.
6
6
  module SubagentPrompts
7
- COMMUNICATION_INSTRUCTION = "Your text messages are automatically forwarded to the parent agent. " \
8
- "When you finish, write your final summary and stop — no special tool needed. " \
9
- "If you need clarification, just ask the parent can reply."
7
+ # Prepended to every sub-agent's stored prompt after nickname assignment.
8
+ # Establishes identity before any other instruction.
9
+ IDENTITY_TEMPLATE = "You are @%s, a sub-agent of the primary agent."
10
+
11
+ COMMUNICATION_INSTRUCTION = "Your messages reach the parent automatically. " \
12
+ "Ask if you need clarification — the parent can reply."
13
+
14
+ # Framing message inserted as the sub-agent's first user message.
15
+ # This is the "brake" between inherited parent context and the sub-agent's
16
+ # own task — without it, the model continues the parent's trajectory.
17
+ FORK_FRAMING_MESSAGE = "You were spawned to help with a single task. " \
18
+ "The messages above are the parent agent's context — background for your work, " \
19
+ "but the parent's goals are not yours. " \
20
+ "Your sole task is described in your Goal."
10
21
 
11
22
  private
12
23
 
13
- # Runs the analytical brain synchronously to assign a nickname.
24
+ # Creates the sub-agent's Goal from the task description and inserts
25
+ # the framing message as the first user message.
26
+ #
27
+ # @param child [Session] the newly created child session
28
+ # @param task [String] the task description to pin as the sole Goal
29
+ # @return [void]
30
+ def pin_goal_and_frame(child, task)
31
+ child.goals.create!(description: task)
32
+ child.create_user_message(FORK_FRAMING_MESSAGE)
33
+ end
34
+
35
+ # Runs the analytical brain synchronously to assign a nickname,
36
+ # then prepends identity context to the stored prompt.
14
37
  # Falls back to a sequential "agent-N" name on any failure.
38
+ # Identity injection runs in +ensure+ so it applies to both
39
+ # brain-assigned and fallback nicknames.
15
40
  def assign_nickname_via_brain(child)
16
41
  AnalyticalBrain::Runner.new(child).call
17
42
  child.reload
18
43
  rescue => error
19
44
  Rails.logger.warn("Sub-agent nickname assignment failed: #{error.message}")
20
45
  child.update!(name: fallback_nickname)
46
+ ensure
47
+ inject_identity_context(child)
48
+ end
49
+
50
+ # Prepends identity context (nickname + sub-agent status) to the child's
51
+ # stored prompt. Called after nickname assignment so the sub-agent knows
52
+ # who it is from first token.
53
+ #
54
+ # @param child [Session] the child session with a nickname already set
55
+ # @return [void]
56
+ def inject_identity_context(child)
57
+ identity = format(IDENTITY_TEMPLATE, child.name)
58
+ child.update!(prompt: "#{identity}\n#{child.prompt}")
21
59
  end
22
60
 
23
61
  def fallback_nickname
data/lib/tools/think.rb CHANGED
@@ -13,6 +13,10 @@ module Tools
13
13
  # - **inner** (default) — silent reasoning, visible only in verbose/debug
14
14
  # - **aloud** — narration shown in all view modes with a thought bubble
15
15
  #
16
+ # The +maxLength+ on thoughts is controlled by +thinking_budget+ in settings.
17
+ # Sub-agents receive half the main agent's budget — their tasks are scoped
18
+ # and less complex, so runaway reasoning is a stronger signal of confusion.
19
+ #
16
20
  # @example Silent planning between tool calls
17
21
  # think(thoughts: "Three auth failures — likely a config issue, not individual tests.")
18
22
  #
@@ -23,6 +27,8 @@ module Tools
23
27
 
24
28
  def self.description = "Think out loud or silently."
25
29
 
30
+ # Schema is static — maxLength is injected at runtime by the registry
31
+ # via {#dynamic_schema} when session context is available.
26
32
  def self.input_schema
27
33
  {
28
34
  type: "object",
@@ -38,6 +44,23 @@ module Tools
38
44
  }
39
45
  end
40
46
 
47
+ # @param session [Session, nil] current session for budget calculation
48
+ def initialize(session: nil, **)
49
+ @session = session
50
+ end
51
+
52
+ # Returns the tool schema with a thinking budget applied as maxLength
53
+ # on the thoughts property. Sub-agents get half the budget.
54
+ #
55
+ # @return [Hash] Anthropic tool schema with maxLength constraint
56
+ def dynamic_schema
57
+ schema = self.class.schema.deep_dup
58
+ budget = Anima::Settings.thinking_budget
59
+ budget /= 2 if @session&.sub_agent?
60
+ schema[:input_schema][:properties][:thoughts][:maxLength] = budget
61
+ schema
62
+ end
63
+
41
64
  # @param input [Hash] with "thoughts" and optional "visibility"
42
65
  # @return [String] acknowledgement — the value is in the call, not the result
43
66
  def execute(input)
data/lib/tools/write.rb CHANGED
@@ -15,7 +15,7 @@ module Tools
15
15
  # tool.execute("path" => "README.md", "content" => "# Title\n")
16
16
  # # => "Wrote 9 bytes to /home/user/project/README.md"
17
17
  class Write < Base
18
- def self.tool_name = "write"
18
+ def self.tool_name = "write_file"
19
19
 
20
20
  def self.description = "Write file."
21
21
 
data/lib/tui/app.rb CHANGED
@@ -21,7 +21,7 @@ module TUI
21
21
  }.freeze
22
22
 
23
23
  MENU_LABELS = (COMMAND_KEYS.map { |key, action| "[#{key}] #{action.to_s.tr("_", " ").capitalize}" } +
24
- ["[\u2191] Scroll chat", "[\u2193] Return to input"]).freeze
24
+ ["[\u2191] Scroll chat", "[\u2193] Return to input", "[\u2192] Scroll HUD"]).freeze
25
25
 
26
26
  # HUD occupies 1/3 of screen width, clamped to a usable minimum.
27
27
  HUD_MIN_WIDTH = 24
@@ -78,10 +78,16 @@ module TUI
78
78
  # Grace period for watchdog thread to exit before force-killing it.
79
79
  WATCHDOG_SHUTDOWN_TIMEOUT = 1
80
80
 
81
+ # HUD scroll step sizes (lines per event).
82
+ HUD_SCROLL_STEP = 1
83
+ HUD_MOUSE_SCROLL_STEP = 2
84
+
81
85
  attr_reader :current_screen, :command_mode, :session_picker_active,
82
86
  :view_mode_picker_active
83
87
  # @return [Boolean] true when the HUD info panel is visible
84
88
  attr_reader :hud_visible
89
+ # @return [Boolean] true when the HUD pane has keyboard focus for scrolling
90
+ attr_reader :hud_focused
85
91
  # @return [Boolean] true when the token setup popup overlay is visible
86
92
  attr_reader :token_setup_active
87
93
  # @return [Boolean] true when graceful shutdown has been requested via signal
@@ -102,6 +108,11 @@ module TUI
102
108
  @session_picker_mode = :root
103
109
  @session_picker_parent_id = nil
104
110
  @hud_visible = true
111
+ @hud_focused = false
112
+ @hud_scroll_offset = 0
113
+ @hud_max_scroll = 0
114
+ @hud_visible_height = 0
115
+ @hud_content_area = nil
105
116
  @view_mode_picker_active = false
106
117
  @view_mode_picker_index = 0
107
118
  @token_setup_active = false
@@ -197,8 +208,13 @@ module TUI
197
208
  GOAL_ICON_ACTIVE = "\u25CF" # ●
198
209
  GOAL_ICON_IN_PROGRESS = "\u25D0" # ◐
199
210
  GOAL_ICON_COMPLETED = "\u2713" # ✓
200
- CHILD_ICON_RUNNING = "\u25CF" # ●
201
- CHILD_ICON_IDLE = "\u25CC" #
211
+
212
+ # Sub-agent state icons — form communicates type of work,
213
+ # color communicates status. Work independently.
214
+ CHILD_ICON_IDLE = "\u25CC" # ◌ hollow — nothing happening
215
+ CHILD_ICON_GENERATING = "\u25CF" # ● filled — LLM thinking
216
+ CHILD_ICON_TOOL_EXECUTING = "\u25C9" # ◉ dot-in-circle — tool running
217
+ CHILD_ICON_INTERRUPTING = "\u25CF" # ● filled — stopping
202
218
 
203
219
  def render_info(frame, area, tui)
204
220
  session = @screens[:chat].session_info
@@ -218,8 +234,10 @@ module TUI
218
234
  end
219
235
 
220
236
  # Renders the main HUD content: session name, goals, skills,
221
- # workflow, and sub-agents.
237
+ # workflow, and sub-agents. Supports vertical scrolling with
238
+ # a scrollbar when content exceeds the visible area.
222
239
  def render_hud_content(frame, area, tui, session)
240
+ @hud_content_area = area
223
241
  session_label = session[:name] || "##{session[:id]}"
224
242
 
225
243
  lines = [
@@ -234,16 +252,42 @@ module TUI
234
252
  interaction_state_line(tui)
235
253
  ].flatten.compact
236
254
 
255
+ @hud_visible_height = [area.height - 2, 0].max
256
+ inner_width = [area.width - 2, 1].max
257
+ total_height = tui.paragraph(text: lines, wrap: true).line_count(inner_width)
258
+ @hud_max_scroll = [total_height - @hud_visible_height, 0].max
259
+ @hud_scroll_offset = @hud_scroll_offset.clamp(0, @hud_max_scroll)
260
+
261
+ border_color = @hud_focused ? "yellow" : "white"
262
+
237
263
  content = tui.paragraph(
238
264
  text: lines,
239
265
  wrap: true,
266
+ scroll: [@hud_scroll_offset, 0],
240
267
  block: tui.block(
241
268
  borders: [:left, :top, :right],
242
269
  border_type: :rounded,
243
- border_style: {fg: "white"}
270
+ border_style: {fg: border_color}
244
271
  )
245
272
  )
246
273
  frame.render_widget(content, area)
274
+
275
+ render_hud_scrollbar(frame, area, tui)
276
+ end
277
+
278
+ # Renders a scrollbar on the right edge of the HUD when content overflows.
279
+ def render_hud_scrollbar(frame, area, tui)
280
+ return unless @hud_max_scroll > 0
281
+
282
+ scrollbar = tui.scrollbar(
283
+ content_length: @hud_max_scroll,
284
+ position: @hud_scroll_offset,
285
+ orientation: :vertical_right,
286
+ thumb_style: {fg: "cyan"},
287
+ track_symbol: "\u2502",
288
+ track_style: {fg: "dark_gray"}
289
+ )
290
+ frame.render_widget(scrollbar, area)
247
291
  end
248
292
 
249
293
  # Renders the bottom status bar: connection state and model name.
@@ -382,26 +426,44 @@ module TUI
382
426
  end
383
427
 
384
428
  # Returns the activity icon and color for a child session.
429
+ # Form communicates type of work, color communicates status —
430
+ # they work independently so even without color the shape tells
431
+ # the story.
385
432
  #
386
- # @param child [Hash] child session data with "processing" key
433
+ # @param child [Hash] child session data with "session_state" key
387
434
  # @return [Array(String, String)] icon and color pair
388
435
  def child_icon_and_color(child)
389
- if child["processing"]
390
- [CHILD_ICON_RUNNING, "yellow"]
436
+ case child["session_state"]
437
+ when "llm_generating"
438
+ [CHILD_ICON_GENERATING, "green"]
439
+ when "tool_executing"
440
+ [CHILD_ICON_TOOL_EXECUTING, "green"]
441
+ when "interrupting"
442
+ [CHILD_ICON_INTERRUPTING, "red"]
391
443
  else
392
- [CHILD_ICON_IDLE, "green"]
444
+ [CHILD_ICON_IDLE, "dark_gray"]
393
445
  end
394
446
  end
395
447
 
396
- # Shows "Scrolling" when chat pane is focused, "Thinking..." during LLM processing.
448
+ # Shows focus mode when a pane is focused, or the braille spinner
449
+ # during active processing.
397
450
  def interaction_state_line(tui)
398
- if @screens[:chat].chat_focused
451
+ if @hud_focused
452
+ tui.line(spans: [
453
+ tui.span(content: "HUD Scroll", style: tui.style(fg: "yellow", modifiers: [:bold]))
454
+ ])
455
+ elsif @screens[:chat].chat_focused
399
456
  tui.line(spans: [
400
457
  tui.span(content: "Scrolling", style: tui.style(fg: "yellow", modifiers: [:bold]))
401
458
  ])
402
459
  elsif chat_loading?
460
+ chat = @screens[:chat]
461
+ char = chat.spinner.tick || "\u2800"
462
+ color = chat.spinner_color
463
+ label = chat.spinner_label
403
464
  tui.line(spans: [
404
- tui.span(content: "Thinking...", style: tui.style(fg: "magenta", modifiers: [:bold]))
465
+ tui.span(content: "#{char} ", style: tui.style(fg: color, modifiers: [:bold])),
466
+ tui.span(content: label, style: tui.style(fg: color))
405
467
  ])
406
468
  end
407
469
  end
@@ -410,6 +472,38 @@ module TUI
410
472
  @screens[:chat].loading?
411
473
  end
412
474
 
475
+ # Switches keyboard focus to the HUD pane for scrolling.
476
+ # Unfocuses the chat pane if it was focused.
477
+ #
478
+ # @return [void]
479
+ def focus_hud
480
+ @screens[:chat].unfocus_chat if @screens[:chat].chat_focused
481
+ @hud_focused = true
482
+ end
483
+
484
+ # Returns keyboard focus from the HUD pane.
485
+ #
486
+ # @return [void]
487
+ def unfocus_hud
488
+ @hud_focused = false
489
+ end
490
+
491
+ # Scrolls the HUD viewport up, clamping at the top.
492
+ #
493
+ # @param lines [Integer] number of lines to scroll
494
+ # @return [void]
495
+ def scroll_hud_up(lines)
496
+ @hud_scroll_offset = [@hud_scroll_offset - lines, 0].max
497
+ end
498
+
499
+ # Scrolls the HUD viewport down, clamping at max_scroll.
500
+ #
501
+ # @param lines [Integer] number of lines to scroll
502
+ # @return [void]
503
+ def scroll_hud_down(lines)
504
+ @hud_scroll_offset = [@hud_scroll_offset + lines, @hud_max_scroll].min
505
+ end
506
+
413
507
  def handle_event(event)
414
508
  return nil if event.none?
415
509
  return :quit if event.ctrl_c?
@@ -422,6 +516,8 @@ module TUI
422
516
  handle_view_mode_picker(event)
423
517
  elsif @command_mode
424
518
  handle_command_mode(event)
519
+ elsif @hud_focused
520
+ handle_hud_focused_event(event)
425
521
  else
426
522
  handle_normal_mode(event)
427
523
  end
@@ -442,6 +538,11 @@ module TUI
442
538
  return nil
443
539
  end
444
540
 
541
+ if event.right? && @hud_visible
542
+ focus_hud
543
+ return nil
544
+ end
545
+
445
546
  action = COMMAND_KEYS[event.code]
446
547
  case action
447
548
  when :quit
@@ -451,6 +552,7 @@ module TUI
451
552
  nil
452
553
  when :toggle_hud
453
554
  @hud_visible = !@hud_visible
555
+ unfocus_hud if !@hud_visible
454
556
  nil
455
557
  when :new_session
456
558
  @screens[:chat].new_session
@@ -466,7 +568,13 @@ module TUI
466
568
  end
467
569
 
468
570
  def handle_normal_mode(event)
469
- if event.mouse? || event.paste?
571
+ if event.paste?
572
+ delegate_to_screen(event)
573
+ return nil
574
+ end
575
+
576
+ if event.mouse?
577
+ return nil if route_mouse_to_hud(event)
470
578
  delegate_to_screen(event)
471
579
  return nil
472
580
  end
@@ -497,6 +605,63 @@ module TUI
497
605
  nil
498
606
  end
499
607
 
608
+ # Handles keyboard events when the HUD pane has focus.
609
+ # Arrow keys and Page Up/Down scroll the HUD; Escape and Ctrl+A exit.
610
+ def handle_hud_focused_event(event)
611
+ return nil if event.none?
612
+ return :quit if event.ctrl_c?
613
+
614
+ if event.mouse?
615
+ return nil if route_mouse_to_hud(event)
616
+ delegate_to_screen(event)
617
+ return nil
618
+ end
619
+
620
+ return nil unless event.key?
621
+
622
+ if event.esc? || ctrl_a?(event)
623
+ unfocus_hud
624
+ return nil
625
+ end
626
+
627
+ if event.up?
628
+ scroll_hud_up(HUD_SCROLL_STEP)
629
+ elsif event.down?
630
+ scroll_hud_down(HUD_SCROLL_STEP)
631
+ elsif event.page_up?
632
+ scroll_hud_up(@hud_visible_height)
633
+ elsif event.page_down?
634
+ scroll_hud_down(@hud_visible_height)
635
+ elsif event.home?
636
+ scroll_hud_up(@hud_max_scroll)
637
+ elsif event.end?
638
+ scroll_hud_down(@hud_max_scroll)
639
+ end
640
+ nil
641
+ end
642
+
643
+ # Routes mouse scroll events to the HUD when the cursor is over the HUD area.
644
+ # @return [Boolean] true if the event was handled by the HUD
645
+ def route_mouse_to_hud(event)
646
+ return false if !@hud_visible || !@hud_content_area
647
+ return false unless mouse_over_hud?(event)
648
+ return false unless event.scroll_up? || event.scroll_down?
649
+
650
+ if event.scroll_up?
651
+ scroll_hud_up(HUD_MOUSE_SCROLL_STEP)
652
+ else
653
+ scroll_hud_down(HUD_MOUSE_SCROLL_STEP)
654
+ end
655
+ true
656
+ end
657
+
658
+ # Checks whether a mouse event's coordinates fall within the HUD content area.
659
+ def mouse_over_hud?(event)
660
+ area = @hud_content_area
661
+ event.x >= area.x && event.x < area.x + area.width &&
662
+ event.y >= area.y && event.y < area.y + area.height
663
+ end
664
+
500
665
  # Switches to the parent session when viewing a child (sub-agent) session.
501
666
  # No-op if the current session is a root session.
502
667
  #