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/app.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
|
+
require_relative "settings"
|
|
4
5
|
require_relative "cable_client"
|
|
5
6
|
require_relative "input_buffer"
|
|
6
7
|
require_relative "message_store"
|
|
@@ -21,10 +22,7 @@ module TUI
|
|
|
21
22
|
}.freeze
|
|
22
23
|
|
|
23
24
|
MENU_LABELS = (COMMAND_KEYS.map { |key, action| "[#{key}] #{action.to_s.tr("_", " ").capitalize}" } +
|
|
24
|
-
["[\u2191] Scroll chat", "[\u2193] Return to input"]).freeze
|
|
25
|
-
|
|
26
|
-
# HUD occupies 1/3 of screen width, clamped to a usable minimum.
|
|
27
|
-
HUD_MIN_WIDTH = 24
|
|
25
|
+
["[\u2191] Scroll chat", "[\u2193] Return to input", "[\u2192] Sub-agents / HUD"]).freeze
|
|
28
26
|
|
|
29
27
|
# Picker entry prefix width: "[N]" (3) + marker (1) + space (1) = 5
|
|
30
28
|
PICKER_PREFIX_WIDTH = 5
|
|
@@ -38,28 +36,22 @@ module TUI
|
|
|
38
36
|
|
|
39
37
|
# Connection status emoji indicators for the info panel.
|
|
40
38
|
# Subscribed (normal state) shows only the emoji; other states add text.
|
|
41
|
-
|
|
42
|
-
disconnected:
|
|
43
|
-
connecting:
|
|
44
|
-
connected:
|
|
45
|
-
subscribed:
|
|
46
|
-
reconnecting:
|
|
39
|
+
STATUS_LABELS = {
|
|
40
|
+
disconnected: "🔴 Disconnected",
|
|
41
|
+
connecting: "🟡 Connecting",
|
|
42
|
+
connected: "🟡 Connecting",
|
|
43
|
+
subscribed: "🟢",
|
|
44
|
+
reconnecting: "🟡 Reconnecting"
|
|
47
45
|
}.freeze
|
|
48
46
|
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# Token setup popup dimensions. Height accommodates: status line, blank,
|
|
59
|
-
# 2 instruction lines, blank, "Token:" label, input line, blank,
|
|
60
|
-
# error/success line, blank, hint line, plus top/bottom borders.
|
|
61
|
-
POPUP_HEIGHT = 14
|
|
62
|
-
POPUP_MIN_WIDTH = 44
|
|
47
|
+
# Maps connection status to semantic theme color.
|
|
48
|
+
STATUS_COLORS = {
|
|
49
|
+
disconnected: :theme_color_error,
|
|
50
|
+
connecting: :theme_color_warning,
|
|
51
|
+
connected: :theme_color_warning,
|
|
52
|
+
subscribed: :theme_color_success,
|
|
53
|
+
reconnecting: :theme_color_warning
|
|
54
|
+
}.freeze
|
|
63
55
|
|
|
64
56
|
# Matches a single printable Unicode character (no control codes).
|
|
65
57
|
PRINTABLE_CHAR = /\A[[:print:]]\z/
|
|
@@ -67,21 +59,18 @@ module TUI
|
|
|
67
59
|
# Signals that trigger graceful shutdown when received from the OS.
|
|
68
60
|
SHUTDOWN_SIGNALS = %w[HUP TERM INT].freeze
|
|
69
61
|
|
|
70
|
-
# How often the watchdog thread checks if the controlling terminal is alive.
|
|
71
|
-
# @see #terminal_watchdog_loop
|
|
72
|
-
TERMINAL_CHECK_INTERVAL = 0.5
|
|
73
|
-
|
|
74
62
|
# Unix controlling terminal device path.
|
|
75
63
|
# @see #terminal_watchdog_loop
|
|
76
64
|
CONTROLLING_TERMINAL = "/dev/tty"
|
|
77
65
|
|
|
78
|
-
# Grace period for watchdog thread to exit before force-killing it.
|
|
79
|
-
WATCHDOG_SHUTDOWN_TIMEOUT = 1
|
|
80
|
-
|
|
81
66
|
attr_reader :current_screen, :command_mode, :session_picker_active,
|
|
82
67
|
:view_mode_picker_active
|
|
83
68
|
# @return [Boolean] true when the HUD info panel is visible
|
|
84
69
|
attr_reader :hud_visible
|
|
70
|
+
# @return [Boolean] true when the HUD pane has keyboard focus for scrolling
|
|
71
|
+
attr_reader :hud_focused
|
|
72
|
+
# @return [Integer, nil] index of selected child in HUD navigation, nil when inactive
|
|
73
|
+
attr_reader :hud_child_index
|
|
85
74
|
# @return [Boolean] true when the token setup popup overlay is visible
|
|
86
75
|
attr_reader :token_setup_active
|
|
87
76
|
# @return [Boolean] true when graceful shutdown has been requested via signal
|
|
@@ -102,6 +91,12 @@ module TUI
|
|
|
102
91
|
@session_picker_mode = :root
|
|
103
92
|
@session_picker_parent_id = nil
|
|
104
93
|
@hud_visible = true
|
|
94
|
+
@hud_focused = false
|
|
95
|
+
@hud_child_index = nil
|
|
96
|
+
@hud_scroll_offset = 0
|
|
97
|
+
@hud_max_scroll = 0
|
|
98
|
+
@hud_visible_height = 0
|
|
99
|
+
@hud_content_area = nil
|
|
105
100
|
@view_mode_picker_active = false
|
|
106
101
|
@view_mode_picker_index = 0
|
|
107
102
|
@token_setup_active = false
|
|
@@ -146,7 +141,7 @@ module TUI
|
|
|
146
141
|
@screens[:chat].hud_hint = !@hud_visible
|
|
147
142
|
|
|
148
143
|
if @hud_visible
|
|
149
|
-
hud_width = [frame.area.width / 3,
|
|
144
|
+
hud_width = [frame.area.width / 3, Settings.hud_min_width].max
|
|
150
145
|
content_area, sidebar = tui.split(
|
|
151
146
|
frame.area,
|
|
152
147
|
direction: :horizontal,
|
|
@@ -187,7 +182,7 @@ module TUI
|
|
|
187
182
|
title: "Command",
|
|
188
183
|
borders: [:all],
|
|
189
184
|
border_type: :rounded,
|
|
190
|
-
border_style: {fg:
|
|
185
|
+
border_style: {fg: Settings.theme_border_focused}
|
|
191
186
|
)
|
|
192
187
|
)
|
|
193
188
|
frame.render_widget(menu, area)
|
|
@@ -197,35 +192,58 @@ module TUI
|
|
|
197
192
|
GOAL_ICON_ACTIVE = "\u25CF" # ●
|
|
198
193
|
GOAL_ICON_IN_PROGRESS = "\u25D0" # ◐
|
|
199
194
|
GOAL_ICON_COMPLETED = "\u2713" # ✓
|
|
200
|
-
|
|
201
|
-
|
|
195
|
+
|
|
196
|
+
# Sub-agent state icons — form communicates type of work,
|
|
197
|
+
# color communicates status. Work independently.
|
|
198
|
+
CHILD_ICON_IDLE = "\u25CC" # ◌ hollow — nothing happening
|
|
199
|
+
CHILD_ICON_GENERATING = "\u25CF" # ● filled — LLM thinking
|
|
200
|
+
CHILD_ICON_TOOL_EXECUTING = "\u25C9" # ◉ dot-in-circle — tool running
|
|
201
|
+
CHILD_ICON_INTERRUPTING = "\u25CF" # ● filled — stopping
|
|
202
202
|
|
|
203
203
|
def render_info(frame, area, tui)
|
|
204
204
|
session = @screens[:chat].session_info
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
205
|
+
stats = @screens[:chat].message_store.token_economy
|
|
206
|
+
has_metrics = stats[:call_count] > 0 || stats[:rate_limits]
|
|
207
|
+
|
|
208
|
+
# Split into main content, optional token economy panel, and status bar
|
|
209
|
+
if has_metrics
|
|
210
|
+
economy_height = token_economy_line_count(stats) + 1 # +1 for top border
|
|
211
|
+
main_area, economy_area, status_area = tui.split(
|
|
212
|
+
area,
|
|
213
|
+
direction: :vertical,
|
|
214
|
+
constraints: [
|
|
215
|
+
tui.constraint_fill(1),
|
|
216
|
+
tui.constraint_length(economy_height),
|
|
217
|
+
tui.constraint_length(2)
|
|
218
|
+
]
|
|
219
|
+
)
|
|
220
|
+
render_hud_content(frame, main_area, tui, session)
|
|
221
|
+
render_token_economy_panel(frame, economy_area, tui, stats)
|
|
222
|
+
else
|
|
223
|
+
main_area, status_area = tui.split(
|
|
224
|
+
area,
|
|
225
|
+
direction: :vertical,
|
|
226
|
+
constraints: [
|
|
227
|
+
tui.constraint_fill(1),
|
|
228
|
+
tui.constraint_length(2)
|
|
229
|
+
]
|
|
230
|
+
)
|
|
231
|
+
render_hud_content(frame, main_area, tui, session)
|
|
232
|
+
end
|
|
217
233
|
render_hud_status_bar(frame, status_area, 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 = [
|
|
226
244
|
tui.line(spans: [
|
|
227
|
-
tui.span(content: "\u{1F4CB} ", style: tui.style(fg:
|
|
228
|
-
tui.span(content: session_label, style: tui.style(fg:
|
|
245
|
+
tui.span(content: "\u{1F4CB} ", style: tui.style(fg: Settings.theme_color_muted)),
|
|
246
|
+
tui.span(content: session_label, style: tui.style(fg: Settings.theme_color_info, modifiers: [:bold]))
|
|
229
247
|
]),
|
|
230
248
|
hud_goals_section(tui, session),
|
|
231
249
|
hud_skills_line(tui, session),
|
|
@@ -234,51 +252,79 @@ 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 ? Settings.theme_border_focused : Settings.theme_border_normal
|
|
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: Settings.theme_scrollbar_thumb},
|
|
287
|
+
track_symbol: "\u2502",
|
|
288
|
+
track_style: {fg: Settings.theme_scrollbar_track}
|
|
289
|
+
)
|
|
290
|
+
frame.render_widget(scrollbar, area)
|
|
247
291
|
end
|
|
248
292
|
|
|
249
293
|
# Renders the bottom status bar: connection state and model name.
|
|
250
294
|
def render_hud_status_bar(frame, area, tui)
|
|
251
295
|
cable_status = @cable_client.status
|
|
252
|
-
|
|
296
|
+
label = STATUS_LABELS.fetch(cable_status, STATUS_LABELS[:disconnected])
|
|
297
|
+
status_color = Settings.public_send(STATUS_COLORS.fetch(cable_status, :theme_color_error))
|
|
253
298
|
|
|
254
299
|
status_label = if cable_status == :reconnecting
|
|
255
300
|
attempt = @cable_client.reconnect_attempt
|
|
256
|
-
max =
|
|
257
|
-
"#{
|
|
301
|
+
max = Settings.connection_max_reconnect_attempts
|
|
302
|
+
"#{label} (#{attempt}/#{max})"
|
|
258
303
|
else
|
|
259
|
-
|
|
304
|
+
label
|
|
260
305
|
end
|
|
261
306
|
|
|
262
307
|
view_mode = @screens[:chat].view_mode
|
|
263
308
|
mode_label = view_mode.capitalize
|
|
264
309
|
mode_color = case view_mode
|
|
265
|
-
when "verbose" then
|
|
266
|
-
when "debug" then
|
|
267
|
-
else
|
|
310
|
+
when "verbose" then Settings.theme_color_warning
|
|
311
|
+
when "debug" then Settings.theme_color_accent
|
|
312
|
+
else Settings.theme_color_info
|
|
268
313
|
end
|
|
269
314
|
|
|
270
315
|
bar = tui.paragraph(
|
|
271
316
|
text: [
|
|
272
317
|
tui.line(spans: [
|
|
273
|
-
tui.span(content:
|
|
274
|
-
tui.span(content:
|
|
318
|
+
tui.span(content: " ", style: tui.style(fg: Settings.theme_color_muted)),
|
|
319
|
+
tui.span(content: status_label, style: tui.style(fg: status_color, modifiers: [:bold])),
|
|
320
|
+
tui.span(content: " ", style: tui.style(fg: Settings.theme_color_muted)),
|
|
275
321
|
tui.span(content: mode_label, style: tui.style(fg: mode_color, modifiers: [:bold]))
|
|
276
322
|
])
|
|
277
323
|
],
|
|
278
324
|
block: tui.block(
|
|
279
325
|
borders: [:left, :bottom, :right],
|
|
280
326
|
border_type: :rounded,
|
|
281
|
-
border_style: {fg:
|
|
327
|
+
border_style: {fg: Settings.theme_border_normal}
|
|
282
328
|
)
|
|
283
329
|
)
|
|
284
330
|
frame.render_widget(bar, area)
|
|
@@ -296,7 +342,7 @@ module TUI
|
|
|
296
342
|
lines = [
|
|
297
343
|
tui.line(spans: [tui.span(content: "")]),
|
|
298
344
|
tui.line(spans: [
|
|
299
|
-
tui.span(content: "\u{1F3AF} Goals", style: tui.style(fg:
|
|
345
|
+
tui.span(content: "\u{1F3AF} Goals", style: tui.style(fg: Settings.theme_color_muted))
|
|
300
346
|
])
|
|
301
347
|
]
|
|
302
348
|
|
|
@@ -304,7 +350,7 @@ module TUI
|
|
|
304
350
|
icon, color = goal_icon_and_color(goal)
|
|
305
351
|
lines << tui.line(spans: [
|
|
306
352
|
tui.span(content: " #{icon} ", style: tui.style(fg: color)),
|
|
307
|
-
tui.span(content: goal["description"].to_s, style: tui.style(fg:
|
|
353
|
+
tui.span(content: goal["description"].to_s, style: tui.style(fg: Settings.theme_color_text))
|
|
308
354
|
])
|
|
309
355
|
end
|
|
310
356
|
|
|
@@ -318,11 +364,11 @@ module TUI
|
|
|
318
364
|
# @return [Array(String, String)] icon and color pair
|
|
319
365
|
def goal_icon_and_color(goal)
|
|
320
366
|
if goal["status"] == "completed"
|
|
321
|
-
[GOAL_ICON_COMPLETED,
|
|
367
|
+
[GOAL_ICON_COMPLETED, Settings.theme_color_success]
|
|
322
368
|
elsif goal["sub_goals"]&.any? { |sg| sg["status"] == "completed" }
|
|
323
|
-
[GOAL_ICON_IN_PROGRESS,
|
|
369
|
+
[GOAL_ICON_IN_PROGRESS, Settings.theme_color_warning]
|
|
324
370
|
else
|
|
325
|
-
[GOAL_ICON_ACTIVE,
|
|
371
|
+
[GOAL_ICON_ACTIVE, Settings.theme_color_info]
|
|
326
372
|
end
|
|
327
373
|
end
|
|
328
374
|
|
|
@@ -335,8 +381,8 @@ module TUI
|
|
|
335
381
|
[
|
|
336
382
|
tui.line(spans: [tui.span(content: "")]),
|
|
337
383
|
tui.line(spans: [
|
|
338
|
-
tui.span(content: "\u{1F9E0} ", style: tui.style(fg:
|
|
339
|
-
tui.span(content: skills.join(", "), style: tui.style(fg:
|
|
384
|
+
tui.span(content: "\u{1F9E0} ", style: tui.style(fg: Settings.theme_color_muted)),
|
|
385
|
+
tui.span(content: skills.join(", "), style: tui.style(fg: Settings.theme_color_warning))
|
|
340
386
|
])
|
|
341
387
|
]
|
|
342
388
|
end
|
|
@@ -350,58 +396,273 @@ module TUI
|
|
|
350
396
|
[
|
|
351
397
|
tui.line(spans: [tui.span(content: "")]),
|
|
352
398
|
tui.line(spans: [
|
|
353
|
-
tui.span(content: "\u{1F4DC} ", style: tui.style(fg:
|
|
354
|
-
tui.span(content: workflow, style: tui.style(fg:
|
|
399
|
+
tui.span(content: "\u{1F4DC} ", style: tui.style(fg: Settings.theme_color_muted)),
|
|
400
|
+
tui.span(content: workflow, style: tui.style(fg: Settings.theme_color_accent))
|
|
355
401
|
])
|
|
356
402
|
]
|
|
357
403
|
end
|
|
358
404
|
|
|
359
405
|
# Builds the sub-agents section with activity indicators.
|
|
406
|
+
# Highlights the selected child when HUD child navigation is active.
|
|
360
407
|
# @return [Array<RatatuiRuby::Widgets::Line>, nil]
|
|
361
408
|
def hud_children_section(tui, session)
|
|
362
409
|
children = session[:children]
|
|
363
410
|
return if children.nil? || children.empty?
|
|
364
411
|
|
|
412
|
+
header_label = @hud_child_index ? "Sub-agents [\u2190 back]" : "Sub-agents"
|
|
413
|
+
|
|
365
414
|
lines = [
|
|
366
415
|
tui.line(spans: [tui.span(content: "")]),
|
|
367
416
|
tui.line(spans: [
|
|
368
|
-
tui.span(content: "\u{1F465}
|
|
417
|
+
tui.span(content: "\u{1F465} #{header_label}", style: tui.style(fg: Settings.theme_color_muted))
|
|
369
418
|
])
|
|
370
419
|
]
|
|
371
420
|
|
|
372
|
-
children.
|
|
421
|
+
children.each_with_index do |child, idx|
|
|
373
422
|
icon, color = child_icon_and_color(child)
|
|
374
423
|
name = child["name"] || "sub-agent"
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
424
|
+
selected = idx == @hud_child_index
|
|
425
|
+
|
|
426
|
+
lines << if selected
|
|
427
|
+
tui.line(spans: [
|
|
428
|
+
tui.span(content: "\u25B8 #{icon} ", style: tui.style(fg: Settings.theme_color_info)),
|
|
429
|
+
tui.span(content: "@#{name}", style: tui.style(fg: Settings.theme_color_info, modifiers: [:bold]))
|
|
430
|
+
])
|
|
431
|
+
else
|
|
432
|
+
tui.line(spans: [
|
|
433
|
+
tui.span(content: " #{icon} ", style: tui.style(fg: color)),
|
|
434
|
+
tui.span(content: "@#{name}", style: tui.style(fg: Settings.theme_color_text))
|
|
435
|
+
])
|
|
436
|
+
end
|
|
379
437
|
end
|
|
380
438
|
|
|
381
439
|
lines
|
|
382
440
|
end
|
|
383
441
|
|
|
384
442
|
# Returns the activity icon and color for a child session.
|
|
443
|
+
# Form communicates type of work, color communicates status —
|
|
444
|
+
# they work independently so even without color the shape tells
|
|
445
|
+
# the story.
|
|
385
446
|
#
|
|
386
|
-
# @param child [Hash] child session data with "
|
|
447
|
+
# @param child [Hash] child session data with "session_state" key
|
|
387
448
|
# @return [Array(String, String)] icon and color pair
|
|
388
449
|
def child_icon_and_color(child)
|
|
389
|
-
|
|
390
|
-
|
|
450
|
+
case child["session_state"]
|
|
451
|
+
when "llm_generating"
|
|
452
|
+
[CHILD_ICON_GENERATING, Settings.theme_color_success]
|
|
453
|
+
when "tool_executing"
|
|
454
|
+
[CHILD_ICON_TOOL_EXECUTING, Settings.theme_color_success]
|
|
455
|
+
when "interrupting"
|
|
456
|
+
[CHILD_ICON_INTERRUPTING, Settings.theme_color_error]
|
|
391
457
|
else
|
|
392
|
-
[CHILD_ICON_IDLE,
|
|
458
|
+
[CHILD_ICON_IDLE, Settings.theme_color_muted]
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Renders the Token Economy panel as a fixed section between HUD content
|
|
463
|
+
# and the status bar.
|
|
464
|
+
def render_token_economy_panel(frame, area, tui, stats)
|
|
465
|
+
lines = []
|
|
466
|
+
|
|
467
|
+
rate_limits = stats[:rate_limits]
|
|
468
|
+
lines.concat(build_rate_limit_lines(tui, rate_limits)) if rate_limits
|
|
469
|
+
lines.concat(build_cache_metrics_lines(tui, stats)) if stats[:call_count] > 0
|
|
470
|
+
|
|
471
|
+
panel = tui.paragraph(
|
|
472
|
+
text: lines,
|
|
473
|
+
block: tui.block(
|
|
474
|
+
title: " \u{1F4CA} Token Economy ",
|
|
475
|
+
borders: [:left, :top, :right],
|
|
476
|
+
border_type: :rounded,
|
|
477
|
+
border_style: {fg: Settings.theme_border_normal}
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
frame.render_widget(panel, area)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Returns the number of content lines the token economy panel will render.
|
|
484
|
+
def token_economy_line_count(stats)
|
|
485
|
+
count = 0
|
|
486
|
+
if stats[:rate_limits]
|
|
487
|
+
count += 1 if stats[:rate_limits]["5h_utilization"]
|
|
488
|
+
count += 1 if stats[:rate_limits]["7d_utilization"]
|
|
489
|
+
end
|
|
490
|
+
if stats[:call_count] > 0
|
|
491
|
+
count += 2 # cache hit bar + saved
|
|
492
|
+
count += 1 if stats[:cache_history]&.size.to_i > 1 # sparkline
|
|
393
493
|
end
|
|
494
|
+
count
|
|
394
495
|
end
|
|
395
496
|
|
|
396
|
-
#
|
|
497
|
+
# Builds rate limit display lines with progress bars and pacing.
|
|
498
|
+
# Compact aligned labels: 5h, 7d (same 2-char width).
|
|
499
|
+
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
500
|
+
def build_rate_limit_lines(tui, rate_limits)
|
|
501
|
+
lines = []
|
|
502
|
+
|
|
503
|
+
# 5-hour window
|
|
504
|
+
util_5h = rate_limits["5h_utilization"]
|
|
505
|
+
if util_5h
|
|
506
|
+
pct = (util_5h * 100).round
|
|
507
|
+
bar = build_progress_bar(pct, Settings.theme_progress_bar_width)
|
|
508
|
+
color = rate_limit_color(pct)
|
|
509
|
+
reset_5h = rate_limits["5h_reset"]
|
|
510
|
+
reset_label = reset_5h ? " #{format_reset_time(reset_5h)}" : ""
|
|
511
|
+
|
|
512
|
+
lines << tui.line(spans: [
|
|
513
|
+
tui.span(content: " 5h ", style: tui.style(fg: color, modifiers: [:bold])),
|
|
514
|
+
tui.span(content: bar, style: tui.style(fg: color)),
|
|
515
|
+
tui.span(content: " #{pct.to_s.rjust(2)}%", style: tui.style(fg: color)),
|
|
516
|
+
tui.span(content: reset_label, style: tui.style(fg: Settings.theme_color_muted))
|
|
517
|
+
])
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# 7-day window
|
|
521
|
+
util_7d = rate_limits["7d_utilization"]
|
|
522
|
+
if util_7d
|
|
523
|
+
pct = (util_7d * 100).round
|
|
524
|
+
bar = build_progress_bar(pct, Settings.theme_progress_bar_width)
|
|
525
|
+
color = rate_limit_color(pct)
|
|
526
|
+
|
|
527
|
+
lines << tui.line(spans: [
|
|
528
|
+
tui.span(content: " 7d ", style: tui.style(fg: color, modifiers: [:bold])),
|
|
529
|
+
tui.span(content: bar, style: tui.style(fg: color)),
|
|
530
|
+
tui.span(content: " #{pct.to_s.rjust(2)}%", style: tui.style(fg: color))
|
|
531
|
+
])
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
lines
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Builds cache efficiency display lines with sparkline graph.
|
|
538
|
+
# ⚡ for hit rate bar, braille sparkline for per-call history, 💾 for saved.
|
|
539
|
+
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
540
|
+
def build_cache_metrics_lines(tui, stats)
|
|
541
|
+
hit_rate = (stats[:cache_hit_rate] * 100).round
|
|
542
|
+
color = cache_hit_color(hit_rate)
|
|
543
|
+
bar = build_progress_bar(hit_rate, Settings.theme_progress_bar_width)
|
|
544
|
+
|
|
545
|
+
total_cached = stats[:cache_read_input_tokens]
|
|
546
|
+
saved_label = format_token_count(total_cached)
|
|
547
|
+
|
|
548
|
+
lines = [
|
|
549
|
+
tui.line(spans: [
|
|
550
|
+
tui.span(content: " \u{26A1} ", style: tui.style(fg: color)),
|
|
551
|
+
tui.span(content: bar, style: tui.style(fg: color)),
|
|
552
|
+
tui.span(content: " #{hit_rate.to_s.rjust(2)}%", style: tui.style(fg: color))
|
|
553
|
+
]),
|
|
554
|
+
tui.line(spans: [
|
|
555
|
+
tui.span(content: " \u{1F4BE} ", style: tui.style(fg: Settings.theme_color_success)),
|
|
556
|
+
tui.span(content: saved_label, style: tui.style(fg: Settings.theme_color_success))
|
|
557
|
+
])
|
|
558
|
+
]
|
|
559
|
+
|
|
560
|
+
# Braille sparkline: per-call cache hit history (below icons)
|
|
561
|
+
history = stats[:cache_history]
|
|
562
|
+
if history && history.size > 1
|
|
563
|
+
sparkline = build_braille_sparkline(history)
|
|
564
|
+
color = cache_hit_color(hit_rate)
|
|
565
|
+
lines << tui.line(spans: [
|
|
566
|
+
tui.span(content: " ", style: tui.style(fg: Settings.theme_color_muted)),
|
|
567
|
+
tui.span(content: sparkline, style: tui.style(fg: color))
|
|
568
|
+
])
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
lines
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Builds a braille sparkline from a series of values (0.0-1.0).
|
|
575
|
+
# Each braille character encodes two adjacent data points using
|
|
576
|
+
# the left and right dot columns (4 dots each = 5 levels: 0-4).
|
|
577
|
+
#
|
|
578
|
+
# @param values [Array<Float>] data points between 0.0 and 1.0
|
|
579
|
+
# @return [String] braille sparkline string
|
|
580
|
+
def build_braille_sparkline(values)
|
|
581
|
+
# Braille dot positions: left column (dots 1,2,3,7), right column (dots 4,5,6,8)
|
|
582
|
+
# Numbered per Unicode spec: bit 0=dot1, bit 1=dot2, bit 2=dot3,
|
|
583
|
+
# bit 3=dot4, bit 4=dot5, bit 5=dot6, bit 6=dot7, bit 7=dot8
|
|
584
|
+
left_dots = [0x01, 0x02, 0x04, 0x40] # dots 1,2,3,7 (bottom to top)
|
|
585
|
+
right_dots = [0x08, 0x10, 0x20, 0x80] # dots 4,5,6,8 (bottom to top)
|
|
586
|
+
|
|
587
|
+
chars = []
|
|
588
|
+
values.each_slice(2) do |pair|
|
|
589
|
+
code = 0x2800 # braille base
|
|
590
|
+
# Left column: first value
|
|
591
|
+
fill_left = (pair[0].clamp(0.0, 1.0) * 4).round
|
|
592
|
+
fill_left.times { |i| code |= left_dots[i] }
|
|
593
|
+
# Right column: second value (or repeat first if odd)
|
|
594
|
+
right_val = pair[1] || pair[0]
|
|
595
|
+
fill_right = (right_val.clamp(0.0, 1.0) * 4).round
|
|
596
|
+
fill_right.times { |i| code |= right_dots[i] }
|
|
597
|
+
chars << code.chr(Encoding::UTF_8)
|
|
598
|
+
end
|
|
599
|
+
chars.join
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
# Builds a Unicode progress bar.
|
|
603
|
+
# @param pct [Integer] percentage (0-100)
|
|
604
|
+
# @param width [Integer] bar width in characters
|
|
605
|
+
# @return [String] progress bar string
|
|
606
|
+
def build_progress_bar(pct, width)
|
|
607
|
+
filled = (pct.clamp(0, 100) * width / 100.0).round
|
|
608
|
+
empty = width - filled
|
|
609
|
+
("\u2593" * filled) + ("\u2591" * empty)
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Color for rate limit percentage: green below warning, yellow below critical, red above.
|
|
613
|
+
def rate_limit_color(pct)
|
|
614
|
+
return Settings.theme_color_error if pct >= Settings.theme_rate_limit_critical
|
|
615
|
+
return Settings.theme_color_warning if pct >= Settings.theme_rate_limit_warning
|
|
616
|
+
Settings.theme_color_success
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Color for cache hit rate: green above good, yellow above low, red below.
|
|
620
|
+
def cache_hit_color(pct)
|
|
621
|
+
return Settings.theme_color_success if pct >= Settings.theme_cache_hit_good
|
|
622
|
+
return Settings.theme_color_warning if pct >= Settings.theme_cache_hit_low
|
|
623
|
+
Settings.theme_color_error
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# Formats a reset timestamp as time remaining (e.g., "2h15m").
|
|
627
|
+
def format_reset_time(epoch_seconds)
|
|
628
|
+
diff = epoch_seconds - Time.now.to_i
|
|
629
|
+
return "soon" if diff <= 0
|
|
630
|
+
|
|
631
|
+
hours = diff / 3600
|
|
632
|
+
minutes = (diff % 3600) / 60
|
|
633
|
+
if hours > 0
|
|
634
|
+
"\u279E#{hours}h#{minutes.to_s.rjust(2, "0")}m"
|
|
635
|
+
else
|
|
636
|
+
"\u279E#{minutes}m"
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# Formats a token count for display (e.g., "47.2K").
|
|
641
|
+
def format_token_count(count)
|
|
642
|
+
return "0" if count == 0
|
|
643
|
+
return count.to_s if count < 1000
|
|
644
|
+
"#{(count / 1000.0).round(1)}K tokens"
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Shows focus mode when a pane is focused, or the braille spinner
|
|
648
|
+
# during active processing.
|
|
397
649
|
def interaction_state_line(tui)
|
|
398
|
-
if @
|
|
650
|
+
if @hud_focused
|
|
651
|
+
tui.line(spans: [
|
|
652
|
+
tui.span(content: "HUD Scroll", style: tui.style(fg: Settings.theme_color_warning, modifiers: [:bold]))
|
|
653
|
+
])
|
|
654
|
+
elsif @screens[:chat].chat_focused
|
|
399
655
|
tui.line(spans: [
|
|
400
|
-
tui.span(content: "Scrolling", style: tui.style(fg:
|
|
656
|
+
tui.span(content: "Scrolling", style: tui.style(fg: Settings.theme_color_warning, modifiers: [:bold]))
|
|
401
657
|
])
|
|
402
658
|
elsif chat_loading?
|
|
659
|
+
chat = @screens[:chat]
|
|
660
|
+
char = chat.spinner.tick || "\u2800"
|
|
661
|
+
color = chat.spinner_color
|
|
662
|
+
label = chat.spinner_label
|
|
403
663
|
tui.line(spans: [
|
|
404
|
-
tui.span(content: "
|
|
664
|
+
tui.span(content: "#{char} ", style: tui.style(fg: color, modifiers: [:bold])),
|
|
665
|
+
tui.span(content: label, style: tui.style(fg: color))
|
|
405
666
|
])
|
|
406
667
|
end
|
|
407
668
|
end
|
|
@@ -410,6 +671,52 @@ module TUI
|
|
|
410
671
|
@screens[:chat].loading?
|
|
411
672
|
end
|
|
412
673
|
|
|
674
|
+
# Switches keyboard focus to the HUD pane.
|
|
675
|
+
# When children exist, enters child navigation mode with the first
|
|
676
|
+
# child selected; otherwise enters plain scroll mode.
|
|
677
|
+
#
|
|
678
|
+
# @return [void]
|
|
679
|
+
def focus_hud
|
|
680
|
+
@screens[:chat].unfocus_chat if @screens[:chat].chat_focused
|
|
681
|
+
@hud_focused = true
|
|
682
|
+
children = hud_children
|
|
683
|
+
if children.any?
|
|
684
|
+
@hud_child_index = 0
|
|
685
|
+
@hud_scroll_offset = @hud_max_scroll
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# Returns keyboard focus from the HUD pane and exits child navigation.
|
|
690
|
+
#
|
|
691
|
+
# @return [void]
|
|
692
|
+
def unfocus_hud
|
|
693
|
+
@hud_focused = false
|
|
694
|
+
@hud_child_index = nil
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Returns the current session's child sessions (sub-agents).
|
|
698
|
+
#
|
|
699
|
+
# @return [Array<Hash>] child session hashes, empty when none
|
|
700
|
+
def hud_children
|
|
701
|
+
@screens[:chat].session_info[:children] || []
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# Scrolls the HUD viewport up, clamping at the top.
|
|
705
|
+
#
|
|
706
|
+
# @param lines [Integer] number of lines to scroll
|
|
707
|
+
# @return [void]
|
|
708
|
+
def scroll_hud_up(lines)
|
|
709
|
+
@hud_scroll_offset = [@hud_scroll_offset - lines, 0].max
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Scrolls the HUD viewport down, clamping at max_scroll.
|
|
713
|
+
#
|
|
714
|
+
# @param lines [Integer] number of lines to scroll
|
|
715
|
+
# @return [void]
|
|
716
|
+
def scroll_hud_down(lines)
|
|
717
|
+
@hud_scroll_offset = [@hud_scroll_offset + lines, @hud_max_scroll].min
|
|
718
|
+
end
|
|
719
|
+
|
|
413
720
|
def handle_event(event)
|
|
414
721
|
return nil if event.none?
|
|
415
722
|
return :quit if event.ctrl_c?
|
|
@@ -422,6 +729,8 @@ module TUI
|
|
|
422
729
|
handle_view_mode_picker(event)
|
|
423
730
|
elsif @command_mode
|
|
424
731
|
handle_command_mode(event)
|
|
732
|
+
elsif @hud_focused
|
|
733
|
+
handle_hud_focused_event(event)
|
|
425
734
|
else
|
|
426
735
|
handle_normal_mode(event)
|
|
427
736
|
end
|
|
@@ -442,6 +751,11 @@ module TUI
|
|
|
442
751
|
return nil
|
|
443
752
|
end
|
|
444
753
|
|
|
754
|
+
if event.right? && @hud_visible
|
|
755
|
+
focus_hud
|
|
756
|
+
return nil
|
|
757
|
+
end
|
|
758
|
+
|
|
445
759
|
action = COMMAND_KEYS[event.code]
|
|
446
760
|
case action
|
|
447
761
|
when :quit
|
|
@@ -451,6 +765,7 @@ module TUI
|
|
|
451
765
|
nil
|
|
452
766
|
when :toggle_hud
|
|
453
767
|
@hud_visible = !@hud_visible
|
|
768
|
+
unfocus_hud if !@hud_visible
|
|
454
769
|
nil
|
|
455
770
|
when :new_session
|
|
456
771
|
@screens[:chat].new_session
|
|
@@ -466,7 +781,13 @@ module TUI
|
|
|
466
781
|
end
|
|
467
782
|
|
|
468
783
|
def handle_normal_mode(event)
|
|
469
|
-
if event.
|
|
784
|
+
if event.paste?
|
|
785
|
+
delegate_to_screen(event)
|
|
786
|
+
return nil
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
if event.mouse?
|
|
790
|
+
return nil if route_mouse_to_hud(event)
|
|
470
791
|
delegate_to_screen(event)
|
|
471
792
|
return nil
|
|
472
793
|
end
|
|
@@ -497,6 +818,95 @@ module TUI
|
|
|
497
818
|
nil
|
|
498
819
|
end
|
|
499
820
|
|
|
821
|
+
# Handles keyboard events when the HUD pane has focus.
|
|
822
|
+
# When a child is selected (child navigation mode), arrow keys move
|
|
823
|
+
# between sub-agents and Enter switches to the selected session.
|
|
824
|
+
# Otherwise, arrow keys and Page Up/Down scroll the HUD content.
|
|
825
|
+
# Escape and Ctrl+A always exit HUD focus.
|
|
826
|
+
def handle_hud_focused_event(event)
|
|
827
|
+
return nil if event.none?
|
|
828
|
+
return :quit if event.ctrl_c?
|
|
829
|
+
|
|
830
|
+
if event.mouse?
|
|
831
|
+
return nil if route_mouse_to_hud(event)
|
|
832
|
+
delegate_to_screen(event)
|
|
833
|
+
return nil
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
return nil unless event.key?
|
|
837
|
+
|
|
838
|
+
if event.esc? || ctrl_a?(event)
|
|
839
|
+
unfocus_hud
|
|
840
|
+
return nil
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
if @hud_child_index
|
|
844
|
+
handle_hud_child_navigation(event)
|
|
845
|
+
else
|
|
846
|
+
handle_hud_scroll(event)
|
|
847
|
+
end
|
|
848
|
+
nil
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# Navigates between sub-agent entries in the HUD.
|
|
852
|
+
# Up/Down move selection, Enter switches to the selected session,
|
|
853
|
+
# Left exits child navigation back to scroll mode.
|
|
854
|
+
def handle_hud_child_navigation(event)
|
|
855
|
+
children = hud_children
|
|
856
|
+
return if children.empty?
|
|
857
|
+
|
|
858
|
+
if event.up?
|
|
859
|
+
@hud_child_index = [@hud_child_index - 1, 0].max
|
|
860
|
+
elsif event.down?
|
|
861
|
+
@hud_child_index = [@hud_child_index + 1, children.size - 1].min
|
|
862
|
+
elsif event.left?
|
|
863
|
+
@hud_child_index = nil
|
|
864
|
+
elsif event.enter?
|
|
865
|
+
child = children[@hud_child_index]
|
|
866
|
+
@screens[:chat].switch_session(child["id"]) if child
|
|
867
|
+
unfocus_hud
|
|
868
|
+
end
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# Scrolls the HUD viewport with arrow/page keys.
|
|
872
|
+
def handle_hud_scroll(event)
|
|
873
|
+
if event.up?
|
|
874
|
+
scroll_hud_up(Settings.hud_scroll_step)
|
|
875
|
+
elsif event.down?
|
|
876
|
+
scroll_hud_down(Settings.hud_scroll_step)
|
|
877
|
+
elsif event.page_up?
|
|
878
|
+
scroll_hud_up(@hud_visible_height)
|
|
879
|
+
elsif event.page_down?
|
|
880
|
+
scroll_hud_down(@hud_visible_height)
|
|
881
|
+
elsif event.home?
|
|
882
|
+
scroll_hud_up(@hud_max_scroll)
|
|
883
|
+
elsif event.end?
|
|
884
|
+
scroll_hud_down(@hud_max_scroll)
|
|
885
|
+
end
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# Routes mouse scroll events to the HUD when the cursor is over the HUD area.
|
|
889
|
+
# @return [Boolean] true if the event was handled by the HUD
|
|
890
|
+
def route_mouse_to_hud(event)
|
|
891
|
+
return false if !@hud_visible || !@hud_content_area
|
|
892
|
+
return false unless mouse_over_hud?(event)
|
|
893
|
+
return false unless event.scroll_up? || event.scroll_down?
|
|
894
|
+
|
|
895
|
+
if event.scroll_up?
|
|
896
|
+
scroll_hud_up(Settings.hud_mouse_scroll_step)
|
|
897
|
+
else
|
|
898
|
+
scroll_hud_down(Settings.hud_mouse_scroll_step)
|
|
899
|
+
end
|
|
900
|
+
true
|
|
901
|
+
end
|
|
902
|
+
|
|
903
|
+
# Checks whether a mouse event's coordinates fall within the HUD content area.
|
|
904
|
+
def mouse_over_hud?(event)
|
|
905
|
+
area = @hud_content_area
|
|
906
|
+
event.x >= area.x && event.x < area.x + area.width &&
|
|
907
|
+
event.y >= area.y && event.y < area.y + area.height
|
|
908
|
+
end
|
|
909
|
+
|
|
500
910
|
# Switches to the parent session when viewing a child (sub-agent) session.
|
|
501
911
|
# No-op if the current session is a root session.
|
|
502
912
|
#
|
|
@@ -584,12 +994,10 @@ module TUI
|
|
|
584
994
|
CHILD_STATUS_DONE = "\u2713" # ✓
|
|
585
995
|
CHILDREN_ARROW = "\u25B8" # ▸ shown next to sessions with children
|
|
586
996
|
UNNAMED_SUBAGENT_LABEL = "sub-agent"
|
|
587
|
-
SESSION_PICKER_PAGE_SIZE = 9
|
|
588
|
-
SESSION_PICKER_FETCH_LIMIT = 50
|
|
589
997
|
BACK_ARROW = "\u2190" # ←
|
|
590
998
|
|
|
591
999
|
# Requests the session list from the brain and opens the picker overlay.
|
|
592
|
-
# Fetches up to
|
|
1000
|
+
# Fetches up to Settings.session_picker_fetch_limit sessions for client-side pagination.
|
|
593
1001
|
# @return [void]
|
|
594
1002
|
def activate_session_picker
|
|
595
1003
|
@session_picker_active = true
|
|
@@ -597,7 +1005,7 @@ module TUI
|
|
|
597
1005
|
@session_picker_page = 0
|
|
598
1006
|
@session_picker_mode = :root
|
|
599
1007
|
@session_picker_parent_id = nil
|
|
600
|
-
@cable_client.list_sessions(limit:
|
|
1008
|
+
@cable_client.list_sessions(limit: Settings.session_picker_fetch_limit)
|
|
601
1009
|
end
|
|
602
1010
|
|
|
603
1011
|
# Dispatches keyboard events while the session picker overlay is open.
|
|
@@ -661,8 +1069,8 @@ module TUI
|
|
|
661
1069
|
# @return [Array<Hash>] visible items for the current page
|
|
662
1070
|
def session_picker_visible_items
|
|
663
1071
|
all = session_picker_all_items_for_mode
|
|
664
|
-
start = @session_picker_page *
|
|
665
|
-
page = all[start,
|
|
1072
|
+
start = @session_picker_page * Settings.session_picker_page_size
|
|
1073
|
+
page = all[start, Settings.session_picker_page_size] || []
|
|
666
1074
|
|
|
667
1075
|
page.map do |item|
|
|
668
1076
|
case @session_picker_mode
|
|
@@ -677,13 +1085,13 @@ module TUI
|
|
|
677
1085
|
# @return [Boolean] true when more items exist beyond the current page
|
|
678
1086
|
def session_picker_has_more?
|
|
679
1087
|
total = session_picker_all_items_for_mode.size
|
|
680
|
-
((@session_picker_page + 1) *
|
|
1088
|
+
((@session_picker_page + 1) * Settings.session_picker_page_size) < total
|
|
681
1089
|
end
|
|
682
1090
|
|
|
683
1091
|
# @return [Integer] number of items beyond the current page
|
|
684
1092
|
def session_picker_remaining_count
|
|
685
1093
|
total = session_picker_all_items_for_mode.size
|
|
686
|
-
[total - ((@session_picker_page + 1) *
|
|
1094
|
+
[total - ((@session_picker_page + 1) * Settings.session_picker_page_size), 0].max
|
|
687
1095
|
end
|
|
688
1096
|
|
|
689
1097
|
# Handles Escape in the session picker. In children mode, returns to root.
|
|
@@ -760,7 +1168,7 @@ module TUI
|
|
|
760
1168
|
|
|
761
1169
|
if sessions.nil?
|
|
762
1170
|
lines = [tui.line(spans: [
|
|
763
|
-
tui.span(content: "Loading...", style: tui.style(fg:
|
|
1171
|
+
tui.span(content: "Loading...", style: tui.style(fg: Settings.theme_color_warning))
|
|
764
1172
|
])]
|
|
765
1173
|
else
|
|
766
1174
|
visible = session_picker_visible_items
|
|
@@ -778,7 +1186,7 @@ module TUI
|
|
|
778
1186
|
|
|
779
1187
|
if lines.empty?
|
|
780
1188
|
lines = [tui.line(spans: [
|
|
781
|
-
tui.span(content: "No sessions", style: tui.style(fg:
|
|
1189
|
+
tui.span(content: "No sessions", style: tui.style(fg: Settings.theme_color_muted))
|
|
782
1190
|
])]
|
|
783
1191
|
end
|
|
784
1192
|
end
|
|
@@ -789,7 +1197,7 @@ module TUI
|
|
|
789
1197
|
title: session_picker_title,
|
|
790
1198
|
borders: [:all],
|
|
791
1199
|
border_type: :rounded,
|
|
792
|
-
border_style: {fg:
|
|
1200
|
+
border_style: {fg: Settings.theme_color_info}
|
|
793
1201
|
)
|
|
794
1202
|
)
|
|
795
1203
|
frame.render_widget(picker, area)
|
|
@@ -830,11 +1238,11 @@ module TUI
|
|
|
830
1238
|
label = "#{prefix}#{marker}#{arrow}#{display_name} #{count}#{child_info} #{time}"
|
|
831
1239
|
|
|
832
1240
|
style = if selected
|
|
833
|
-
tui.style(fg:
|
|
1241
|
+
tui.style(fg: Settings.theme_highlight_fg, bg: Settings.theme_highlight_bg)
|
|
834
1242
|
elsif is_current
|
|
835
|
-
tui.style(fg:
|
|
1243
|
+
tui.style(fg: Settings.theme_color_info, modifiers: [:bold])
|
|
836
1244
|
else
|
|
837
|
-
tui.style(fg:
|
|
1245
|
+
tui.style(fg: Settings.theme_color_text)
|
|
838
1246
|
end
|
|
839
1247
|
|
|
840
1248
|
[tui.line(spans: [tui.span(content: label, style: style)])]
|
|
@@ -855,15 +1263,15 @@ module TUI
|
|
|
855
1263
|
prefix = hotkey ? "[#{hotkey}]" : " "
|
|
856
1264
|
marker = is_current ? "*" : " "
|
|
857
1265
|
status = child["processing"] ? CHILD_STATUS_RUNNING : CHILD_STATUS_DONE
|
|
858
|
-
status_color = child["processing"] ?
|
|
1266
|
+
status_color = child["processing"] ? Settings.theme_color_warning : Settings.theme_color_success
|
|
859
1267
|
display_name = child["name"] || UNNAMED_SUBAGENT_LABEL
|
|
860
1268
|
|
|
861
1269
|
label = "#{prefix}#{marker}#{status} #{display_name}"
|
|
862
1270
|
|
|
863
1271
|
style = if selected
|
|
864
|
-
tui.style(fg:
|
|
1272
|
+
tui.style(fg: Settings.theme_highlight_fg, bg: Settings.theme_highlight_bg)
|
|
865
1273
|
elsif is_current
|
|
866
|
-
tui.style(fg:
|
|
1274
|
+
tui.style(fg: Settings.theme_color_info, modifiers: [:bold])
|
|
867
1275
|
else
|
|
868
1276
|
tui.style(fg: status_color)
|
|
869
1277
|
end
|
|
@@ -878,7 +1286,7 @@ module TUI
|
|
|
878
1286
|
def format_load_more_entry(tui)
|
|
879
1287
|
remaining = session_picker_remaining_count
|
|
880
1288
|
label = "[0] Load more (#{remaining})"
|
|
881
|
-
[tui.line(spans: [tui.span(content: label, style: tui.style(fg:
|
|
1289
|
+
[tui.line(spans: [tui.span(content: label, style: tui.style(fg: Settings.theme_color_muted))])]
|
|
882
1290
|
end
|
|
883
1291
|
|
|
884
1292
|
# -- View mode picker ----------------------------------------------
|
|
@@ -935,7 +1343,7 @@ module TUI
|
|
|
935
1343
|
title: "View Mode",
|
|
936
1344
|
borders: [:all],
|
|
937
1345
|
border_type: :rounded,
|
|
938
|
-
border_style: {fg:
|
|
1346
|
+
border_style: {fg: Settings.theme_color_info}
|
|
939
1347
|
)
|
|
940
1348
|
)
|
|
941
1349
|
frame.render_widget(picker, area)
|
|
@@ -957,17 +1365,17 @@ module TUI
|
|
|
957
1365
|
prefix = hotkey ? "[#{hotkey}]" : " "
|
|
958
1366
|
marker = is_current ? "*" : " "
|
|
959
1367
|
|
|
960
|
-
selected_style = tui.style(fg:
|
|
1368
|
+
selected_style = tui.style(fg: Settings.theme_highlight_fg, bg: Settings.theme_highlight_bg)
|
|
961
1369
|
|
|
962
1370
|
name_style = if selected
|
|
963
1371
|
selected_style
|
|
964
1372
|
elsif is_current
|
|
965
|
-
tui.style(fg:
|
|
1373
|
+
tui.style(fg: Settings.theme_color_info, modifiers: [:bold])
|
|
966
1374
|
else
|
|
967
|
-
tui.style(fg:
|
|
1375
|
+
tui.style(fg: Settings.theme_color_text)
|
|
968
1376
|
end
|
|
969
1377
|
|
|
970
|
-
desc_style = selected ? selected_style : tui.style(fg:
|
|
1378
|
+
desc_style = selected ? selected_style : tui.style(fg: Settings.theme_color_muted)
|
|
971
1379
|
|
|
972
1380
|
[
|
|
973
1381
|
tui.line(spans: [tui.span(content: "#{prefix}#{marker}#{mode.capitalize}", style: name_style)]),
|
|
@@ -1112,9 +1520,9 @@ module TUI
|
|
|
1112
1520
|
frame.render_widget(tui.clear, popup_area)
|
|
1113
1521
|
|
|
1114
1522
|
border_color = case @token_setup_status
|
|
1115
|
-
when :success then
|
|
1116
|
-
when :error then
|
|
1117
|
-
else
|
|
1523
|
+
when :success then Settings.theme_color_success
|
|
1524
|
+
when :error then Settings.theme_color_error
|
|
1525
|
+
else Settings.theme_color_warning
|
|
1118
1526
|
end
|
|
1119
1527
|
|
|
1120
1528
|
lines = build_token_setup_lines(tui)
|
|
@@ -1143,36 +1551,36 @@ module TUI
|
|
|
1143
1551
|
# Status
|
|
1144
1552
|
status_text, status_color = token_status_display
|
|
1145
1553
|
lines << tui.line(spans: [
|
|
1146
|
-
tui.span(content: "Status: ", style: tui.style(fg:
|
|
1554
|
+
tui.span(content: "Status: ", style: tui.style(fg: Settings.theme_color_muted)),
|
|
1147
1555
|
tui.span(content: status_text, style: tui.style(fg: status_color, modifiers: [:bold]))
|
|
1148
1556
|
])
|
|
1149
1557
|
lines << tui.line(spans: [tui.span(content: "")])
|
|
1150
1558
|
|
|
1151
1559
|
# Instructions
|
|
1152
1560
|
lines << tui.line(spans: [
|
|
1153
|
-
tui.span(content: "Run ", style: tui.style(fg:
|
|
1154
|
-
tui.span(content: "claude setup-token", style: tui.style(fg:
|
|
1155
|
-
tui.span(content: " to get", style: tui.style(fg:
|
|
1561
|
+
tui.span(content: "Run ", style: tui.style(fg: Settings.theme_color_text)),
|
|
1562
|
+
tui.span(content: "claude setup-token", style: tui.style(fg: Settings.theme_color_info, modifiers: [:bold])),
|
|
1563
|
+
tui.span(content: " to get", style: tui.style(fg: Settings.theme_color_text))
|
|
1156
1564
|
])
|
|
1157
1565
|
lines << tui.line(spans: [
|
|
1158
|
-
tui.span(content: "your token, then paste it here.", style: tui.style(fg:
|
|
1566
|
+
tui.span(content: "your token, then paste it here.", style: tui.style(fg: Settings.theme_color_text))
|
|
1159
1567
|
])
|
|
1160
1568
|
lines << tui.line(spans: [tui.span(content: "")])
|
|
1161
1569
|
|
|
1162
1570
|
# Token input
|
|
1163
1571
|
masked = mask_token(@token_input_buffer.text)
|
|
1164
1572
|
lines << tui.line(spans: [
|
|
1165
|
-
tui.span(content: "Token:", style: tui.style(fg:
|
|
1573
|
+
tui.span(content: "Token:", style: tui.style(fg: Settings.theme_color_text, modifiers: [:bold]))
|
|
1166
1574
|
])
|
|
1167
1575
|
lines << tui.line(spans: [
|
|
1168
|
-
tui.span(content: "> #{masked}", style: tui.style(fg:
|
|
1576
|
+
tui.span(content: "> #{masked}", style: tui.style(fg: Settings.theme_color_text))
|
|
1169
1577
|
])
|
|
1170
1578
|
lines << tui.line(spans: [tui.span(content: "")])
|
|
1171
1579
|
|
|
1172
1580
|
# Error or success message
|
|
1173
1581
|
if @token_setup_error
|
|
1174
1582
|
lines << tui.line(spans: [
|
|
1175
|
-
tui.span(content: @token_setup_error, style: tui.style(fg:
|
|
1583
|
+
tui.span(content: @token_setup_error, style: tui.style(fg: Settings.theme_color_error))
|
|
1176
1584
|
])
|
|
1177
1585
|
lines << tui.line(spans: [tui.span(content: "")])
|
|
1178
1586
|
end
|
|
@@ -1180,11 +1588,11 @@ module TUI
|
|
|
1180
1588
|
if @token_setup_status == :success
|
|
1181
1589
|
lines << if @token_setup_warning
|
|
1182
1590
|
tui.line(spans: [
|
|
1183
|
-
tui.span(content: "Token saved (API unavailable, validation skipped)", style: tui.style(fg:
|
|
1591
|
+
tui.span(content: "Token saved (API unavailable, validation skipped)", style: tui.style(fg: Settings.theme_color_warning, modifiers: [:bold]))
|
|
1184
1592
|
])
|
|
1185
1593
|
else
|
|
1186
1594
|
tui.line(spans: [
|
|
1187
|
-
tui.span(content: "Token saved and validated!", style: tui.style(fg:
|
|
1595
|
+
tui.span(content: "Token saved and validated!", style: tui.style(fg: Settings.theme_color_success, modifiers: [:bold]))
|
|
1188
1596
|
])
|
|
1189
1597
|
end
|
|
1190
1598
|
lines << tui.line(spans: [tui.span(content: "")])
|
|
@@ -1197,7 +1605,7 @@ module TUI
|
|
|
1197
1605
|
else "[Enter] Save [Esc] Cancel"
|
|
1198
1606
|
end
|
|
1199
1607
|
lines << tui.line(spans: [
|
|
1200
|
-
tui.span(content: hint, style: tui.style(fg:
|
|
1608
|
+
tui.span(content: hint, style: tui.style(fg: Settings.theme_color_muted))
|
|
1201
1609
|
])
|
|
1202
1610
|
|
|
1203
1611
|
lines
|
|
@@ -1207,31 +1615,31 @@ module TUI
|
|
|
1207
1615
|
def token_status_display
|
|
1208
1616
|
case @token_setup_status
|
|
1209
1617
|
when :success
|
|
1210
|
-
@token_setup_warning ? ["Saved (unverified)",
|
|
1618
|
+
@token_setup_warning ? ["Saved (unverified)", Settings.theme_color_warning] : ["Valid", Settings.theme_color_success]
|
|
1211
1619
|
when :validating
|
|
1212
|
-
["Validating...",
|
|
1620
|
+
["Validating...", Settings.theme_color_warning]
|
|
1213
1621
|
when :error
|
|
1214
|
-
["Invalid",
|
|
1622
|
+
["Invalid", Settings.theme_color_error]
|
|
1215
1623
|
else
|
|
1216
1624
|
if @token_input_buffer.text.empty?
|
|
1217
|
-
["Not configured",
|
|
1625
|
+
["Not configured", Settings.theme_color_muted]
|
|
1218
1626
|
else
|
|
1219
|
-
["Ready to save",
|
|
1627
|
+
["Ready to save", Settings.theme_color_info]
|
|
1220
1628
|
end
|
|
1221
1629
|
end
|
|
1222
1630
|
end
|
|
1223
1631
|
|
|
1224
|
-
# Masks an Anthropic token for display: shows the first
|
|
1632
|
+
# Masks an Anthropic token for display: shows the first Settings.token_dialog_mask_visible
|
|
1225
1633
|
# characters (the prefix) and replaces the rest with stars.
|
|
1226
1634
|
#
|
|
1227
1635
|
# @param token [String] raw token text
|
|
1228
1636
|
# @return [String] masked display text
|
|
1229
1637
|
def mask_token(token)
|
|
1230
1638
|
return "" if token.empty?
|
|
1231
|
-
return token if token.length <=
|
|
1639
|
+
return token if token.length <= Settings.token_dialog_mask_visible
|
|
1232
1640
|
|
|
1233
|
-
visible = token[0...
|
|
1234
|
-
hidden_count = [token.length -
|
|
1641
|
+
visible = token[0...Settings.token_dialog_mask_visible]
|
|
1642
|
+
hidden_count = [token.length - Settings.token_dialog_mask_visible, Settings.token_dialog_mask_stars].min
|
|
1235
1643
|
"#{visible}#{"*" * hidden_count}..."
|
|
1236
1644
|
end
|
|
1237
1645
|
|
|
@@ -1269,7 +1677,7 @@ module TUI
|
|
|
1269
1677
|
# @param area [RatatuiRuby::Rect] full terminal area
|
|
1270
1678
|
# @return [RatatuiRuby::Rect] centered popup area
|
|
1271
1679
|
def centered_popup_area(tui, area)
|
|
1272
|
-
popup_height = [
|
|
1680
|
+
popup_height = [Settings.token_dialog_popup_height, area.height - 2].min
|
|
1273
1681
|
v_margin = [(area.height - popup_height) / 2, 0].max
|
|
1274
1682
|
|
|
1275
1683
|
_, center_v, _ = tui.split(
|
|
@@ -1282,7 +1690,7 @@ module TUI
|
|
|
1282
1690
|
]
|
|
1283
1691
|
)
|
|
1284
1692
|
|
|
1285
|
-
popup_width = (area.width * 60 / 100).clamp(
|
|
1693
|
+
popup_width = (area.width * 60 / 100).clamp(Settings.token_dialog_popup_min_width, area.width - 2)
|
|
1286
1694
|
h_margin = [(area.width - popup_width) / 2, 0].max
|
|
1287
1695
|
|
|
1288
1696
|
_, center, _ = tui.split(
|
|
@@ -1362,18 +1770,18 @@ module TUI
|
|
|
1362
1770
|
def stop_terminal_watchdog
|
|
1363
1771
|
return unless @watchdog_thread
|
|
1364
1772
|
|
|
1365
|
-
@watchdog_thread.join(
|
|
1773
|
+
@watchdog_thread.join(Settings.terminal_shutdown_timeout)
|
|
1366
1774
|
@watchdog_thread.kill if @watchdog_thread.alive?
|
|
1367
1775
|
@watchdog_thread = nil
|
|
1368
1776
|
end
|
|
1369
1777
|
|
|
1370
|
-
# Opens {CONTROLLING_TERMINAL} every
|
|
1778
|
+
# Opens {CONTROLLING_TERMINAL} every +terminal.check_interval+ seconds.
|
|
1371
1779
|
# File.open (not File.stat) is required because stat only checks the
|
|
1372
1780
|
# filesystem entry which always exists; open actually probes the device.
|
|
1373
1781
|
# When the terminal disappears, calls {#handle_terminal_loss}.
|
|
1374
1782
|
# Exits silently in non-TTY environments (CI, test suites).
|
|
1375
1783
|
# @see CONTROLLING_TERMINAL
|
|
1376
|
-
# @see
|
|
1784
|
+
# @see TUI::Settings.terminal_check_interval
|
|
1377
1785
|
# @return [void]
|
|
1378
1786
|
def terminal_watchdog_loop
|
|
1379
1787
|
# Empty block triggers open syscall to probe the device, then immediately closes the FD.
|
|
@@ -1386,7 +1794,7 @@ module TUI
|
|
|
1386
1794
|
rescue Errno::ENXIO, Errno::EIO, Errno::ENOENT
|
|
1387
1795
|
handle_terminal_loss
|
|
1388
1796
|
end
|
|
1389
|
-
sleep
|
|
1797
|
+
sleep Settings.terminal_check_interval
|
|
1390
1798
|
end
|
|
1391
1799
|
rescue SystemCallError
|
|
1392
1800
|
# No controlling terminal — nothing to watch (ENXIO, EIO, ENOENT, EACCES, etc.)
|