anima-core 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +8 -1
- data/README.md +36 -11
- data/agents/codebase-analyzer.md +1 -1
- data/agents/codebase-pattern-finder.md +1 -1
- data/agents/documentation-researcher.md +1 -1
- data/agents/thoughts-analyzer.md +1 -1
- data/agents/web-search-researcher.md +2 -2
- data/app/channels/session_channel.rb +53 -35
- data/app/decorators/tool_call_decorator.rb +4 -4
- data/app/decorators/user_message_decorator.rb +3 -17
- data/app/jobs/agent_request_job.rb +13 -4
- data/app/models/goal.rb +13 -0
- data/app/models/message.rb +13 -18
- data/app/models/pending_message.rb +43 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +194 -43
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/lib/agent_loop.rb +13 -40
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +7 -4
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/installer.rb +7 -1
- data/lib/anima/settings.rb +31 -2
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/base.rb +1 -1
- data/lib/events/subscribers/persister.rb +11 -18
- data/lib/events/subscribers/subagent_message_router.rb +20 -8
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +54 -20
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +1 -1
- data/lib/shell_session.rb +54 -16
- data/lib/tools/base.rb +23 -0
- data/lib/tools/bash.rb +56 -4
- data/lib/tools/edit.rb +2 -2
- data/lib/tools/mark_goal_completed.rb +86 -0
- data/lib/tools/read.rb +2 -1
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +36 -7
- data/lib/tools/remember.rb +1 -1
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +6 -5
- data/lib/tools/spawn_subagent.rb +8 -6
- data/lib/tools/subagent_prompts.rb +43 -5
- data/lib/tools/think.rb +23 -0
- data/lib/tools/write.rb +1 -1
- data/lib/tui/app.rb +178 -13
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +4 -4
- data/lib/tui/decorators/base_decorator.rb +17 -8
- data/lib/tui/decorators/bash_decorator.rb +2 -2
- data/lib/tui/decorators/edit_decorator.rb +5 -4
- data/lib/tui/decorators/read_decorator.rb +4 -8
- data/lib/tui/decorators/think_decorator.rb +3 -5
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +5 -4
- data/lib/tui/flash.rb +1 -1
- data/lib/tui/formatting.rb +22 -0
- data/lib/tui/message_store.rb +70 -26
- data/lib/tui/screens/chat.rb +269 -66
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +26 -0
- metadata +11 -1
|
@@ -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 = "
|
|
25
|
-
"Its messages
|
|
26
|
-
"
|
|
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
|
|
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)
|
data/lib/tools/spawn_subagent.rb
CHANGED
|
@@ -14,14 +14,15 @@ module Tools
|
|
|
14
14
|
class SpawnSubagent < Base
|
|
15
15
|
include SubagentPrompts
|
|
16
16
|
|
|
17
|
-
GENERIC_PROMPT = "
|
|
17
|
+
GENERIC_PROMPT = "#{COMMUNICATION_INSTRUCTION}\n"
|
|
18
18
|
|
|
19
19
|
def self.tool_name = "spawn_subagent"
|
|
20
20
|
|
|
21
21
|
def self.description
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
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
|
|
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
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
#
|
|
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 = "
|
|
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
|
-
|
|
201
|
-
|
|
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:
|
|
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 "
|
|
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
|
-
|
|
390
|
-
|
|
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, "
|
|
444
|
+
[CHILD_ICON_IDLE, "dark_gray"]
|
|
393
445
|
end
|
|
394
446
|
end
|
|
395
447
|
|
|
396
|
-
# Shows
|
|
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 @
|
|
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: "
|
|
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.
|
|
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
|
#
|