anima-core 0.2.0 → 0.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/CHANGELOG.md +34 -0
- data/README.md +20 -32
- data/anima-core.gemspec +1 -0
- data/app/channels/session_channel.rb +220 -26
- data/app/decorators/agent_message_decorator.rb +24 -0
- data/app/decorators/application_decorator.rb +6 -0
- data/app/decorators/event_decorator.rb +173 -0
- data/app/decorators/system_message_decorator.rb +21 -0
- data/app/decorators/tool_call_decorator.rb +48 -0
- data/app/decorators/tool_response_decorator.rb +37 -0
- data/app/decorators/user_message_decorator.rb +35 -0
- data/app/jobs/agent_request_job.rb +31 -2
- data/app/jobs/count_event_tokens_job.rb +14 -3
- data/app/models/concerns/event/broadcasting.rb +63 -0
- data/app/models/event.rb +36 -0
- data/app/models/session.rb +46 -14
- data/config/application.rb +1 -0
- data/config/initializers/event_subscribers.rb +0 -1
- data/config/routes.rb +0 -6
- data/db/cable_schema.rb +14 -2
- data/db/migrate/20260312170000_add_view_mode_to_sessions.rb +7 -0
- data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
- data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
- data/lib/agent_loop.rb +5 -2
- data/lib/anima/cli.rb +1 -40
- data/lib/anima/version.rb +1 -1
- data/lib/events/subscribers/persister.rb +1 -0
- data/lib/events/user_message.rb +17 -0
- data/lib/providers/anthropic.rb +3 -13
- data/lib/tools/edit.rb +227 -0
- data/lib/tools/read.rb +152 -0
- data/lib/tools/write.rb +86 -0
- data/lib/tui/app.rb +831 -55
- data/lib/tui/cable_client.rb +79 -31
- data/lib/tui/input_buffer.rb +181 -0
- data/lib/tui/message_store.rb +162 -14
- data/lib/tui/screens/chat.rb +504 -75
- metadata +30 -5
- data/app/controllers/api/sessions_controller.rb +0 -25
- data/lib/events/subscribers/action_cable_bridge.rb +0 -35
- data/lib/tui/screens/anthropic.rb +0 -25
- data/lib/tui/screens/settings.rb +0 -52
data/lib/tui/app.rb
CHANGED
|
@@ -1,67 +1,132 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
3
4
|
require_relative "cable_client"
|
|
5
|
+
require_relative "input_buffer"
|
|
4
6
|
require_relative "message_store"
|
|
5
7
|
require_relative "screens/chat"
|
|
6
|
-
require_relative "screens/settings"
|
|
7
|
-
require_relative "screens/anthropic"
|
|
8
8
|
|
|
9
9
|
module TUI
|
|
10
10
|
class App
|
|
11
|
-
SCREENS = %i[chat
|
|
11
|
+
SCREENS = %i[chat].freeze
|
|
12
12
|
|
|
13
13
|
COMMAND_KEYS = {
|
|
14
|
+
"a" => :anthropic_token,
|
|
14
15
|
"n" => :new_session,
|
|
15
|
-
"s" => :
|
|
16
|
-
"
|
|
16
|
+
"s" => :session_picker,
|
|
17
|
+
"v" => :view_mode,
|
|
17
18
|
"q" => :quit
|
|
18
19
|
}.freeze
|
|
19
20
|
|
|
20
|
-
MENU_LABELS = COMMAND_KEYS.map { |key, action| "[#{key}] #{action.capitalize}" }.freeze
|
|
21
|
+
MENU_LABELS = COMMAND_KEYS.map { |key, action| "[#{key}] #{action.to_s.tr("_", " ").capitalize}" }.freeze
|
|
21
22
|
|
|
22
23
|
SIDEBAR_WIDTH = 28
|
|
23
24
|
|
|
24
|
-
#
|
|
25
|
+
# Picker entry prefix width: "[N]" (3) + marker (1) + space (1) = 5
|
|
26
|
+
PICKER_PREFIX_WIDTH = 5
|
|
27
|
+
|
|
28
|
+
# User-facing descriptions shown below each mode name in the view mode picker.
|
|
29
|
+
VIEW_MODE_LABELS = {
|
|
30
|
+
"basic" => "Chat messages only",
|
|
31
|
+
"verbose" => "Tools & timestamps",
|
|
32
|
+
"debug" => "Full LLM context"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# Connection status emoji indicators for the info panel.
|
|
36
|
+
# Subscribed (normal state) shows only the emoji; other states add text.
|
|
25
37
|
STATUS_STYLES = {
|
|
26
|
-
disconnected: {label: "
|
|
27
|
-
connecting: {label: "
|
|
28
|
-
connected: {label: "
|
|
29
|
-
subscribed: {label: "
|
|
30
|
-
reconnecting: {label: "
|
|
38
|
+
disconnected: {label: "🔴 Disconnected", color: "red"},
|
|
39
|
+
connecting: {label: "🟡 Connecting", color: "yellow"},
|
|
40
|
+
connected: {label: "🟡 Connecting", color: "yellow"},
|
|
41
|
+
subscribed: {label: "🟢", color: "green"},
|
|
42
|
+
reconnecting: {label: "🟡 Reconnecting", color: "yellow"}
|
|
31
43
|
}.freeze
|
|
32
44
|
|
|
33
|
-
|
|
45
|
+
# Number of leading characters to show unmasked in the token input.
|
|
46
|
+
# Matches the "sk-ant-oat01-" prefix (13 chars) plus one character of the
|
|
47
|
+
# secret portion so the user can verify both the token type and start of key.
|
|
48
|
+
TOKEN_MASK_VISIBLE = 14
|
|
49
|
+
|
|
50
|
+
# Maximum stars to show in the masked portion of the token.
|
|
51
|
+
# Keeps the masked display compact regardless of actual token length.
|
|
52
|
+
TOKEN_MASK_STARS = 4
|
|
53
|
+
|
|
54
|
+
# Token setup popup dimensions. Height accommodates: status line, blank,
|
|
55
|
+
# 2 instruction lines, blank, "Token:" label, input line, blank,
|
|
56
|
+
# error/success line, blank, hint line, plus top/bottom borders.
|
|
57
|
+
POPUP_HEIGHT = 14
|
|
58
|
+
POPUP_MIN_WIDTH = 44
|
|
59
|
+
|
|
60
|
+
# Matches a single printable Unicode character (no control codes).
|
|
61
|
+
PRINTABLE_CHAR = /\A[[:print:]]\z/
|
|
62
|
+
|
|
63
|
+
# Signals that trigger graceful shutdown when received from the OS.
|
|
64
|
+
SHUTDOWN_SIGNALS = %w[HUP TERM INT].freeze
|
|
65
|
+
|
|
66
|
+
# How often the watchdog thread checks if the controlling terminal is alive.
|
|
67
|
+
# @see #terminal_watchdog_loop
|
|
68
|
+
TERMINAL_CHECK_INTERVAL = 0.5
|
|
69
|
+
|
|
70
|
+
# Unix controlling terminal device path.
|
|
71
|
+
# @see #terminal_watchdog_loop
|
|
72
|
+
CONTROLLING_TERMINAL = "/dev/tty"
|
|
73
|
+
|
|
74
|
+
# Grace period for watchdog thread to exit before force-killing it.
|
|
75
|
+
WATCHDOG_SHUTDOWN_TIMEOUT = 1
|
|
76
|
+
|
|
77
|
+
attr_reader :current_screen, :command_mode, :session_picker_active,
|
|
78
|
+
:view_mode_picker_active
|
|
79
|
+
# @return [Boolean] true when the token setup popup overlay is visible
|
|
80
|
+
attr_reader :token_setup_active
|
|
81
|
+
# @return [Boolean] true when graceful shutdown has been requested via signal
|
|
82
|
+
attr_reader :shutdown_requested
|
|
34
83
|
|
|
35
84
|
# @param cable_client [TUI::CableClient] WebSocket client connected to the brain
|
|
36
85
|
def initialize(cable_client:)
|
|
37
86
|
@cable_client = cable_client
|
|
38
87
|
@current_screen = :chat
|
|
39
88
|
@command_mode = false
|
|
89
|
+
@session_picker_active = false
|
|
90
|
+
@session_picker_index = 0
|
|
91
|
+
@view_mode_picker_active = false
|
|
92
|
+
@view_mode_picker_index = 0
|
|
93
|
+
@token_setup_active = false
|
|
94
|
+
@token_input_buffer = InputBuffer.new
|
|
95
|
+
@token_setup_error = nil
|
|
96
|
+
@token_setup_status = :idle
|
|
97
|
+
@shutdown_requested = false
|
|
98
|
+
@previous_signal_handlers = {}
|
|
99
|
+
@watchdog_thread = nil
|
|
40
100
|
@screens = {
|
|
41
|
-
chat: Screens::Chat.new(cable_client: cable_client)
|
|
42
|
-
settings: Screens::Settings.new,
|
|
43
|
-
anthropic: Screens::Anthropic.new
|
|
101
|
+
chat: Screens::Chat.new(cable_client: cable_client)
|
|
44
102
|
}
|
|
45
103
|
end
|
|
46
104
|
|
|
47
105
|
def run
|
|
106
|
+
install_signal_handlers
|
|
107
|
+
start_terminal_watchdog
|
|
48
108
|
RatatuiRuby.run do |tui|
|
|
49
109
|
loop do
|
|
110
|
+
break if @shutdown_requested
|
|
111
|
+
|
|
50
112
|
tui.draw { |frame| render(frame, tui) }
|
|
51
113
|
|
|
52
114
|
event = tui.poll_event(timeout: 0.1)
|
|
115
|
+
break if @shutdown_requested
|
|
53
116
|
next if event.nil? || event.none?
|
|
54
117
|
break if handle_event(event) == :quit
|
|
55
118
|
end
|
|
56
119
|
end
|
|
57
120
|
ensure
|
|
121
|
+
stop_terminal_watchdog
|
|
122
|
+
restore_signal_handlers
|
|
58
123
|
@cable_client.disconnect
|
|
59
124
|
end
|
|
60
125
|
|
|
61
126
|
private
|
|
62
127
|
|
|
63
128
|
def render(frame, tui)
|
|
64
|
-
|
|
129
|
+
content_area, sidebar = tui.split(
|
|
65
130
|
frame.area,
|
|
66
131
|
direction: :horizontal,
|
|
67
132
|
constraints: [
|
|
@@ -70,22 +135,19 @@ module TUI
|
|
|
70
135
|
]
|
|
71
136
|
)
|
|
72
137
|
|
|
73
|
-
content_area, status_bar = tui.split(
|
|
74
|
-
main_area,
|
|
75
|
-
direction: :vertical,
|
|
76
|
-
constraints: [
|
|
77
|
-
tui.constraint_fill(1),
|
|
78
|
-
tui.constraint_length(1)
|
|
79
|
-
]
|
|
80
|
-
)
|
|
81
|
-
|
|
82
138
|
@screens[@current_screen].render(frame, content_area, tui)
|
|
83
139
|
render_sidebar(frame, sidebar, tui)
|
|
84
|
-
|
|
140
|
+
|
|
141
|
+
check_token_setup_signals
|
|
142
|
+
render_token_setup_popup(frame, frame.area, tui) if @token_setup_active
|
|
85
143
|
end
|
|
86
144
|
|
|
87
145
|
def render_sidebar(frame, area, tui)
|
|
88
|
-
if @
|
|
146
|
+
if @session_picker_active
|
|
147
|
+
render_session_picker(frame, area, tui)
|
|
148
|
+
elsif @view_mode_picker_active
|
|
149
|
+
render_view_mode_picker(frame, area, tui)
|
|
150
|
+
elsif @command_mode
|
|
89
151
|
render_menu(frame, area, tui)
|
|
90
152
|
else
|
|
91
153
|
render_info(frame, area, tui)
|
|
@@ -107,6 +169,15 @@ module TUI
|
|
|
107
169
|
|
|
108
170
|
def render_info(frame, area, tui)
|
|
109
171
|
session = @screens[:chat].session_info
|
|
172
|
+
view_mode = @screens[:chat].view_mode
|
|
173
|
+
|
|
174
|
+
mode_label = view_mode.capitalize
|
|
175
|
+
mode_color = case view_mode
|
|
176
|
+
when "verbose" then "yellow"
|
|
177
|
+
when "debug" then "magenta"
|
|
178
|
+
else "cyan"
|
|
179
|
+
end
|
|
180
|
+
|
|
110
181
|
lines = [
|
|
111
182
|
tui.line(spans: [
|
|
112
183
|
tui.span(content: "Anima v#{Anima::VERSION}", style: tui.style(fg: "white"))
|
|
@@ -121,6 +192,14 @@ module TUI
|
|
|
121
192
|
tui.span(content: session[:message_count].to_s, style: tui.style(fg: "cyan"))
|
|
122
193
|
]),
|
|
123
194
|
tui.line(spans: [tui.span(content: "")]),
|
|
195
|
+
tui.line(spans: [
|
|
196
|
+
tui.span(content: "Mode ", style: tui.style(fg: "dark_gray")),
|
|
197
|
+
tui.span(content: mode_label, style: tui.style(fg: mode_color, modifiers: [:bold]))
|
|
198
|
+
]),
|
|
199
|
+
interaction_state_line(tui),
|
|
200
|
+
tui.line(spans: [tui.span(content: "")]),
|
|
201
|
+
connection_status_line(tui),
|
|
202
|
+
tui.line(spans: [tui.span(content: "")]),
|
|
124
203
|
tui.line(spans: [
|
|
125
204
|
tui.span(content: "Ctrl+a", style: tui.style(fg: "cyan", modifiers: [:bold])),
|
|
126
205
|
tui.span(content: " command mode", style: tui.style(fg: "dark_gray"))
|
|
@@ -139,35 +218,38 @@ module TUI
|
|
|
139
218
|
frame.render_widget(info, area)
|
|
140
219
|
end
|
|
141
220
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
tui.
|
|
221
|
+
# Builds the interaction state line for the info panel.
|
|
222
|
+
# Shows "Thinking..." during LLM processing.
|
|
223
|
+
def interaction_state_line(tui)
|
|
224
|
+
if chat_loading?
|
|
225
|
+
tui.line(spans: [
|
|
226
|
+
tui.span(content: "Thinking...", style: tui.style(fg: "magenta", modifiers: [:bold]))
|
|
227
|
+
])
|
|
147
228
|
else
|
|
148
|
-
tui.
|
|
229
|
+
tui.line(spans: [tui.span(content: "")])
|
|
149
230
|
end
|
|
150
|
-
|
|
151
|
-
conn_span = connection_status_span(tui)
|
|
152
|
-
|
|
153
|
-
widget = tui.paragraph(text: tui.line(spans: [mode_span, conn_span]))
|
|
154
|
-
frame.render_widget(widget, area)
|
|
155
231
|
end
|
|
156
232
|
|
|
157
|
-
|
|
233
|
+
# Builds the connection status line for the info panel.
|
|
234
|
+
# Shows a single emoji for the normal (subscribed) state; adds descriptive
|
|
235
|
+
# text only when something requires attention.
|
|
236
|
+
# @param tui [RatatuiRuby] TUI rendering context
|
|
237
|
+
# @return [RatatuiRuby::Widgets::Line] styled status line with emoji indicator
|
|
238
|
+
def connection_status_line(tui)
|
|
158
239
|
cable_status = @cable_client.status
|
|
240
|
+
style = STATUS_STYLES.fetch(cable_status, STATUS_STYLES[:disconnected])
|
|
159
241
|
|
|
160
|
-
if cable_status == :reconnecting
|
|
242
|
+
label = if cable_status == :reconnecting
|
|
161
243
|
attempt = @cable_client.reconnect_attempt
|
|
162
244
|
max = CableClient::MAX_RECONNECT_ATTEMPTS
|
|
163
|
-
label
|
|
164
|
-
style = STATUS_STYLES[:reconnecting]
|
|
245
|
+
"#{style[:label]} (#{attempt}/#{max})"
|
|
165
246
|
else
|
|
166
|
-
style
|
|
167
|
-
label = style[:label]
|
|
247
|
+
style[:label]
|
|
168
248
|
end
|
|
169
249
|
|
|
170
|
-
tui.
|
|
250
|
+
tui.line(spans: [
|
|
251
|
+
tui.span(content: label, style: tui.style(fg: style[:color], modifiers: [:bold]))
|
|
252
|
+
])
|
|
171
253
|
end
|
|
172
254
|
|
|
173
255
|
def chat_loading?
|
|
@@ -178,7 +260,13 @@ module TUI
|
|
|
178
260
|
return nil if event.none?
|
|
179
261
|
return :quit if event.ctrl_c?
|
|
180
262
|
|
|
181
|
-
if @
|
|
263
|
+
if @token_setup_active
|
|
264
|
+
handle_token_setup(event)
|
|
265
|
+
elsif @session_picker_active
|
|
266
|
+
handle_session_picker(event)
|
|
267
|
+
elsif @view_mode_picker_active
|
|
268
|
+
handle_view_mode_picker(event)
|
|
269
|
+
elsif @command_mode
|
|
182
270
|
handle_command_mode(event)
|
|
183
271
|
else
|
|
184
272
|
handle_normal_mode(event)
|
|
@@ -194,18 +282,24 @@ module TUI
|
|
|
194
282
|
case action
|
|
195
283
|
when :quit
|
|
196
284
|
:quit
|
|
285
|
+
when :anthropic_token
|
|
286
|
+
activate_token_setup
|
|
287
|
+
nil
|
|
197
288
|
when :new_session
|
|
198
289
|
@screens[:chat].new_session
|
|
199
290
|
@current_screen = :chat
|
|
200
291
|
nil
|
|
201
|
-
when :
|
|
202
|
-
|
|
292
|
+
when :session_picker
|
|
293
|
+
activate_session_picker
|
|
294
|
+
nil
|
|
295
|
+
when :view_mode
|
|
296
|
+
activate_view_mode_picker
|
|
203
297
|
nil
|
|
204
298
|
end
|
|
205
299
|
end
|
|
206
300
|
|
|
207
301
|
def handle_normal_mode(event)
|
|
208
|
-
if event.mouse?
|
|
302
|
+
if event.mouse? || event.paste?
|
|
209
303
|
delegate_to_screen(event)
|
|
210
304
|
return nil
|
|
211
305
|
end
|
|
@@ -217,11 +311,6 @@ module TUI
|
|
|
217
311
|
return nil
|
|
218
312
|
end
|
|
219
313
|
|
|
220
|
-
if event.esc? && @current_screen != :chat
|
|
221
|
-
@current_screen = :chat
|
|
222
|
-
return nil
|
|
223
|
-
end
|
|
224
|
-
|
|
225
314
|
delegate_to_screen(event)
|
|
226
315
|
nil
|
|
227
316
|
end
|
|
@@ -235,5 +324,692 @@ module TUI
|
|
|
235
324
|
def ctrl_a?(event)
|
|
236
325
|
event.code == "a" && event.modifiers&.include?("ctrl")
|
|
237
326
|
end
|
|
327
|
+
|
|
328
|
+
# -- Command mode pickers ------------------------------------------
|
|
329
|
+
|
|
330
|
+
# Shared keyboard navigation for Command Mode picker overlays.
|
|
331
|
+
# Handles arrow keys, Enter, Escape, and digit hotkeys.
|
|
332
|
+
#
|
|
333
|
+
# @param event [RatatuiRuby::Event] keyboard event
|
|
334
|
+
# @param items [Array] list of selectable items
|
|
335
|
+
# @param index_ivar [Symbol] instance variable name tracking selected index
|
|
336
|
+
# @return [:close, Object, nil] :close on Escape, selected item on
|
|
337
|
+
# Enter/hotkey, nil otherwise
|
|
338
|
+
def navigate_picker(event, items:, index_ivar:)
|
|
339
|
+
return nil unless event.key?
|
|
340
|
+
return :close if event.esc?
|
|
341
|
+
|
|
342
|
+
current_index = instance_variable_get(index_ivar)
|
|
343
|
+
|
|
344
|
+
if event.up?
|
|
345
|
+
instance_variable_set(index_ivar, [current_index - 1, 0].max)
|
|
346
|
+
return nil
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
if event.down?
|
|
350
|
+
max = [items.size - 1, 0].max
|
|
351
|
+
instance_variable_set(index_ivar, [current_index + 1, max].min)
|
|
352
|
+
return nil
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
if event.enter? && items.any?
|
|
356
|
+
return items[current_index]
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
idx = hotkey_to_index(event.code)
|
|
360
|
+
if idx && idx < items.size
|
|
361
|
+
return items[idx]
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
nil
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Maps digit key codes to picker list indices.
|
|
368
|
+
# Keys 1-9 map to indices 0-8, key 0 maps to index 9.
|
|
369
|
+
#
|
|
370
|
+
# @param code [String] the key code
|
|
371
|
+
# @return [Integer, nil] list index, or nil for non-digit keys
|
|
372
|
+
def hotkey_to_index(code)
|
|
373
|
+
return nil unless code.length == 1
|
|
374
|
+
|
|
375
|
+
case code
|
|
376
|
+
when "1".."9" then code.to_i - 1
|
|
377
|
+
when "0" then 9
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Returns the hotkey character for a given picker list position.
|
|
382
|
+
# Positions 0-8 get keys "1"-"9", position 9 gets "0".
|
|
383
|
+
#
|
|
384
|
+
# @param idx [Integer] zero-based list position
|
|
385
|
+
# @return [String, nil] hotkey character, or nil for positions beyond 9
|
|
386
|
+
def picker_hotkey(idx)
|
|
387
|
+
return (idx + 1).to_s if idx < 9
|
|
388
|
+
return "0" if idx == 9
|
|
389
|
+
nil
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# -- Session picker ------------------------------------------------
|
|
393
|
+
|
|
394
|
+
# Requests the session list from the brain and opens the picker overlay.
|
|
395
|
+
# @return [void]
|
|
396
|
+
def activate_session_picker
|
|
397
|
+
@session_picker_active = true
|
|
398
|
+
@session_picker_index = 0
|
|
399
|
+
@cable_client.list_sessions
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Dispatches keyboard events while the session picker overlay is open.
|
|
403
|
+
#
|
|
404
|
+
# @param event [RatatuiRuby::Event] keyboard event
|
|
405
|
+
# @return [nil]
|
|
406
|
+
def handle_session_picker(event)
|
|
407
|
+
sessions = @screens[:chat].sessions_list || []
|
|
408
|
+
result = navigate_picker(event, items: sessions, index_ivar: :@session_picker_index)
|
|
409
|
+
|
|
410
|
+
case result
|
|
411
|
+
when :close
|
|
412
|
+
@session_picker_active = false
|
|
413
|
+
when Hash
|
|
414
|
+
pick_session(result)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
nil
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Switches to the selected session and closes the picker.
|
|
421
|
+
#
|
|
422
|
+
# @param session [Hash] session entry from sessions_list
|
|
423
|
+
# @return [void]
|
|
424
|
+
def pick_session(session)
|
|
425
|
+
return unless session
|
|
426
|
+
|
|
427
|
+
@session_picker_active = false
|
|
428
|
+
@screens[:chat].switch_session(session["id"])
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Renders the session picker overlay in the sidebar.
|
|
432
|
+
# Shows a loading indicator until the sessions_list arrives from the brain.
|
|
433
|
+
#
|
|
434
|
+
# @param frame [RatatuiRuby::Frame] terminal frame for widget rendering
|
|
435
|
+
# @param area [RatatuiRuby::Rect] sidebar area to render into
|
|
436
|
+
# @param tui [RatatuiRuby] TUI rendering API
|
|
437
|
+
# @return [void]
|
|
438
|
+
def render_session_picker(frame, area, tui)
|
|
439
|
+
sessions = @screens[:chat].sessions_list
|
|
440
|
+
current_id = @screens[:chat].session_info[:id]
|
|
441
|
+
|
|
442
|
+
if sessions.nil?
|
|
443
|
+
lines = [tui.line(spans: [
|
|
444
|
+
tui.span(content: "Loading...", style: tui.style(fg: "yellow"))
|
|
445
|
+
])]
|
|
446
|
+
else
|
|
447
|
+
lines = sessions.each_with_index.flat_map do |session, idx|
|
|
448
|
+
format_session_picker_entry(tui, session, idx, current_id)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
if lines.empty?
|
|
452
|
+
lines = [tui.line(spans: [
|
|
453
|
+
tui.span(content: "No sessions", style: tui.style(fg: "dark_gray"))
|
|
454
|
+
])]
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
picker = tui.paragraph(
|
|
459
|
+
text: lines,
|
|
460
|
+
block: tui.block(
|
|
461
|
+
title: "Sessions",
|
|
462
|
+
borders: [:all],
|
|
463
|
+
border_type: :rounded,
|
|
464
|
+
border_style: {fg: "cyan"}
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
frame.render_widget(picker, area)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Formats a single session entry for the picker. Highlights the selected
|
|
471
|
+
# entry and marks the currently active session.
|
|
472
|
+
#
|
|
473
|
+
# @param tui [RatatuiRuby] TUI rendering API
|
|
474
|
+
# @param session [Hash] session data with "id", "message_count", "updated_at"
|
|
475
|
+
# @param idx [Integer] position in the list (determines hotkey)
|
|
476
|
+
# @param current_id [Integer] the active session's ID
|
|
477
|
+
# @return [Array<RatatuiRuby::Widgets::Line>] single line for this entry
|
|
478
|
+
def format_session_picker_entry(tui, session, idx, current_id)
|
|
479
|
+
selected = idx == @session_picker_index
|
|
480
|
+
is_current = session["id"] == current_id
|
|
481
|
+
|
|
482
|
+
hotkey = picker_hotkey(idx)
|
|
483
|
+
prefix = hotkey ? "[#{hotkey}]" : " "
|
|
484
|
+
marker = is_current ? "*" : " "
|
|
485
|
+
id_label = "##{session["id"]}"
|
|
486
|
+
count = "#{session["message_count"]}msg"
|
|
487
|
+
time = format_relative_time(session["updated_at"])
|
|
488
|
+
|
|
489
|
+
label = "#{prefix}#{marker}#{id_label} #{count} #{time}"
|
|
490
|
+
|
|
491
|
+
style = if selected
|
|
492
|
+
tui.style(fg: "black", bg: "cyan")
|
|
493
|
+
elsif is_current
|
|
494
|
+
tui.style(fg: "cyan", modifiers: [:bold])
|
|
495
|
+
else
|
|
496
|
+
tui.style(fg: "white")
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
[tui.line(spans: [tui.span(content: label, style: style)])]
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# -- View mode picker ----------------------------------------------
|
|
503
|
+
|
|
504
|
+
# Opens the view mode picker overlay. Pre-selects the current mode.
|
|
505
|
+
# @return [void]
|
|
506
|
+
def activate_view_mode_picker
|
|
507
|
+
@view_mode_picker_active = true
|
|
508
|
+
@view_mode_picker_index = Screens::Chat::VIEW_MODES.index(@screens[:chat].view_mode) || 0
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Dispatches keyboard events while the view mode picker is open.
|
|
512
|
+
#
|
|
513
|
+
# @param event [RatatuiRuby::Event] keyboard event
|
|
514
|
+
# @return [nil]
|
|
515
|
+
def handle_view_mode_picker(event)
|
|
516
|
+
result = navigate_picker(event, items: Screens::Chat::VIEW_MODES, index_ivar: :@view_mode_picker_index)
|
|
517
|
+
|
|
518
|
+
case result
|
|
519
|
+
when :close
|
|
520
|
+
@view_mode_picker_active = false
|
|
521
|
+
when String
|
|
522
|
+
pick_view_mode(result)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
nil
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Switches to the selected view mode and closes the picker.
|
|
529
|
+
#
|
|
530
|
+
# @param mode [String] view mode name
|
|
531
|
+
# @return [void]
|
|
532
|
+
def pick_view_mode(mode)
|
|
533
|
+
@view_mode_picker_active = false
|
|
534
|
+
@screens[:chat].switch_view_mode(mode)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Renders the view mode picker overlay in the sidebar.
|
|
538
|
+
#
|
|
539
|
+
# @param frame [RatatuiRuby::Frame] terminal frame for widget rendering
|
|
540
|
+
# @param area [RatatuiRuby::Rect] sidebar area to render into
|
|
541
|
+
# @param tui [RatatuiRuby] TUI rendering API
|
|
542
|
+
# @return [void]
|
|
543
|
+
def render_view_mode_picker(frame, area, tui)
|
|
544
|
+
current_mode = @screens[:chat].view_mode
|
|
545
|
+
|
|
546
|
+
lines = Screens::Chat::VIEW_MODES.each_with_index.flat_map do |mode, idx|
|
|
547
|
+
format_view_mode_entry(tui, mode, idx, current_mode)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
picker = tui.paragraph(
|
|
551
|
+
text: lines,
|
|
552
|
+
block: tui.block(
|
|
553
|
+
title: "View Mode",
|
|
554
|
+
borders: [:all],
|
|
555
|
+
border_type: :rounded,
|
|
556
|
+
border_style: {fg: "cyan"}
|
|
557
|
+
)
|
|
558
|
+
)
|
|
559
|
+
frame.render_widget(picker, area)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Formats a view mode entry with name and description.
|
|
563
|
+
# Highlights the selected entry and marks the current mode.
|
|
564
|
+
#
|
|
565
|
+
# @param tui [RatatuiRuby] TUI rendering API
|
|
566
|
+
# @param mode [String] view mode name
|
|
567
|
+
# @param idx [Integer] position in the list
|
|
568
|
+
# @param current_mode [String] currently active mode
|
|
569
|
+
# @return [Array<RatatuiRuby::Widgets::Line>] name and description lines
|
|
570
|
+
def format_view_mode_entry(tui, mode, idx, current_mode)
|
|
571
|
+
selected = idx == @view_mode_picker_index
|
|
572
|
+
is_current = mode == current_mode
|
|
573
|
+
|
|
574
|
+
hotkey = picker_hotkey(idx)
|
|
575
|
+
prefix = hotkey ? "[#{hotkey}]" : " "
|
|
576
|
+
marker = is_current ? "*" : " "
|
|
577
|
+
|
|
578
|
+
selected_style = tui.style(fg: "black", bg: "cyan")
|
|
579
|
+
|
|
580
|
+
name_style = if selected
|
|
581
|
+
selected_style
|
|
582
|
+
elsif is_current
|
|
583
|
+
tui.style(fg: "cyan", modifiers: [:bold])
|
|
584
|
+
else
|
|
585
|
+
tui.style(fg: "white")
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
desc_style = selected ? selected_style : tui.style(fg: "dark_gray")
|
|
589
|
+
|
|
590
|
+
[
|
|
591
|
+
tui.line(spans: [tui.span(content: "#{prefix}#{marker}#{mode.capitalize}", style: name_style)]),
|
|
592
|
+
tui.line(spans: [tui.span(content: "#{" " * PICKER_PREFIX_WIDTH}#{VIEW_MODE_LABELS[mode]}", style: desc_style)])
|
|
593
|
+
]
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# -- Token setup popup -----------------------------------------------
|
|
597
|
+
|
|
598
|
+
# Opens the token setup popup and resets all input state.
|
|
599
|
+
# Can be triggered manually via Ctrl+a > a or automatically when the
|
|
600
|
+
# brain broadcasts authentication_required.
|
|
601
|
+
# @return [void]
|
|
602
|
+
def activate_token_setup
|
|
603
|
+
@token_setup_active = true
|
|
604
|
+
@token_input_buffer.clear
|
|
605
|
+
@token_setup_error = nil
|
|
606
|
+
@token_setup_status = :idle
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
# Closes the token setup popup and resets all state.
|
|
610
|
+
# @return [void]
|
|
611
|
+
def close_token_setup
|
|
612
|
+
@token_setup_active = false
|
|
613
|
+
@token_input_buffer.clear
|
|
614
|
+
@token_setup_error = nil
|
|
615
|
+
@token_setup_status = :idle
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Polls the chat screen for authentication signals and token save results.
|
|
619
|
+
# Called every render frame so the popup reacts to server responses.
|
|
620
|
+
#
|
|
621
|
+
# State transitions:
|
|
622
|
+
# authentication_required signal → activates popup (if not already open)
|
|
623
|
+
# token_saved result → @token_setup_status becomes :success
|
|
624
|
+
# token_error result → @token_setup_status becomes :error
|
|
625
|
+
#
|
|
626
|
+
# @return [void]
|
|
627
|
+
def check_token_setup_signals
|
|
628
|
+
chat = @screens[:chat]
|
|
629
|
+
|
|
630
|
+
if chat.authentication_required && !@token_setup_active
|
|
631
|
+
activate_token_setup
|
|
632
|
+
chat.clear_authentication_required
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
result = chat.consume_token_save_result
|
|
636
|
+
return unless result
|
|
637
|
+
|
|
638
|
+
if result[:success]
|
|
639
|
+
@token_setup_status = :success
|
|
640
|
+
@token_setup_error = nil
|
|
641
|
+
else
|
|
642
|
+
@token_setup_status = :error
|
|
643
|
+
@token_setup_error = result[:message]
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Dispatches keyboard and paste events while the token setup popup is open.
|
|
648
|
+
#
|
|
649
|
+
# @param event [RatatuiRuby::Event] input event
|
|
650
|
+
# @return [nil]
|
|
651
|
+
def handle_token_setup(event)
|
|
652
|
+
# In success state, any key closes the popup
|
|
653
|
+
if @token_setup_status == :success
|
|
654
|
+
close_token_setup if event.key? || event.paste?
|
|
655
|
+
return nil
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# During validation, ignore all input
|
|
659
|
+
return nil if @token_setup_status == :validating
|
|
660
|
+
|
|
661
|
+
if event.paste?
|
|
662
|
+
@token_input_buffer.insert(event.content)
|
|
663
|
+
@token_setup_error = nil
|
|
664
|
+
@token_setup_status = :idle
|
|
665
|
+
return nil
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
return nil unless event.key?
|
|
669
|
+
|
|
670
|
+
if event.esc?
|
|
671
|
+
close_token_setup
|
|
672
|
+
elsif event.enter?
|
|
673
|
+
submit_token
|
|
674
|
+
elsif event.backspace?
|
|
675
|
+
@token_input_buffer.backspace
|
|
676
|
+
@token_setup_error = nil
|
|
677
|
+
@token_setup_status = :idle
|
|
678
|
+
elsif event.delete?
|
|
679
|
+
@token_input_buffer.delete
|
|
680
|
+
elsif event.left?
|
|
681
|
+
@token_input_buffer.move_left
|
|
682
|
+
elsif event.right?
|
|
683
|
+
@token_input_buffer.move_right
|
|
684
|
+
elsif event.home?
|
|
685
|
+
@token_input_buffer.move_home
|
|
686
|
+
elsif event.end?
|
|
687
|
+
@token_input_buffer.move_end
|
|
688
|
+
elsif printable_token_char?(event)
|
|
689
|
+
@token_input_buffer.insert(event.code)
|
|
690
|
+
@token_setup_error = nil
|
|
691
|
+
@token_setup_status = :idle
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
nil
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Sends the entered token to the brain for validation and storage.
|
|
698
|
+
# @return [void]
|
|
699
|
+
def submit_token
|
|
700
|
+
token = @token_input_buffer.text.strip
|
|
701
|
+
return if token.empty?
|
|
702
|
+
|
|
703
|
+
@token_setup_status = :validating
|
|
704
|
+
@token_setup_error = nil
|
|
705
|
+
@cable_client.save_token(token)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# @param event [RatatuiRuby::Event] keyboard event
|
|
709
|
+
# @return [Boolean] true if the key is a printable character without ctrl
|
|
710
|
+
def printable_token_char?(event)
|
|
711
|
+
return false if event.modifiers&.include?("ctrl")
|
|
712
|
+
|
|
713
|
+
event.code.length == 1 && event.code.match?(PRINTABLE_CHAR)
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# Renders the token setup popup as a centered overlay on the full terminal area.
|
|
717
|
+
# Uses the Clear widget to prevent background content from bleeding through.
|
|
718
|
+
#
|
|
719
|
+
# @param frame [RatatuiRuby::Frame] terminal frame
|
|
720
|
+
# @param area [RatatuiRuby::Rect] full terminal area
|
|
721
|
+
# @param tui [RatatuiRuby] TUI rendering API
|
|
722
|
+
# @return [void]
|
|
723
|
+
def render_token_setup_popup(frame, area, tui)
|
|
724
|
+
popup_area = centered_popup_area(tui, area)
|
|
725
|
+
|
|
726
|
+
frame.render_widget(tui.clear, popup_area)
|
|
727
|
+
|
|
728
|
+
border_color = case @token_setup_status
|
|
729
|
+
when :success then "green"
|
|
730
|
+
when :error then "red"
|
|
731
|
+
else "yellow"
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
lines = build_token_setup_lines(tui)
|
|
735
|
+
|
|
736
|
+
popup = tui.paragraph(
|
|
737
|
+
text: lines,
|
|
738
|
+
wrap: true,
|
|
739
|
+
block: tui.block(
|
|
740
|
+
title: "Anthropic Token Setup",
|
|
741
|
+
borders: [:all],
|
|
742
|
+
border_type: :rounded,
|
|
743
|
+
border_style: {fg: border_color}
|
|
744
|
+
)
|
|
745
|
+
)
|
|
746
|
+
frame.render_widget(popup, popup_area)
|
|
747
|
+
|
|
748
|
+
set_token_input_cursor(frame, popup_area) if token_cursor_visible?
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
# Builds the text lines for the token setup popup.
|
|
752
|
+
# @param tui [RatatuiRuby] TUI rendering API
|
|
753
|
+
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
754
|
+
def build_token_setup_lines(tui)
|
|
755
|
+
lines = []
|
|
756
|
+
|
|
757
|
+
# Status
|
|
758
|
+
status_text, status_color = token_status_display
|
|
759
|
+
lines << tui.line(spans: [
|
|
760
|
+
tui.span(content: "Status: ", style: tui.style(fg: "dark_gray")),
|
|
761
|
+
tui.span(content: status_text, style: tui.style(fg: status_color, modifiers: [:bold]))
|
|
762
|
+
])
|
|
763
|
+
lines << tui.line(spans: [tui.span(content: "")])
|
|
764
|
+
|
|
765
|
+
# Instructions
|
|
766
|
+
lines << tui.line(spans: [
|
|
767
|
+
tui.span(content: "Run ", style: tui.style(fg: "white")),
|
|
768
|
+
tui.span(content: "claude setup-token", style: tui.style(fg: "cyan", modifiers: [:bold])),
|
|
769
|
+
tui.span(content: " to get", style: tui.style(fg: "white"))
|
|
770
|
+
])
|
|
771
|
+
lines << tui.line(spans: [
|
|
772
|
+
tui.span(content: "your token, then paste it here.", style: tui.style(fg: "white"))
|
|
773
|
+
])
|
|
774
|
+
lines << tui.line(spans: [tui.span(content: "")])
|
|
775
|
+
|
|
776
|
+
# Token input
|
|
777
|
+
masked = mask_token(@token_input_buffer.text)
|
|
778
|
+
lines << tui.line(spans: [
|
|
779
|
+
tui.span(content: "Token:", style: tui.style(fg: "white", modifiers: [:bold]))
|
|
780
|
+
])
|
|
781
|
+
lines << tui.line(spans: [
|
|
782
|
+
tui.span(content: "> #{masked}", style: tui.style(fg: "white"))
|
|
783
|
+
])
|
|
784
|
+
lines << tui.line(spans: [tui.span(content: "")])
|
|
785
|
+
|
|
786
|
+
# Error or success message
|
|
787
|
+
if @token_setup_error
|
|
788
|
+
lines << tui.line(spans: [
|
|
789
|
+
tui.span(content: @token_setup_error, style: tui.style(fg: "red"))
|
|
790
|
+
])
|
|
791
|
+
lines << tui.line(spans: [tui.span(content: "")])
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
if @token_setup_status == :success
|
|
795
|
+
lines << tui.line(spans: [
|
|
796
|
+
tui.span(content: "Token saved and validated!", style: tui.style(fg: "green", modifiers: [:bold]))
|
|
797
|
+
])
|
|
798
|
+
lines << tui.line(spans: [tui.span(content: "")])
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
# Hints
|
|
802
|
+
hint = case @token_setup_status
|
|
803
|
+
when :success then "[any key] Close"
|
|
804
|
+
when :validating then "Validating..."
|
|
805
|
+
else "[Enter] Save [Esc] Cancel"
|
|
806
|
+
end
|
|
807
|
+
lines << tui.line(spans: [
|
|
808
|
+
tui.span(content: hint, style: tui.style(fg: "dark_gray"))
|
|
809
|
+
])
|
|
810
|
+
|
|
811
|
+
lines
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
# @return [Array(String, String)] [status_text, color] for the current token setup state
|
|
815
|
+
def token_status_display
|
|
816
|
+
case @token_setup_status
|
|
817
|
+
when :success
|
|
818
|
+
["Valid", "green"]
|
|
819
|
+
when :validating
|
|
820
|
+
["Validating...", "yellow"]
|
|
821
|
+
when :error
|
|
822
|
+
["Invalid", "red"]
|
|
823
|
+
else
|
|
824
|
+
if @token_input_buffer.text.empty?
|
|
825
|
+
["Not configured", "dark_gray"]
|
|
826
|
+
else
|
|
827
|
+
["Ready to save", "cyan"]
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
# Masks an Anthropic token for display: shows the first TOKEN_MASK_VISIBLE
|
|
833
|
+
# characters (the prefix) and replaces the rest with stars.
|
|
834
|
+
#
|
|
835
|
+
# @param token [String] raw token text
|
|
836
|
+
# @return [String] masked display text
|
|
837
|
+
def mask_token(token)
|
|
838
|
+
return "" if token.empty?
|
|
839
|
+
return token if token.length <= TOKEN_MASK_VISIBLE
|
|
840
|
+
|
|
841
|
+
visible = token[0...TOKEN_MASK_VISIBLE]
|
|
842
|
+
hidden_count = [token.length - TOKEN_MASK_VISIBLE, TOKEN_MASK_STARS].min
|
|
843
|
+
"#{visible}#{"*" * hidden_count}..."
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
# @return [Boolean] true when the blinking cursor should be shown in the input field
|
|
847
|
+
def token_cursor_visible?
|
|
848
|
+
@token_setup_status == :idle || @token_setup_status == :error
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# Positions the terminal cursor on the token input line inside the popup.
|
|
852
|
+
# The input ">" line is at a fixed offset from the popup top.
|
|
853
|
+
#
|
|
854
|
+
# @param frame [RatatuiRuby::Frame] terminal frame
|
|
855
|
+
# @param popup_area [RatatuiRuby::Rect] popup rectangle
|
|
856
|
+
# @return [void]
|
|
857
|
+
def set_token_input_cursor(frame, popup_area)
|
|
858
|
+
# Content line offsets within the popup (after top border):
|
|
859
|
+
# 0: Status 1: blank 2: Instructions L1 3: Instructions L2
|
|
860
|
+
# 4: blank 5: Token: 6: > (input)
|
|
861
|
+
input_line_offset = 7 # border (1) + 6 content lines
|
|
862
|
+
|
|
863
|
+
masked = mask_token(@token_input_buffer.text)
|
|
864
|
+
prompt_width = 2 # "> " prefix before the masked token text
|
|
865
|
+
cursor_x = popup_area.x + 1 + prompt_width + masked.length # border + prompt + text
|
|
866
|
+
cursor_y = popup_area.y + input_line_offset
|
|
867
|
+
|
|
868
|
+
return unless cursor_x < popup_area.x + popup_area.width - 1 &&
|
|
869
|
+
cursor_y < popup_area.y + popup_area.height - 1
|
|
870
|
+
|
|
871
|
+
frame.set_cursor_position(cursor_x, cursor_y)
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
# Calculates a centered rectangle for the popup overlay.
|
|
875
|
+
#
|
|
876
|
+
# @param tui [RatatuiRuby] TUI rendering API
|
|
877
|
+
# @param area [RatatuiRuby::Rect] full terminal area
|
|
878
|
+
# @return [RatatuiRuby::Rect] centered popup area
|
|
879
|
+
def centered_popup_area(tui, area)
|
|
880
|
+
popup_height = [POPUP_HEIGHT, area.height - 2].min
|
|
881
|
+
v_margin = [(area.height - popup_height) / 2, 0].max
|
|
882
|
+
|
|
883
|
+
_, center_v, _ = tui.split(
|
|
884
|
+
area,
|
|
885
|
+
direction: :vertical,
|
|
886
|
+
constraints: [
|
|
887
|
+
tui.constraint_length(v_margin),
|
|
888
|
+
tui.constraint_length(popup_height),
|
|
889
|
+
tui.constraint_fill(1)
|
|
890
|
+
]
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
popup_width = (area.width * 60 / 100).clamp(POPUP_MIN_WIDTH, area.width - 2)
|
|
894
|
+
h_margin = [(area.width - popup_width) / 2, 0].max
|
|
895
|
+
|
|
896
|
+
_, center, _ = tui.split(
|
|
897
|
+
center_v,
|
|
898
|
+
direction: :horizontal,
|
|
899
|
+
constraints: [
|
|
900
|
+
tui.constraint_length(h_margin),
|
|
901
|
+
tui.constraint_length(popup_width),
|
|
902
|
+
tui.constraint_fill(1)
|
|
903
|
+
]
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
center
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
# Formats an ISO8601 timestamp as a human-readable relative time.
|
|
910
|
+
#
|
|
911
|
+
# @param iso_string [String, nil] ISO8601 timestamp
|
|
912
|
+
# @return [String] e.g. "2m ago", "3h ago", "Mar 12"
|
|
913
|
+
def format_relative_time(iso_string)
|
|
914
|
+
return "" unless iso_string
|
|
915
|
+
|
|
916
|
+
time = Time.parse(iso_string)
|
|
917
|
+
delta = Time.now - time
|
|
918
|
+
|
|
919
|
+
if delta < 60
|
|
920
|
+
"now"
|
|
921
|
+
elsif delta < 3_600
|
|
922
|
+
"#{(delta / 60).to_i}m ago"
|
|
923
|
+
elsif delta < 86_400
|
|
924
|
+
"#{(delta / 3_600).to_i}h ago"
|
|
925
|
+
else
|
|
926
|
+
time.strftime("%b %d")
|
|
927
|
+
end
|
|
928
|
+
rescue ArgumentError
|
|
929
|
+
""
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# -- Signal handling -----------------------------------------------
|
|
933
|
+
|
|
934
|
+
# Traps SIGHUP, SIGTERM, and SIGINT to trigger graceful shutdown.
|
|
935
|
+
# Saves previous handlers so they can be restored when {#run} exits.
|
|
936
|
+
# Must only be called once per {#run} invocation.
|
|
937
|
+
# @return [void]
|
|
938
|
+
def install_signal_handlers
|
|
939
|
+
@previous_signal_handlers = {}
|
|
940
|
+
SHUTDOWN_SIGNALS.each do |signal|
|
|
941
|
+
@previous_signal_handlers[signal] = Signal.trap(signal) { @shutdown_requested = true }
|
|
942
|
+
rescue ArgumentError
|
|
943
|
+
# Signal not supported on this platform
|
|
944
|
+
end
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
# Restores signal handlers that were in place before the TUI started.
|
|
948
|
+
# @return [void]
|
|
949
|
+
def restore_signal_handlers
|
|
950
|
+
@previous_signal_handlers.each do |signal, handler|
|
|
951
|
+
Signal.trap(signal, handler || "DEFAULT")
|
|
952
|
+
rescue ArgumentError
|
|
953
|
+
# Signal not restorable
|
|
954
|
+
end
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
# Monitors the controlling terminal in a background thread.
|
|
958
|
+
# RatatuiRuby's Rust layer (crossterm) intercepts SIGHUP at the native level,
|
|
959
|
+
# preventing Ruby signal handlers from running when the PTY master closes
|
|
960
|
+
# (tmux kill-session, SSH disconnect, terminal crash). This watchdog detects
|
|
961
|
+
# terminal loss by probing {CONTROLLING_TERMINAL} and force-exits the process
|
|
962
|
+
# since the main thread is stuck in native Rust code that cannot be interrupted.
|
|
963
|
+
# @return [void]
|
|
964
|
+
def start_terminal_watchdog
|
|
965
|
+
@watchdog_thread = Thread.new { terminal_watchdog_loop }
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
# Stops the watchdog thread, waiting briefly for graceful exit before force-killing.
|
|
969
|
+
# @return [void]
|
|
970
|
+
def stop_terminal_watchdog
|
|
971
|
+
return unless @watchdog_thread
|
|
972
|
+
|
|
973
|
+
@watchdog_thread.join(WATCHDOG_SHUTDOWN_TIMEOUT)
|
|
974
|
+
@watchdog_thread.kill if @watchdog_thread.alive?
|
|
975
|
+
@watchdog_thread = nil
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
# Opens {CONTROLLING_TERMINAL} every {TERMINAL_CHECK_INTERVAL} seconds.
|
|
979
|
+
# File.open (not File.stat) is required because stat only checks the
|
|
980
|
+
# filesystem entry which always exists; open actually probes the device.
|
|
981
|
+
# When the terminal disappears, calls {#handle_terminal_loss}.
|
|
982
|
+
# Exits silently in non-TTY environments (CI, test suites).
|
|
983
|
+
# @see CONTROLLING_TERMINAL
|
|
984
|
+
# @see TERMINAL_CHECK_INTERVAL
|
|
985
|
+
# @return [void]
|
|
986
|
+
def terminal_watchdog_loop
|
|
987
|
+
# Empty block triggers open syscall to probe the device, then immediately closes the FD.
|
|
988
|
+
File.open(CONTROLLING_TERMINAL, "r") {}
|
|
989
|
+
|
|
990
|
+
loop do
|
|
991
|
+
break if @shutdown_requested
|
|
992
|
+
begin
|
|
993
|
+
File.open(CONTROLLING_TERMINAL, "r") {}
|
|
994
|
+
rescue Errno::ENXIO, Errno::EIO, Errno::ENOENT
|
|
995
|
+
handle_terminal_loss
|
|
996
|
+
end
|
|
997
|
+
sleep TERMINAL_CHECK_INTERVAL
|
|
998
|
+
end
|
|
999
|
+
rescue SystemCallError
|
|
1000
|
+
# No controlling terminal — nothing to watch (ENXIO, EIO, ENOENT, EACCES, etc.)
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
# Best-effort WebSocket cleanup followed by immediate process termination.
|
|
1004
|
+
# Uses Kernel.exit!(0) because the main thread is stuck in native Rust FFI
|
|
1005
|
+
# (crossterm poll_event/draw) and cannot be interrupted by Ruby signals.
|
|
1006
|
+
# @return [void]
|
|
1007
|
+
def handle_terminal_loss
|
|
1008
|
+
@cable_client.disconnect
|
|
1009
|
+
rescue
|
|
1010
|
+
nil
|
|
1011
|
+
ensure
|
|
1012
|
+
Kernel.exit!(0)
|
|
1013
|
+
end
|
|
238
1014
|
end
|
|
239
1015
|
end
|