anima-core 1.0.2 → 1.1.1

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +51 -0
  4. data/README.md +63 -29
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +30 -11
  7. data/app/decorators/tool_call_decorator.rb +32 -3
  8. data/app/decorators/tool_decorator.rb +57 -0
  9. data/app/decorators/tool_response_decorator.rb +12 -4
  10. data/app/decorators/web_get_tool_decorator.rb +102 -0
  11. data/app/jobs/agent_request_job.rb +93 -23
  12. data/app/jobs/mneme_job.rb +51 -0
  13. data/app/jobs/passive_recall_job.rb +29 -0
  14. data/app/models/concerns/event/broadcasting.rb +4 -0
  15. data/app/models/event.rb +10 -0
  16. data/app/models/goal.rb +27 -0
  17. data/app/models/goal_pinned_event.rb +11 -0
  18. data/app/models/pinned_event.rb +41 -0
  19. data/app/models/session.rb +402 -6
  20. data/app/models/snapshot.rb +76 -0
  21. data/bin/jobs +5 -0
  22. data/config/initializers/event_subscribers.rb +12 -3
  23. data/config/initializers/fts5_schema_dump.rb +21 -0
  24. data/config/queue.yml +0 -1
  25. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  26. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  27. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  28. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  29. data/lib/agent_loop.rb +63 -20
  30. data/lib/analytical_brain/runner.rb +158 -65
  31. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  32. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  33. data/lib/anima/cli.rb +32 -9
  34. data/lib/anima/installer.rb +11 -24
  35. data/lib/anima/settings.rb +59 -0
  36. data/lib/anima/spinner.rb +75 -0
  37. data/lib/anima/version.rb +1 -1
  38. data/lib/environment_probe.rb +4 -4
  39. data/lib/events/bounce_back.rb +37 -0
  40. data/lib/events/subscribers/persister.rb +19 -0
  41. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  42. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  43. data/lib/events/tool_call.rb +5 -3
  44. data/lib/llm/client.rb +19 -9
  45. data/lib/mneme/compressed_viewport.rb +200 -0
  46. data/lib/mneme/l2_runner.rb +138 -0
  47. data/lib/mneme/passive_recall.rb +69 -0
  48. data/lib/mneme/runner.rb +254 -0
  49. data/lib/mneme/search.rb +150 -0
  50. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  51. data/lib/mneme/tools/everything_ok.rb +24 -0
  52. data/lib/mneme/tools/save_snapshot.rb +68 -0
  53. data/lib/mneme.rb +29 -0
  54. data/lib/providers/anthropic.rb +57 -13
  55. data/lib/shell_session.rb +194 -63
  56. data/lib/tasks/fts5.rake +6 -0
  57. data/lib/tools/base.rb +2 -1
  58. data/lib/tools/bash.rb +4 -2
  59. data/lib/tools/registry.rb +22 -3
  60. data/lib/tools/remember.rb +179 -0
  61. data/lib/tools/request_feature.rb +3 -1
  62. data/lib/tools/spawn_specialist.rb +21 -9
  63. data/lib/tools/spawn_subagent.rb +22 -11
  64. data/lib/tools/subagent_prompts.rb +20 -3
  65. data/lib/tools/web_get.rb +21 -10
  66. data/lib/tui/app.rb +222 -125
  67. data/lib/tui/decorators/base_decorator.rb +165 -0
  68. data/lib/tui/decorators/bash_decorator.rb +20 -0
  69. data/lib/tui/decorators/edit_decorator.rb +19 -0
  70. data/lib/tui/decorators/read_decorator.rb +24 -0
  71. data/lib/tui/decorators/think_decorator.rb +36 -0
  72. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  73. data/lib/tui/decorators/write_decorator.rb +19 -0
  74. data/lib/tui/flash.rb +139 -0
  75. data/lib/tui/formatting.rb +28 -0
  76. data/lib/tui/height_map.rb +93 -0
  77. data/lib/tui/message_store.rb +97 -8
  78. data/lib/tui/performance_logger.rb +90 -0
  79. data/lib/tui/screens/chat.rb +358 -133
  80. data/templates/config.toml +47 -0
  81. data/templates/soul.md +1 -1
  82. metadata +83 -4
  83. data/CHANGELOG.md +0 -80
  84. data/Gemfile +0 -17
  85. data/lib/tools/return_result.rb +0 -81
data/lib/tui/app.rb CHANGED
@@ -4,6 +4,7 @@ require "time"
4
4
  require_relative "cable_client"
5
5
  require_relative "input_buffer"
6
6
  require_relative "message_store"
7
+ require_relative "performance_logger"
7
8
  require_relative "screens/chat"
8
9
 
9
10
  module TUI
@@ -12,6 +13,7 @@ module TUI
12
13
 
13
14
  COMMAND_KEYS = {
14
15
  "a" => :anthropic_token,
16
+ "h" => :toggle_hud,
15
17
  "n" => :new_session,
16
18
  "s" => :session_picker,
17
19
  "v" => :view_mode,
@@ -21,7 +23,8 @@ module TUI
21
23
  MENU_LABELS = (COMMAND_KEYS.map { |key, action| "[#{key}] #{action.to_s.tr("_", " ").capitalize}" } +
22
24
  ["[\u2191] Scroll chat", "[\u2193] Return to input"]).freeze
23
25
 
24
- SIDEBAR_WIDTH = 28
26
+ # HUD occupies 1/3 of screen width, clamped to a usable minimum.
27
+ HUD_MIN_WIDTH = 24
25
28
 
26
29
  # Picker entry prefix width: "[N]" (3) + marker (1) + space (1) = 5
27
30
  PICKER_PREFIX_WIDTH = 5
@@ -77,14 +80,20 @@ module TUI
77
80
 
78
81
  attr_reader :current_screen, :command_mode, :session_picker_active,
79
82
  :view_mode_picker_active
83
+ # @return [Boolean] true when the HUD info panel is visible
84
+ attr_reader :hud_visible
80
85
  # @return [Boolean] true when the token setup popup overlay is visible
81
86
  attr_reader :token_setup_active
82
87
  # @return [Boolean] true when graceful shutdown has been requested via signal
83
88
  attr_reader :shutdown_requested
89
+ # @return [TUI::PerformanceLogger] frame timing logger (no-op when debug is off)
90
+ attr_reader :perf_logger
84
91
 
85
92
  # @param cable_client [TUI::CableClient] WebSocket client connected to the brain
86
- def initialize(cable_client:)
93
+ # @param debug [Boolean] enable performance logging to log/tui_performance.log
94
+ def initialize(cable_client:, debug: false)
87
95
  @cable_client = cable_client
96
+ @perf_logger = PerformanceLogger.new(enabled: debug)
88
97
  @current_screen = :chat
89
98
  @command_mode = false
90
99
  @session_picker_active = false
@@ -92,17 +101,19 @@ module TUI
92
101
  @session_picker_page = 0
93
102
  @session_picker_mode = :root
94
103
  @session_picker_parent_id = nil
104
+ @hud_visible = true
95
105
  @view_mode_picker_active = false
96
106
  @view_mode_picker_index = 0
97
107
  @token_setup_active = false
98
108
  @token_input_buffer = InputBuffer.new
99
109
  @token_setup_error = nil
110
+ @token_setup_warning = nil
100
111
  @token_setup_status = :idle
101
112
  @shutdown_requested = false
102
113
  @previous_signal_handlers = {}
103
114
  @watchdog_thread = nil
104
115
  @screens = {
105
- chat: Screens::Chat.new(cable_client: cable_client)
116
+ chat: Screens::Chat.new(cable_client: cable_client, perf_logger: @perf_logger)
106
117
  }
107
118
  end
108
119
 
@@ -130,20 +141,31 @@ module TUI
130
141
  private
131
142
 
132
143
  def render(frame, tui)
133
- content_area, sidebar = tui.split(
134
- frame.area,
135
- direction: :horizontal,
136
- constraints: [
137
- tui.constraint_fill(1),
138
- tui.constraint_length(SIDEBAR_WIDTH)
139
- ]
140
- )
144
+ @perf_logger.start_frame
145
+
146
+ @screens[:chat].hud_hint = !@hud_visible
147
+
148
+ if @hud_visible
149
+ hud_width = [frame.area.width / 3, HUD_MIN_WIDTH].max
150
+ content_area, sidebar = tui.split(
151
+ frame.area,
152
+ direction: :horizontal,
153
+ constraints: [
154
+ tui.constraint_fill(1),
155
+ tui.constraint_length(hud_width)
156
+ ]
157
+ )
141
158
 
142
- @screens[@current_screen].render(frame, content_area, tui)
143
- render_sidebar(frame, sidebar, tui)
159
+ @perf_logger.measure(:chat_render) { @screens[@current_screen].render(frame, content_area, tui) }
160
+ @perf_logger.measure(:sidebar) { render_sidebar(frame, sidebar, tui) }
161
+ else
162
+ @perf_logger.measure(:chat_render) { @screens[@current_screen].render(frame, frame.area, tui) }
163
+ end
144
164
 
145
165
  check_token_setup_signals
146
166
  render_token_setup_popup(frame, frame.area, tui) if @token_setup_active
167
+
168
+ @perf_logger.end_frame
147
169
  end
148
170
 
149
171
  def render_sidebar(frame, area, tui)
@@ -171,10 +193,73 @@ module TUI
171
193
  frame.render_widget(menu, area)
172
194
  end
173
195
 
196
+ # HUD status icons for goal progress and sub-agent activity.
197
+ GOAL_ICON_ACTIVE = "\u25CF" # ●
198
+ GOAL_ICON_IN_PROGRESS = "\u25D0" # ◐
199
+ GOAL_ICON_COMPLETED = "\u2713" # ✓
200
+ CHILD_ICON_RUNNING = "\u25CF" # ●
201
+ CHILD_ICON_IDLE = "\u25CC" # ◌
202
+
174
203
  def render_info(frame, area, tui)
175
204
  session = @screens[:chat].session_info
176
- view_mode = @screens[:chat].view_mode
177
205
 
206
+ # Split into main content area and bottom status bar
207
+ main_area, status_area = tui.split(
208
+ area,
209
+ direction: :vertical,
210
+ constraints: [
211
+ tui.constraint_fill(1),
212
+ tui.constraint_length(3)
213
+ ]
214
+ )
215
+
216
+ render_hud_content(frame, main_area, tui, session)
217
+ render_hud_status_bar(frame, status_area, tui)
218
+ end
219
+
220
+ # Renders the main HUD content: session name, goals, skills,
221
+ # workflow, and sub-agents.
222
+ def render_hud_content(frame, area, tui, session)
223
+ session_label = session[:name] || "##{session[:id]}"
224
+
225
+ lines = [
226
+ tui.line(spans: [
227
+ tui.span(content: "\u{1F4CB} ", style: tui.style(fg: "dark_gray")),
228
+ tui.span(content: session_label, style: tui.style(fg: "cyan", modifiers: [:bold]))
229
+ ]),
230
+ hud_goals_section(tui, session),
231
+ hud_skills_line(tui, session),
232
+ hud_workflow_line(tui, session),
233
+ hud_children_section(tui, session),
234
+ interaction_state_line(tui)
235
+ ].flatten.compact
236
+
237
+ content = tui.paragraph(
238
+ text: lines,
239
+ wrap: true,
240
+ block: tui.block(
241
+ borders: [:left, :top, :right],
242
+ border_type: :rounded,
243
+ border_style: {fg: "white"}
244
+ )
245
+ )
246
+ frame.render_widget(content, area)
247
+ end
248
+
249
+ # Renders the bottom status bar: connection state and model name.
250
+ def render_hud_status_bar(frame, area, tui)
251
+ cable_status = @cable_client.status
252
+ style = STATUS_STYLES.fetch(cable_status, STATUS_STYLES[:disconnected])
253
+
254
+ status_label = if cable_status == :reconnecting
255
+ attempt = @cable_client.reconnect_attempt
256
+ max = CableClient::MAX_RECONNECT_ATTEMPTS
257
+ "#{style[:label]} (#{attempt}/#{max})"
258
+ else
259
+ style[:label]
260
+ end
261
+
262
+ view_mode = @screens[:chat].view_mode
178
263
  mode_label = view_mode.capitalize
179
264
  mode_color = case view_mode
180
265
  when "verbose" then "yellow"
@@ -182,110 +267,133 @@ module TUI
182
267
  else "cyan"
183
268
  end
184
269
 
185
- session_label = session[:name] || "##{session[:id]}"
186
-
187
- lines = [
188
- tui.line(spans: [
189
- tui.span(content: "Anima v#{Anima::VERSION}", style: tui.style(fg: "white"))
190
- ]),
191
- tui.line(spans: [tui.span(content: "")]),
192
- if session[:name]
193
- tui.line(spans: [
194
- tui.span(content: session_label, style: tui.style(fg: "cyan", modifiers: [:bold]))
195
- ])
196
- else
270
+ bar = tui.paragraph(
271
+ text: [
197
272
  tui.line(spans: [
198
- tui.span(content: "Session ", style: tui.style(fg: "dark_gray")),
199
- tui.span(content: session_label, style: tui.style(fg: "cyan", modifiers: [:bold]))
273
+ tui.span(content: status_label, style: tui.style(fg: style[:color], modifiers: [:bold])),
274
+ tui.span(content: " \u2502 ", style: tui.style(fg: "dark_gray")),
275
+ tui.span(content: mode_label, style: tui.style(fg: mode_color, modifiers: [:bold]))
200
276
  ])
201
- end,
202
- tui.line(spans: [
203
- tui.span(content: "Messages ", style: tui.style(fg: "dark_gray")),
204
- tui.span(content: session[:message_count].to_s, style: tui.style(fg: "cyan"))
205
- ]),
206
- active_skills_line(tui, session),
207
- active_workflow_line(tui, session),
208
- goals_line(tui, session),
209
- tui.line(spans: [tui.span(content: "")]),
210
- tui.line(spans: [
211
- tui.span(content: "Mode ", style: tui.style(fg: "dark_gray")),
212
- tui.span(content: mode_label, style: tui.style(fg: mode_color, modifiers: [:bold]))
213
- ]),
214
- interaction_state_line(tui),
215
- tui.line(spans: [tui.span(content: "")]),
216
- connection_status_line(tui),
217
- tui.line(spans: [tui.span(content: "")]),
218
- tui.line(spans: [
219
- tui.span(content: "Ctrl+a", style: tui.style(fg: "cyan", modifiers: [:bold])),
220
- tui.span(content: " command mode", style: tui.style(fg: "dark_gray"))
221
- ])
222
- ].compact
223
-
224
- info = tui.paragraph(
225
- text: lines,
277
+ ],
226
278
  block: tui.block(
227
- title: "Info",
228
- borders: [:all],
279
+ borders: [:left, :bottom, :right],
229
280
  border_type: :rounded,
230
281
  border_style: {fg: "white"}
231
282
  )
232
283
  )
233
- frame.render_widget(info, area)
284
+ frame.render_widget(bar, area)
285
+ end
286
+
287
+ # Builds goal lines with status icons and descriptions.
288
+ # Root goals show as individual lines. A root goal with some completed
289
+ # sub-goals shows the ◐ (in-progress) icon.
290
+ #
291
+ # @return [Array<RatatuiRuby::Widgets::Line>, nil]
292
+ def hud_goals_section(tui, session)
293
+ goal_list = session[:goals]
294
+ return if goal_list.nil? || goal_list.empty?
295
+
296
+ lines = [
297
+ tui.line(spans: [tui.span(content: "")]),
298
+ tui.line(spans: [
299
+ tui.span(content: "\u{1F3AF} Goals", style: tui.style(fg: "dark_gray"))
300
+ ])
301
+ ]
302
+
303
+ goal_list.each do |goal|
304
+ icon, color = goal_icon_and_color(goal)
305
+ lines << tui.line(spans: [
306
+ tui.span(content: " #{icon} ", style: tui.style(fg: color)),
307
+ tui.span(content: goal["description"].to_s, style: tui.style(fg: "white"))
308
+ ])
309
+ end
310
+
311
+ lines
234
312
  end
235
313
 
236
- # Builds the active skills line for the info panel.
237
- # Returns nil when no skills are active so the line is hidden entirely.
238
- # @param tui [RatatuiRuby] TUI rendering context
239
- # @param session [Hash] session info hash containing :active_skills array
240
- # @return [RatatuiRuby::Widgets::Line, nil] styled skills line, or nil when empty
241
- def active_skills_line(tui, session)
314
+ # Returns the status icon and color for a goal.
315
+ # Active goals with some completed sub-goals show as in-progress (◐).
316
+ #
317
+ # @param goal [Hash] goal data with "status" and optional "sub_goals" keys
318
+ # @return [Array(String, String)] icon and color pair
319
+ def goal_icon_and_color(goal)
320
+ if goal["status"] == "completed"
321
+ [GOAL_ICON_COMPLETED, "green"]
322
+ elsif goal["sub_goals"]&.any? { |sg| sg["status"] == "completed" }
323
+ [GOAL_ICON_IN_PROGRESS, "yellow"]
324
+ else
325
+ [GOAL_ICON_ACTIVE, "cyan"]
326
+ end
327
+ end
328
+
329
+ # Builds the skills line with brain emoji.
330
+ # @return [Array<RatatuiRuby::Widgets::Line>, nil]
331
+ def hud_skills_line(tui, session)
242
332
  skills = session[:active_skills]
243
333
  return if skills.nil? || skills.empty?
244
334
 
245
- label = skills.join(", ")
246
- tui.line(spans: [
247
- tui.span(content: "\u{1F4DA} ", style: tui.style(fg: "dark_gray")),
248
- tui.span(content: label, style: tui.style(fg: "yellow"))
249
- ])
335
+ [
336
+ tui.line(spans: [tui.span(content: "")]),
337
+ tui.line(spans: [
338
+ tui.span(content: "\u{1F9E0} ", style: tui.style(fg: "dark_gray")),
339
+ tui.span(content: skills.join(", "), style: tui.style(fg: "yellow"))
340
+ ])
341
+ ]
250
342
  end
251
343
 
252
- # Builds the active workflow line for the info panel.
253
- # Returns nil when no workflow is active so the line is hidden entirely.
254
- # @param tui [RatatuiRuby] TUI rendering context
255
- # @param session [Hash] session info hash containing :active_workflow string
256
- # @return [RatatuiRuby::Widgets::Line, nil] styled workflow line, or nil when empty
257
- def active_workflow_line(tui, session)
344
+ # Builds the workflow line with scroll emoji.
345
+ # @return [Array<RatatuiRuby::Widgets::Line>, nil]
346
+ def hud_workflow_line(tui, session)
258
347
  workflow = session[:active_workflow]
259
348
  return if workflow.nil? || workflow.empty?
260
349
 
261
- tui.line(spans: [
262
- tui.span(content: "\u{1F504} ", style: tui.style(fg: "dark_gray")),
263
- tui.span(content: workflow, style: tui.style(fg: "magenta"))
264
- ])
350
+ [
351
+ tui.line(spans: [tui.span(content: "")]),
352
+ tui.line(spans: [
353
+ tui.span(content: "\u{1F4DC} ", style: tui.style(fg: "dark_gray")),
354
+ tui.span(content: workflow, style: tui.style(fg: "magenta"))
355
+ ])
356
+ ]
265
357
  end
266
358
 
267
- # Builds the active goals line for the info panel.
268
- # Returns nil when no goals exist so the line is hidden entirely.
269
- # Shows root goal count with active/completed breakdown.
270
- # @param tui [RatatuiRuby] TUI rendering context
271
- # @param session [Hash] session info hash containing :goals array
272
- # @return [RatatuiRuby::Widgets::Line, nil] styled goals line, or nil when empty
273
- def goals_line(tui, session)
274
- goal_list = session[:goals]
275
- return if goal_list.nil? || goal_list.empty?
359
+ # Builds the sub-agents section with activity indicators.
360
+ # @return [Array<RatatuiRuby::Widgets::Line>, nil]
361
+ def hud_children_section(tui, session)
362
+ children = session[:children]
363
+ return if children.nil? || children.empty?
276
364
 
277
- active = goal_list.count { |g| g["status"] == "active" }
278
- completed = goal_list.count { |g| g["status"] == "completed" }
279
- label = "#{active} active"
280
- label += ", #{completed} done" if completed > 0
281
- tui.line(spans: [
282
- tui.span(content: "\u{1F3AF} ", style: tui.style(fg: "dark_gray")),
283
- tui.span(content: label, style: tui.style(fg: "green"))
284
- ])
365
+ lines = [
366
+ tui.line(spans: [tui.span(content: "")]),
367
+ tui.line(spans: [
368
+ tui.span(content: "\u{1F465} Sub-agents", style: tui.style(fg: "dark_gray"))
369
+ ])
370
+ ]
371
+
372
+ children.each do |child|
373
+ icon, color = child_icon_and_color(child)
374
+ name = child["name"] || "sub-agent"
375
+ lines << tui.line(spans: [
376
+ tui.span(content: " #{icon} ", style: tui.style(fg: color)),
377
+ tui.span(content: "@#{name}", style: tui.style(fg: "white"))
378
+ ])
379
+ end
380
+
381
+ lines
382
+ end
383
+
384
+ # Returns the activity icon and color for a child session.
385
+ #
386
+ # @param child [Hash] child session data with "processing" key
387
+ # @return [Array(String, String)] icon and color pair
388
+ def child_icon_and_color(child)
389
+ if child["processing"]
390
+ [CHILD_ICON_RUNNING, "yellow"]
391
+ else
392
+ [CHILD_ICON_IDLE, "green"]
393
+ end
285
394
  end
286
395
 
287
- # Builds the interaction state line for the info panel.
288
- # Shows "Scrolling" when chat pane is focused, or "Thinking..." during LLM processing.
396
+ # Shows "Scrolling" when chat pane is focused, "Thinking..." during LLM processing.
289
397
  def interaction_state_line(tui)
290
398
  if @screens[:chat].chat_focused
291
399
  tui.line(spans: [
@@ -295,33 +403,9 @@ module TUI
295
403
  tui.line(spans: [
296
404
  tui.span(content: "Thinking...", style: tui.style(fg: "magenta", modifiers: [:bold]))
297
405
  ])
298
- else
299
- tui.line(spans: [tui.span(content: "")])
300
406
  end
301
407
  end
302
408
 
303
- # Builds the connection status line for the info panel.
304
- # Shows a single emoji for the normal (subscribed) state; adds descriptive
305
- # text only when something requires attention.
306
- # @param tui [RatatuiRuby] TUI rendering context
307
- # @return [RatatuiRuby::Widgets::Line] styled status line with emoji indicator
308
- def connection_status_line(tui)
309
- cable_status = @cable_client.status
310
- style = STATUS_STYLES.fetch(cable_status, STATUS_STYLES[:disconnected])
311
-
312
- label = if cable_status == :reconnecting
313
- attempt = @cable_client.reconnect_attempt
314
- max = CableClient::MAX_RECONNECT_ATTEMPTS
315
- "#{style[:label]} (#{attempt}/#{max})"
316
- else
317
- style[:label]
318
- end
319
-
320
- tui.line(spans: [
321
- tui.span(content: label, style: tui.style(fg: style[:color], modifiers: [:bold]))
322
- ])
323
- end
324
-
325
409
  def chat_loading?
326
410
  @screens[:chat].loading?
327
411
  end
@@ -365,6 +449,9 @@ module TUI
365
449
  when :anthropic_token
366
450
  activate_token_setup
367
451
  nil
452
+ when :toggle_hud
453
+ @hud_visible = !@hud_visible
454
+ nil
368
455
  when :new_session
369
456
  @screens[:chat].new_session
370
457
  @current_screen = :chat
@@ -891,13 +978,14 @@ module TUI
891
978
  # -- Token setup popup -----------------------------------------------
892
979
 
893
980
  # Opens the token setup popup and resets all input state.
894
- # Can be triggered manually via Ctrl+a > a or automatically when the
981
+ # Can be triggered manually via C-a a or automatically when the
895
982
  # brain broadcasts authentication_required.
896
983
  # @return [void]
897
984
  def activate_token_setup
898
985
  @token_setup_active = true
899
986
  @token_input_buffer.clear
900
987
  @token_setup_error = nil
988
+ @token_setup_warning = nil
901
989
  @token_setup_status = :idle
902
990
  end
903
991
 
@@ -907,6 +995,7 @@ module TUI
907
995
  @token_setup_active = false
908
996
  @token_input_buffer.clear
909
997
  @token_setup_error = nil
998
+ @token_setup_warning = nil
910
999
  @token_setup_status = :idle
911
1000
  end
912
1001
 
@@ -933,9 +1022,11 @@ module TUI
933
1022
  if result[:success]
934
1023
  @token_setup_status = :success
935
1024
  @token_setup_error = nil
1025
+ @token_setup_warning = result[:warning]
936
1026
  else
937
1027
  @token_setup_status = :error
938
1028
  @token_setup_error = result[:message]
1029
+ @token_setup_warning = nil
939
1030
  end
940
1031
  end
941
1032
 
@@ -1087,9 +1178,15 @@ module TUI
1087
1178
  end
1088
1179
 
1089
1180
  if @token_setup_status == :success
1090
- lines << tui.line(spans: [
1091
- tui.span(content: "Token saved and validated!", style: tui.style(fg: "green", modifiers: [:bold]))
1092
- ])
1181
+ lines << if @token_setup_warning
1182
+ tui.line(spans: [
1183
+ tui.span(content: "Token saved (API unavailable, validation skipped)", style: tui.style(fg: "yellow", modifiers: [:bold]))
1184
+ ])
1185
+ else
1186
+ tui.line(spans: [
1187
+ tui.span(content: "Token saved and validated!", style: tui.style(fg: "green", modifiers: [:bold]))
1188
+ ])
1189
+ end
1093
1190
  lines << tui.line(spans: [tui.span(content: "")])
1094
1191
  end
1095
1192
 
@@ -1110,7 +1207,7 @@ module TUI
1110
1207
  def token_status_display
1111
1208
  case @token_setup_status
1112
1209
  when :success
1113
- ["Valid", "green"]
1210
+ @token_setup_warning ? ["Saved (unverified)", "yellow"] : ["Valid", "green"]
1114
1211
  when :validating
1115
1212
  ["Validating...", "yellow"]
1116
1213
  when :error
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../formatting"
4
+
5
+ module TUI
6
+ module Decorators
7
+ # Client-side decorator layer for per-tool TUI rendering.
8
+ #
9
+ # Mirrors the server's Draper architecture but with a different
10
+ # specialization axis: server decorators are uniform per EVENT TYPE
11
+ # (tool_call, tool_result, message), while client decorators are
12
+ # unique per TOOL NAME (bash, read_file, web_get) — determining
13
+ # how each tool looks on screen.
14
+ #
15
+ # The factory dispatches on the +tool+ field in the structured data
16
+ # hash received from the server. Unknown tools fall back to generic
17
+ # rendering provided by this base class.
18
+ #
19
+ # @example Render a tool call
20
+ # decorator = TUI::Decorators::BaseDecorator.for(data)
21
+ # lines = decorator.render(tui)
22
+ class BaseDecorator
23
+ include Formatting
24
+
25
+ ICON = "\u{1F527}" # wrench
26
+ CHECKMARK = "\u2713"
27
+ RETURN_ARROW = "\u21A9"
28
+ ERROR_ICON = "\u274C"
29
+
30
+ attr_reader :data
31
+
32
+ def initialize(data)
33
+ @data = data
34
+ end
35
+
36
+ # Factory returning the per-tool decorator for the given data hash.
37
+ #
38
+ # @param data [Hash] structured event data with string keys
39
+ # ("role", "tool", "content", etc.)
40
+ # @return [BaseDecorator] the appropriate per-tool decorator
41
+ def self.for(data)
42
+ tool = resolve_tool(data)
43
+ decorator_for(tool).new(data)
44
+ end
45
+
46
+ # Renders the event, dispatching by role to the appropriate method.
47
+ #
48
+ # @param tui [RatatuiRuby] TUI rendering API
49
+ # @return [Array<RatatuiRuby::Widgets::Line>]
50
+ def render(tui)
51
+ case data["role"].to_s
52
+ when "tool_call" then render_call(tui)
53
+ when "tool_response" then render_response(tui)
54
+ when "think" then render_think(tui)
55
+ end
56
+ end
57
+
58
+ # Generic tool call rendering — icon, tool name, and indented input.
59
+ # Subclasses override for tool-specific presentation.
60
+ #
61
+ # @param tui [RatatuiRuby] TUI rendering API
62
+ # @return [Array<RatatuiRuby::Widgets::Line>]
63
+ def render_call(tui)
64
+ style = tui.style(fg: color)
65
+ header = build_call_header
66
+ lines = [tui.line(spans: [tui.span(content: header, style: style)])]
67
+ data["input"].to_s.split("\n", -1).each do |line|
68
+ lines << tui.line(spans: [tui.span(content: " #{line}", style: style)])
69
+ end
70
+ lines
71
+ end
72
+
73
+ # Generic tool response rendering — success/failure indicator and content.
74
+ # Subclasses override for tool-specific presentation.
75
+ #
76
+ # @param tui [RatatuiRuby] TUI rendering API
77
+ # @return [Array<RatatuiRuby::Widgets::Line>]
78
+ def render_response(tui)
79
+ indicator = (data["success"] == false) ? ERROR_ICON : CHECKMARK
80
+ tool_id = data["tool_use_id"]
81
+ tokens = data["tokens"]
82
+
83
+ meta_parts = []
84
+ meta_parts << "[#{tool_id}]" if tool_id
85
+ meta_parts << indicator
86
+ meta_parts << format_token_label(tokens, data["estimated"]) if tokens
87
+ prefix = " #{RETURN_ARROW} #{meta_parts.join(" ")} "
88
+
89
+ content_lines = data["content"].to_s.split("\n", -1)
90
+ style = tui.style(fg: response_color)
91
+ lines = [tui.line(spans: [tui.span(content: "#{prefix}#{content_lines.first}", style: style)])]
92
+ content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
93
+ lines
94
+ end
95
+
96
+ # Think rendering — delegated to ThinkDecorator, but base provides
97
+ # a fallback that renders as a generic tool call.
98
+ #
99
+ # @param tui [RatatuiRuby] TUI rendering API
100
+ # @return [Array<RatatuiRuby::Widgets::Line>]
101
+ def render_think(tui)
102
+ render_call(tui)
103
+ end
104
+
105
+ # Icon for this tool type. Subclasses override with tool-specific icons.
106
+ # @return [String]
107
+ def icon
108
+ ICON
109
+ end
110
+
111
+ # Color for tool call headers. Subclasses override for tool-specific colors.
112
+ # @return [String]
113
+ def color
114
+ "white"
115
+ end
116
+
117
+ # Color for tool response content. Subclasses override for tool-specific colors.
118
+ # @return [String]
119
+ def response_color
120
+ "white"
121
+ end
122
+
123
+ private
124
+
125
+ # Builds the header line for a tool call entry.
126
+ # @return [String]
127
+ def build_call_header
128
+ ts = data["timestamp"]
129
+ tool_id = data["tool_use_id"]
130
+
131
+ meta = []
132
+ meta << "[#{format_ns_timestamp(ts)}]" if ts
133
+ prefix = meta.empty? ? icon : "#{meta.join(" ")} #{icon}"
134
+ header = "#{prefix} #{data["tool"]}"
135
+ header += " [#{tool_id}]" if tool_id
136
+ header
137
+ end
138
+
139
+ # Resolves the tool name from the data hash.
140
+ # Think events have role "think" but no "tool" field.
141
+ def self.resolve_tool(data)
142
+ role = data["role"].to_s
143
+ return "think" if role == "think"
144
+
145
+ data["tool"].to_s
146
+ end
147
+ private_class_method :resolve_tool
148
+
149
+ # Maps tool name to its decorator class.
150
+ # Unknown tools get the base decorator (generic rendering).
151
+ def self.decorator_for(tool_name)
152
+ case tool_name
153
+ when "bash" then BashDecorator
154
+ when "think" then ThinkDecorator
155
+ when "read" then ReadDecorator
156
+ when "edit" then EditDecorator
157
+ when "write" then WriteDecorator
158
+ when "web_get" then WebGetDecorator
159
+ else self
160
+ end
161
+ end
162
+ private_class_method :decorator_for
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ module Decorators
5
+ # Renders bash tool calls and responses.
6
+ # Calls show the shell command with a terminal icon.
7
+ # Responses use green for success, red for failure.
8
+ class BashDecorator < BaseDecorator
9
+ ICON = "\u{1F4BB}" # laptop / terminal
10
+
11
+ def icon
12
+ ICON
13
+ end
14
+
15
+ def response_color
16
+ (data["success"] == false) ? "red" : "green"
17
+ end
18
+ end
19
+ end
20
+ end