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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +20 -32
  4. data/anima-core.gemspec +1 -0
  5. data/app/channels/session_channel.rb +220 -26
  6. data/app/decorators/agent_message_decorator.rb +24 -0
  7. data/app/decorators/application_decorator.rb +6 -0
  8. data/app/decorators/event_decorator.rb +173 -0
  9. data/app/decorators/system_message_decorator.rb +21 -0
  10. data/app/decorators/tool_call_decorator.rb +48 -0
  11. data/app/decorators/tool_response_decorator.rb +37 -0
  12. data/app/decorators/user_message_decorator.rb +35 -0
  13. data/app/jobs/agent_request_job.rb +31 -2
  14. data/app/jobs/count_event_tokens_job.rb +14 -3
  15. data/app/models/concerns/event/broadcasting.rb +63 -0
  16. data/app/models/event.rb +36 -0
  17. data/app/models/session.rb +46 -14
  18. data/config/application.rb +1 -0
  19. data/config/initializers/event_subscribers.rb +0 -1
  20. data/config/routes.rb +0 -6
  21. data/db/cable_schema.rb +14 -2
  22. data/db/migrate/20260312170000_add_view_mode_to_sessions.rb +7 -0
  23. data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
  24. data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
  25. data/lib/agent_loop.rb +5 -2
  26. data/lib/anima/cli.rb +1 -40
  27. data/lib/anima/version.rb +1 -1
  28. data/lib/events/subscribers/persister.rb +1 -0
  29. data/lib/events/user_message.rb +17 -0
  30. data/lib/providers/anthropic.rb +3 -13
  31. data/lib/tools/edit.rb +227 -0
  32. data/lib/tools/read.rb +152 -0
  33. data/lib/tools/write.rb +86 -0
  34. data/lib/tui/app.rb +831 -55
  35. data/lib/tui/cable_client.rb +79 -31
  36. data/lib/tui/input_buffer.rb +181 -0
  37. data/lib/tui/message_store.rb +162 -14
  38. data/lib/tui/screens/chat.rb +504 -75
  39. metadata +30 -5
  40. data/app/controllers/api/sessions_controller.rb +0 -25
  41. data/lib/events/subscribers/action_cable_bridge.rb +0 -35
  42. data/lib/tui/screens/anthropic.rb +0 -25
  43. 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 settings anthropic].freeze
11
+ SCREENS = %i[chat].freeze
12
12
 
13
13
  COMMAND_KEYS = {
14
+ "a" => :anthropic_token,
14
15
  "n" => :new_session,
15
- "s" => :settings,
16
- "a" => :anthropic,
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
- # Connection status display styles
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: " DISCONNECTED ", fg: "white", bg: "red"},
27
- connecting: {label: " CONNECTING ", fg: "black", bg: "yellow"},
28
- connected: {label: " CONNECTED ", fg: "black", bg: "yellow"},
29
- subscribed: {label: " CONNECTED ", fg: "black", bg: "green"},
30
- reconnecting: {label: " RECONNECTING ", fg: "black", bg: "yellow"}
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
- attr_reader :current_screen, :command_mode
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
- main_area, sidebar = tui.split(
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
- render_status_bar(frame, status_bar, tui)
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 @command_mode
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
- def render_status_bar(frame, area, tui)
143
- mode_span = if @command_mode
144
- tui.span(content: " COMMAND ", style: tui.style(fg: "black", bg: "yellow", modifiers: [:bold]))
145
- elsif chat_loading?
146
- tui.span(content: " THINKING ", style: tui.style(fg: "black", bg: "magenta", modifiers: [:bold]))
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.span(content: " NORMAL ", style: tui.style(fg: "black", bg: "cyan", modifiers: [:bold]))
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
- def connection_status_span(tui)
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 = " RECONNECTING (#{attempt}/#{max}) "
164
- style = STATUS_STYLES[:reconnecting]
245
+ "#{style[:label]} (#{attempt}/#{max})"
165
246
  else
166
- style = STATUS_STYLES.fetch(cable_status, STATUS_STYLES[:disconnected])
167
- label = style[:label]
247
+ style[:label]
168
248
  end
169
249
 
170
- tui.span(content: label, style: tui.style(fg: style[:fg], bg: style[:bg], modifiers: [:bold]))
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 @command_mode
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 :settings, :anthropic
202
- @current_screen = action
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