anima-core 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +14 -8
- data/README.md +96 -23
- data/agents/codebase-analyzer.md +1 -1
- data/agents/codebase-pattern-finder.md +1 -1
- data/agents/documentation-researcher.md +1 -1
- data/agents/thoughts-analyzer.md +1 -1
- data/agents/web-search-researcher.md +2 -2
- data/app/channels/session_channel.rb +53 -35
- data/app/decorators/tool_call_decorator.rb +7 -7
- data/app/decorators/user_message_decorator.rb +3 -17
- data/app/jobs/agent_request_job.rb +15 -6
- data/app/jobs/passive_recall_job.rb +6 -11
- data/app/models/concerns/message/broadcasting.rb +1 -0
- data/app/models/goal.rb +14 -0
- data/app/models/message.rb +13 -31
- data/app/models/pending_message.rb +191 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +480 -271
- data/bin/inspect-cassette +144 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/database.yml +1 -0
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/cable_structure.sql +9 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
- data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
- data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
- data/db/queue_structure.sql +61 -0
- data/db/structure.sql +120 -0
- data/lib/agent_loop.rb +53 -51
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +19 -6
- data/lib/analytical_brain/tools/activate_skill.rb +2 -2
- data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
- data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
- data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
- data/lib/analytical_brain/tools/finish_goal.rb +3 -0
- data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
- data/lib/analytical_brain/tools/read_workflow.rb +2 -2
- data/lib/analytical_brain/tools/set_goal.rb +5 -1
- data/lib/analytical_brain/tools/update_goal.rb +5 -1
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +20 -1
- data/lib/anima/settings.rb +37 -2
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/agent_message.rb +14 -0
- data/lib/events/base.rb +1 -1
- data/lib/events/subscribers/persister.rb +12 -18
- data/lib/events/subscribers/subagent_message_router.rb +18 -9
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +91 -50
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +9 -5
- data/lib/mneme/passive_recall.rb +85 -16
- data/lib/mneme/runner.rb +15 -4
- data/lib/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +239 -18
- data/lib/tools/base.rb +22 -0
- data/lib/tools/bash.rb +61 -7
- data/lib/tools/edit.rb +2 -2
- data/lib/tools/mark_goal_completed.rb +85 -0
- data/lib/tools/read.rb +2 -1
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +41 -7
- data/lib/tools/remember.rb +1 -1
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +11 -8
- data/lib/tools/spawn_subagent.rb +19 -13
- data/lib/tools/subagent_prompts.rb +41 -5
- data/lib/tools/think.rb +23 -0
- data/lib/tools/write.rb +1 -1
- data/lib/tui/app.rb +545 -137
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +13 -20
- data/lib/tui/decorators/base_decorator.rb +40 -11
- data/lib/tui/decorators/bash_decorator.rb +3 -3
- data/lib/tui/decorators/edit_decorator.rb +7 -4
- data/lib/tui/decorators/read_decorator.rb +6 -8
- data/lib/tui/decorators/think_decorator.rb +4 -6
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +7 -4
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +33 -0
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +159 -27
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +302 -103
- data/lib/tui/settings.rb +86 -0
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +30 -1
- data/templates/tui.toml +209 -0
- metadata +24 -3
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/environment_probe.rb +0 -232
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TUI
|
|
4
|
+
# Animated braille spinner that communicates session state through distinct
|
|
5
|
+
# visual patterns. Each state gets its own animation — a user watching long
|
|
6
|
+
# enough starts _feeling_ the difference between LLM thinking and tool
|
|
7
|
+
# execution without reading text.
|
|
8
|
+
#
|
|
9
|
+
# The 2x4 braille grid (U+2800-U+28FF) encodes 8 dots in a single character
|
|
10
|
+
# cell. Dot positions map to bit flags:
|
|
11
|
+
#
|
|
12
|
+
# ┌───┬───┐
|
|
13
|
+
# │ 0 │ 3 │ bit 0 = top-left, bit 3 = top-right
|
|
14
|
+
# │ 1 │ 4 │ bit 1 = mid-left, bit 4 = mid-right
|
|
15
|
+
# │ 2 │ 5 │ bit 2 = lower-left, bit 5 = lower-right
|
|
16
|
+
# │ 6 │ 7 │ bit 6 = bottom-left, bit 7 = bottom-right
|
|
17
|
+
# └───┴───┘
|
|
18
|
+
#
|
|
19
|
+
# During LLM generation, a snake weaves through the grid — organic,
|
|
20
|
+
# unpredictable movement like watching a campfire. During tool execution,
|
|
21
|
+
# a fast staccato pulse signals mechanical work. Interrupting decelerates
|
|
22
|
+
# to a freeze.
|
|
23
|
+
#
|
|
24
|
+
# @example Basic usage
|
|
25
|
+
# spinner = BrailleSpinner.new
|
|
26
|
+
# spinner.state = "llm_generating"
|
|
27
|
+
# char = spinner.tick # => "⠋" (braille pattern)
|
|
28
|
+
class BrailleSpinner
|
|
29
|
+
# Clockwise traversal of the 8 dots in the braille grid.
|
|
30
|
+
# Produces a smooth rotating animation — one dot lit at a time.
|
|
31
|
+
SNAKE_FRAMES = [
|
|
32
|
+
0x01, # ⠁ dot 0 (top-left)
|
|
33
|
+
0x02, # ⠂ dot 1 (mid-left)
|
|
34
|
+
0x04, # ⠄ dot 2 (lower-left)
|
|
35
|
+
0x40, # ⡀ dot 6 (bottom-left)
|
|
36
|
+
0x80, # ⢀ dot 7 (bottom-right)
|
|
37
|
+
0x20, # ⠠ dot 5 (lower-right)
|
|
38
|
+
0x10, # ⠐ dot 4 (mid-right)
|
|
39
|
+
0x08 # ⠈ dot 3 (top-right)
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
# Snake animation: 3 consecutive dots form a growing/moving tail.
|
|
43
|
+
# Each frame is the OR of 3 adjacent positions in SNAKE_FRAMES,
|
|
44
|
+
# creating a worm-like creature circling the grid.
|
|
45
|
+
SNAKE_TRAIL_FRAMES = SNAKE_FRAMES.each_index.map { |idx|
|
|
46
|
+
SNAKE_FRAMES[idx] | SNAKE_FRAMES[(idx + 1) % 8] | SNAKE_FRAMES[(idx + 2) % 8]
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# Tool execution: alternating dot patterns for a staccato pulse.
|
|
50
|
+
# Fast, mechanical, clearly different from the smooth snake.
|
|
51
|
+
TOOL_FRAMES = [
|
|
52
|
+
0x09, # ⠉ dots 0+3 (top row)
|
|
53
|
+
0x12, # ⠒ dots 1+4 (middle row)
|
|
54
|
+
0x24, # ⠤ dots 2+5 (lower row)
|
|
55
|
+
0xC0, # ⣀ dots 6+7 (bottom row)
|
|
56
|
+
0x24, # ⠤ dots 2+5 (lower row)
|
|
57
|
+
0x12 # ⠒ dots 1+4 (middle row)
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
# Interrupting: rapid deceleration — full grid fading to empty.
|
|
61
|
+
INTERRUPT_FRAMES = [
|
|
62
|
+
0xFF, # ⣿ all dots
|
|
63
|
+
0xDB, # ⣛ most dots
|
|
64
|
+
0x49, # ⡉ sparse
|
|
65
|
+
0x00, # ⠀ empty
|
|
66
|
+
0x49, # ⡉ sparse
|
|
67
|
+
0xFF # ⣿ all dots
|
|
68
|
+
].freeze
|
|
69
|
+
|
|
70
|
+
# Braille Unicode block base codepoint.
|
|
71
|
+
BRAILLE_BASE = 0x2800
|
|
72
|
+
|
|
73
|
+
# Ticks per frame for each state — controls animation speed.
|
|
74
|
+
# Higher = slower. At ~15fps render loop: 2 = ~7.5fps, 4 = ~3.75fps.
|
|
75
|
+
SPEED = {
|
|
76
|
+
"llm_generating" => 2,
|
|
77
|
+
"tool_executing" => 1,
|
|
78
|
+
"interrupting" => 1
|
|
79
|
+
}.freeze
|
|
80
|
+
|
|
81
|
+
# @return [String] current session state
|
|
82
|
+
attr_reader :state
|
|
83
|
+
|
|
84
|
+
def initialize
|
|
85
|
+
@state = "idle"
|
|
86
|
+
@frame_index = 0
|
|
87
|
+
@tick_count = 0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Updates the session state driving the animation.
|
|
91
|
+
# Resets frame position on state change for a clean transition.
|
|
92
|
+
#
|
|
93
|
+
# @param new_state [String] one of "idle", "llm_generating",
|
|
94
|
+
# "tool_executing", "interrupting"
|
|
95
|
+
def state=(new_state)
|
|
96
|
+
if @state != new_state
|
|
97
|
+
@frame_index = 0
|
|
98
|
+
@tick_count = 0
|
|
99
|
+
end
|
|
100
|
+
@state = new_state
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Advances the animation by one tick and returns the current
|
|
104
|
+
# braille character. Returns nil when idle (no animation).
|
|
105
|
+
#
|
|
106
|
+
# @return [String, nil] single braille character, or nil when idle
|
|
107
|
+
def tick
|
|
108
|
+
return nil if @state == "idle"
|
|
109
|
+
|
|
110
|
+
frames = frames_for_state
|
|
111
|
+
return nil unless frames
|
|
112
|
+
|
|
113
|
+
speed = SPEED.fetch(@state, 2)
|
|
114
|
+
@tick_count += 1
|
|
115
|
+
if @tick_count >= speed
|
|
116
|
+
@tick_count = 0
|
|
117
|
+
@frame_index = (@frame_index + 1) % frames.size
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
(BRAILLE_BASE + frames[@frame_index]).chr(Encoding::UTF_8)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns the current frame character without advancing.
|
|
124
|
+
#
|
|
125
|
+
# @return [String, nil] single braille character, or nil when idle
|
|
126
|
+
def current
|
|
127
|
+
return nil if @state == "idle"
|
|
128
|
+
|
|
129
|
+
frames = frames_for_state
|
|
130
|
+
return nil unless frames
|
|
131
|
+
|
|
132
|
+
(BRAILLE_BASE + frames[@frame_index]).chr(Encoding::UTF_8)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Whether the spinner is actively animating.
|
|
136
|
+
#
|
|
137
|
+
# @return [Boolean]
|
|
138
|
+
def active?
|
|
139
|
+
@state != "idle"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def frames_for_state
|
|
145
|
+
case @state
|
|
146
|
+
when "llm_generating" then SNAKE_TRAIL_FRAMES
|
|
147
|
+
when "tool_executing" then TOOL_FRAMES
|
|
148
|
+
when "interrupting" then INTERRUPT_FRAMES
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/tui/cable_client.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "websocket-client-simple"
|
|
4
4
|
require "json"
|
|
5
|
+
require_relative "settings"
|
|
5
6
|
|
|
6
7
|
module TUI
|
|
7
8
|
# Action Cable WebSocket client for connecting the TUI to the brain server.
|
|
@@ -23,14 +24,6 @@ module TUI
|
|
|
23
24
|
# messages = client.drain_messages
|
|
24
25
|
# client.disconnect
|
|
25
26
|
class CableClient
|
|
26
|
-
DISCONNECT_TIMEOUT = 2 # seconds to wait for WebSocket thread to finish
|
|
27
|
-
POLL_INTERVAL = 0.1 # seconds between connection status checks
|
|
28
|
-
CONNECTION_TIMEOUT = 10 # seconds to wait for the connecting state to advance
|
|
29
|
-
MAX_RECONNECT_ATTEMPTS = 10
|
|
30
|
-
BACKOFF_BASE = 1.0 # initial backoff delay in seconds
|
|
31
|
-
BACKOFF_CAP = 30.0 # maximum backoff delay
|
|
32
|
-
PING_STALE_THRESHOLD = 6.0 # seconds without ping before connection is stale
|
|
33
|
-
|
|
34
27
|
# Message types queued for the TUI render loop via @message_queue
|
|
35
28
|
MSG_TYPE_CONNECTION = "connection"
|
|
36
29
|
|
|
@@ -129,9 +122,9 @@ module TUI
|
|
|
129
122
|
# Requests the brain to recall (delete) a pending message so the user
|
|
130
123
|
# can edit it before the LLM sees it.
|
|
131
124
|
#
|
|
132
|
-
# @param
|
|
133
|
-
def recall_pending(
|
|
134
|
-
send_action("recall_pending", {"
|
|
125
|
+
# @param pending_message_id [Integer] database ID of the {PendingMessage}
|
|
126
|
+
def recall_pending(pending_message_id)
|
|
127
|
+
send_action("recall_pending", {"pending_message_id" => pending_message_id})
|
|
135
128
|
end
|
|
136
129
|
|
|
137
130
|
# Requests interruption of the current tool execution. The server sets
|
|
@@ -143,7 +136,7 @@ module TUI
|
|
|
143
136
|
end
|
|
144
137
|
|
|
145
138
|
# Sends an Anthropic subscription token to the brain for validation and storage.
|
|
146
|
-
# The token flows directly from TUI input to encrypted
|
|
139
|
+
# The token flows directly from TUI input to the encrypted secrets table — never
|
|
147
140
|
# enters the LLM context window.
|
|
148
141
|
#
|
|
149
142
|
# @param token [String] Anthropic subscription token (sk-ant-oat01-...)
|
|
@@ -180,7 +173,7 @@ module TUI
|
|
|
180
173
|
@status = :disconnected
|
|
181
174
|
end
|
|
182
175
|
@ws&.close
|
|
183
|
-
@ws_thread&.join(
|
|
176
|
+
@ws_thread&.join(Settings.connection_disconnect_timeout)
|
|
184
177
|
end
|
|
185
178
|
|
|
186
179
|
private
|
|
@@ -260,13 +253,13 @@ module TUI
|
|
|
260
253
|
loop do
|
|
261
254
|
break if @status == :disconnected
|
|
262
255
|
|
|
263
|
-
if @status == :connecting && (Time.now - connection_start) >
|
|
256
|
+
if @status == :connecting && (Time.now - connection_start) > Settings.connection_timeout
|
|
264
257
|
on_disconnected
|
|
265
258
|
break
|
|
266
259
|
end
|
|
267
260
|
|
|
268
261
|
check_stale_connection
|
|
269
|
-
sleep
|
|
262
|
+
sleep Settings.connection_poll_interval
|
|
270
263
|
end
|
|
271
264
|
end
|
|
272
265
|
|
|
@@ -276,7 +269,7 @@ module TUI
|
|
|
276
269
|
def check_stale_connection
|
|
277
270
|
stale = @mutex.synchronize do
|
|
278
271
|
next false unless @last_ping_at && @status == :subscribed
|
|
279
|
-
(Time.now - @last_ping_at) >=
|
|
272
|
+
(Time.now - @last_ping_at) >= Settings.connection_ping_stale_threshold
|
|
280
273
|
end
|
|
281
274
|
|
|
282
275
|
on_disconnected if stale
|
|
@@ -291,12 +284,12 @@ module TUI
|
|
|
291
284
|
@reconnect_attempt
|
|
292
285
|
end
|
|
293
286
|
|
|
294
|
-
if attempt >
|
|
287
|
+
if attempt > Settings.connection_max_reconnect_attempts
|
|
295
288
|
@mutex.synchronize { @status = :disconnected }
|
|
296
289
|
@message_queue << {
|
|
297
290
|
"type" => MSG_TYPE_CONNECTION,
|
|
298
291
|
"status" => STATUS_FAILED,
|
|
299
|
-
"message" => "Reconnection failed after #{
|
|
292
|
+
"message" => "Reconnection failed after #{Settings.connection_max_reconnect_attempts} attempts"
|
|
300
293
|
}
|
|
301
294
|
return false
|
|
302
295
|
end
|
|
@@ -307,7 +300,7 @@ module TUI
|
|
|
307
300
|
"type" => MSG_TYPE_CONNECTION,
|
|
308
301
|
"status" => STATUS_RECONNECTING,
|
|
309
302
|
"attempt" => attempt,
|
|
310
|
-
"max_attempts" =>
|
|
303
|
+
"max_attempts" => Settings.connection_max_reconnect_attempts,
|
|
311
304
|
"delay" => delay.round(1)
|
|
312
305
|
}
|
|
313
306
|
|
|
@@ -321,7 +314,7 @@ module TUI
|
|
|
321
314
|
# @param attempt [Integer] current attempt number (1-based)
|
|
322
315
|
# @return [Float] delay in seconds
|
|
323
316
|
def backoff_delay(attempt)
|
|
324
|
-
max_delay = [
|
|
317
|
+
max_delay = [Settings.connection_backoff_cap, Settings.connection_backoff_base * (2**(attempt - 1))].min
|
|
325
318
|
rand(0.0..max_delay)
|
|
326
319
|
end
|
|
327
320
|
|
|
@@ -19,6 +19,26 @@ module TUI
|
|
|
19
19
|
# @example Render a tool call
|
|
20
20
|
# decorator = TUI::Decorators::BaseDecorator.for(data)
|
|
21
21
|
# lines = decorator.render(tui)
|
|
22
|
+
# Shared rendering for file-related tool decorators (read, write, edit).
|
|
23
|
+
# Extracts the file path from input and displays it in the header line,
|
|
24
|
+
# making the target file immediately visible — like bash shows its command.
|
|
25
|
+
module FileCallBehavior
|
|
26
|
+
def render_call(tui)
|
|
27
|
+
style = tui.style(fg: color)
|
|
28
|
+
input_lines = data["input"].to_s.split("\n", -1)
|
|
29
|
+
path_line = input_lines.first.to_s
|
|
30
|
+
|
|
31
|
+
header = build_call_header
|
|
32
|
+
header = "#{header} #{path_line}" unless path_line.empty?
|
|
33
|
+
lines = [tui.line(spans: [tui.span(content: header, style: style)])]
|
|
34
|
+
|
|
35
|
+
input_lines.drop(1).each do |line|
|
|
36
|
+
lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)])
|
|
37
|
+
end
|
|
38
|
+
lines
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
22
42
|
class BaseDecorator
|
|
23
43
|
include Formatting
|
|
24
44
|
|
|
@@ -65,12 +85,14 @@ module TUI
|
|
|
65
85
|
header = build_call_header
|
|
66
86
|
lines = [tui.line(spans: [tui.span(content: header, style: style)])]
|
|
67
87
|
data["input"].to_s.split("\n", -1).each do |line|
|
|
68
|
-
lines << tui.line(spans: [tui.span(content: " #{line}", style: style)])
|
|
88
|
+
lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)])
|
|
69
89
|
end
|
|
70
90
|
lines
|
|
71
91
|
end
|
|
72
92
|
|
|
73
93
|
# Generic tool response rendering — success/failure indicator and content.
|
|
94
|
+
# Token counts get their own color-coded span so expensive responses
|
|
95
|
+
# visually jump out in debug mode.
|
|
74
96
|
# Subclasses override for tool-specific presentation.
|
|
75
97
|
#
|
|
76
98
|
# @param tui [RatatuiRuby] TUI rendering API
|
|
@@ -79,17 +101,23 @@ module TUI
|
|
|
79
101
|
indicator = (data["success"] == false) ? ERROR_ICON : CHECKMARK
|
|
80
102
|
tool_id = data["tool_use_id"]
|
|
81
103
|
tokens = data["tokens"]
|
|
104
|
+
style = tui.style(fg: response_color)
|
|
82
105
|
|
|
83
106
|
meta_parts = []
|
|
84
107
|
meta_parts << "[#{tool_id}]" if tool_id
|
|
85
108
|
meta_parts << indicator
|
|
86
|
-
meta_parts << format_token_label(tokens, data["estimated"]) if tokens
|
|
87
109
|
prefix = " #{RETURN_ARROW} #{meta_parts.join(" ")} "
|
|
88
110
|
|
|
89
111
|
content_lines = data["content"].to_s.split("\n", -1)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
112
|
+
first_line_spans = [tui.span(content: prefix, style: style)]
|
|
113
|
+
if tokens
|
|
114
|
+
tok_label = format_token_label(tokens, data["estimated"])
|
|
115
|
+
first_line_spans << tui.span(content: "#{tok_label} ", style: tui.style(fg: token_count_color(tokens)))
|
|
116
|
+
end
|
|
117
|
+
first_line_spans << tui.span(content: content_lines.first.to_s, style: style)
|
|
118
|
+
|
|
119
|
+
lines = [tui.line(spans: first_line_spans)]
|
|
120
|
+
content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)]) }
|
|
93
121
|
lines
|
|
94
122
|
end
|
|
95
123
|
|
|
@@ -108,16 +136,17 @@ module TUI
|
|
|
108
136
|
ICON
|
|
109
137
|
end
|
|
110
138
|
|
|
111
|
-
#
|
|
139
|
+
# Unified color for all tool call headers. Keeps tool invocations
|
|
140
|
+
# visually distinct from conversation messages (user/assistant/thought).
|
|
112
141
|
# @return [String]
|
|
113
142
|
def color
|
|
114
|
-
|
|
143
|
+
Settings.theme_color_accent
|
|
115
144
|
end
|
|
116
145
|
|
|
117
146
|
# Color for tool response content. Subclasses override for tool-specific colors.
|
|
118
147
|
# @return [String]
|
|
119
148
|
def response_color
|
|
120
|
-
|
|
149
|
+
Settings.theme_color_text
|
|
121
150
|
end
|
|
122
151
|
|
|
123
152
|
private
|
|
@@ -152,9 +181,9 @@ module TUI
|
|
|
152
181
|
case tool_name
|
|
153
182
|
when "bash" then BashDecorator
|
|
154
183
|
when "think" then ThinkDecorator
|
|
155
|
-
when "
|
|
156
|
-
when "
|
|
157
|
-
when "
|
|
184
|
+
when "read_file" then ReadDecorator
|
|
185
|
+
when "edit_file" then EditDecorator
|
|
186
|
+
when "write_file" then WriteDecorator
|
|
158
187
|
when "web_get" then WebGetDecorator
|
|
159
188
|
else self
|
|
160
189
|
end
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module TUI
|
|
4
4
|
module Decorators
|
|
5
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.
|
|
6
|
+
# Calls show the shell command with a terminal icon in the unified tool color.
|
|
7
|
+
# Responses use green for success, red for failure — immediate actionable feedback.
|
|
8
8
|
class BashDecorator < BaseDecorator
|
|
9
9
|
ICON = "\u{1F4BB}" # laptop / terminal
|
|
10
10
|
|
|
@@ -13,7 +13,7 @@ module TUI
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def response_color
|
|
16
|
-
(data["success"] == false) ?
|
|
16
|
+
(data["success"] == false) ? Settings.theme_color_error : Settings.theme_color_success
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
end
|
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module TUI
|
|
4
4
|
module Decorators
|
|
5
|
-
# Renders
|
|
6
|
-
# Calls show the file path
|
|
5
|
+
# Renders edit_file tool calls and responses.
|
|
6
|
+
# Calls show the file path in the header line for immediate visibility.
|
|
7
|
+
# Responses use the CRUD Update color (light_yellow) to flag modifications.
|
|
7
8
|
class EditDecorator < BaseDecorator
|
|
9
|
+
include FileCallBehavior
|
|
10
|
+
|
|
8
11
|
ICON = "\u270F\uFE0F" # pencil
|
|
9
12
|
|
|
10
13
|
def icon
|
|
11
14
|
ICON
|
|
12
15
|
end
|
|
13
16
|
|
|
14
|
-
def
|
|
15
|
-
|
|
17
|
+
def response_color
|
|
18
|
+
Settings.theme_tool_update_color
|
|
16
19
|
end
|
|
17
20
|
end
|
|
18
21
|
end
|
|
@@ -2,22 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module TUI
|
|
4
4
|
module Decorators
|
|
5
|
-
# Renders
|
|
6
|
-
# Calls show the file path
|
|
7
|
-
# Responses
|
|
5
|
+
# Renders read_file tool calls and responses.
|
|
6
|
+
# Calls show the file path in the header line for immediate visibility.
|
|
7
|
+
# Responses use the CRUD Read color (light_blue) for informational content.
|
|
8
8
|
class ReadDecorator < BaseDecorator
|
|
9
|
+
include FileCallBehavior
|
|
10
|
+
|
|
9
11
|
ICON = "\u{1F4C4}" # page facing up
|
|
10
12
|
|
|
11
13
|
def icon
|
|
12
14
|
ICON
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
def color
|
|
16
|
-
"cyan"
|
|
17
|
-
end
|
|
18
|
-
|
|
19
17
|
def response_color
|
|
20
|
-
|
|
18
|
+
Settings.theme_tool_read_color
|
|
21
19
|
end
|
|
22
20
|
end
|
|
23
21
|
end
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module TUI
|
|
4
4
|
module Decorators
|
|
5
5
|
# Renders think tool events — the agent's inner reasoning.
|
|
6
|
-
# "aloud" thoughts use
|
|
7
|
-
#
|
|
6
|
+
# Both "aloud" and "inner" thoughts use grey (dark_gray) to visually
|
|
7
|
+
# de-emphasize reasoning content vs. actual conversation output.
|
|
8
8
|
class ThinkDecorator < BaseDecorator
|
|
9
9
|
THOUGHT_BUBBLE = "\u{1F4AD}" # thought balloon
|
|
10
10
|
|
|
@@ -17,9 +17,7 @@ module TUI
|
|
|
17
17
|
# @param tui [RatatuiRuby] TUI rendering API
|
|
18
18
|
# @return [Array<RatatuiRuby::Widgets::Line>]
|
|
19
19
|
def render_think(tui)
|
|
20
|
-
|
|
21
|
-
fg = aloud ? "yellow" : "dark_gray"
|
|
22
|
-
style = tui.style(fg: fg)
|
|
20
|
+
style = tui.style(fg: Settings.theme_color_muted)
|
|
23
21
|
ts = data["timestamp"]
|
|
24
22
|
|
|
25
23
|
meta = []
|
|
@@ -28,7 +26,7 @@ module TUI
|
|
|
28
26
|
|
|
29
27
|
content_lines = data["content"].to_s.split("\n", -1)
|
|
30
28
|
lines = [tui.line(spans: [tui.span(content: "#{header} #{content_lines.first}", style: style)])]
|
|
31
|
-
content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: " #{line}", style: style)]) }
|
|
29
|
+
content_lines.drop(1).each { |line| lines << tui.line(spans: [tui.span(content: preserve_indentation(" #{line}"), style: style)]) }
|
|
32
30
|
lines
|
|
33
31
|
end
|
|
34
32
|
end
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module TUI
|
|
4
4
|
module Decorators
|
|
5
5
|
# Renders web_get tool calls and responses.
|
|
6
|
-
# Calls show the URL with a globe icon.
|
|
6
|
+
# Calls show the URL with a globe icon in the unified tool color.
|
|
7
|
+
# Responses use the CRUD Read color (light_blue) for fetched content.
|
|
7
8
|
class WebGetDecorator < BaseDecorator
|
|
8
9
|
ICON = "\u{1F310}" # globe with meridians
|
|
9
10
|
|
|
@@ -11,8 +12,8 @@ module TUI
|
|
|
11
12
|
ICON
|
|
12
13
|
end
|
|
13
14
|
|
|
14
|
-
def
|
|
15
|
-
|
|
15
|
+
def response_color
|
|
16
|
+
Settings.theme_tool_read_color
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
end
|
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module TUI
|
|
4
4
|
module Decorators
|
|
5
|
-
# Renders
|
|
6
|
-
# Calls show the file path
|
|
5
|
+
# Renders write_file tool calls and responses.
|
|
6
|
+
# Calls show the file path in the header line for immediate visibility.
|
|
7
|
+
# Responses use the CRUD Create color (light_green) to signal new content.
|
|
7
8
|
class WriteDecorator < BaseDecorator
|
|
9
|
+
include FileCallBehavior
|
|
10
|
+
|
|
8
11
|
ICON = "\u{1F4DD}" # memo
|
|
9
12
|
|
|
10
13
|
def icon
|
|
11
14
|
ICON
|
|
12
15
|
end
|
|
13
16
|
|
|
14
|
-
def
|
|
15
|
-
|
|
17
|
+
def response_color
|
|
18
|
+
Settings.theme_tool_create_color
|
|
16
19
|
end
|
|
17
20
|
end
|
|
18
21
|
end
|
data/lib/tui/flash.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "settings"
|
|
4
|
+
|
|
3
5
|
module TUI
|
|
4
6
|
# Ephemeral notification system for the TUI, modeled after Rails flash
|
|
5
7
|
# messages. Notifications render as a colored bar at the top of the
|
|
@@ -22,18 +24,19 @@ module TUI
|
|
|
22
24
|
# @example Dismissing
|
|
23
25
|
# flash.dismiss!
|
|
24
26
|
class Flash
|
|
25
|
-
AUTO_DISMISS_SECONDS = 5.0
|
|
26
|
-
|
|
27
|
-
# Flash area occupies at most 1/3 of the chat pane height.
|
|
28
|
-
MAX_HEIGHT_FRACTION = 3
|
|
29
|
-
|
|
30
27
|
Entry = Struct.new(:message, :level, :created_at, keyword_init: true)
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
LEVEL_ICONS = {error: " \u2718 ", warning: " \u26A0 ", info: " \u2139 "}.freeze
|
|
30
|
+
|
|
31
|
+
# Builds level styles from current theme settings.
|
|
32
|
+
# Called per-render so hot-reloaded theme changes take effect immediately.
|
|
33
|
+
def self.level_styles
|
|
34
|
+
{
|
|
35
|
+
error: {fg: Settings.theme_flash_error_fg, bg: Settings.theme_flash_error_bg},
|
|
36
|
+
warning: {fg: Settings.theme_flash_warning_fg, bg: Settings.theme_flash_warning_bg},
|
|
37
|
+
info: {fg: Settings.theme_flash_info_fg, bg: Settings.theme_flash_info_bg}
|
|
38
|
+
}
|
|
39
|
+
end
|
|
37
40
|
|
|
38
41
|
def initialize
|
|
39
42
|
@entries = []
|
|
@@ -81,7 +84,7 @@ module TUI
|
|
|
81
84
|
expire!
|
|
82
85
|
return 0 if @entries.empty?
|
|
83
86
|
|
|
84
|
-
height = [@entries.size, area.height /
|
|
87
|
+
height = [@entries.size, area.height / Settings.flash_max_height_fraction].min
|
|
85
88
|
|
|
86
89
|
flash_area, _ = tui.split(
|
|
87
90
|
area,
|
|
@@ -110,7 +113,7 @@ module TUI
|
|
|
110
113
|
|
|
111
114
|
def expire!
|
|
112
115
|
now = monotonic_now
|
|
113
|
-
@entries.reject! { |entry| now - entry.created_at >
|
|
116
|
+
@entries.reject! { |entry| now - entry.created_at > Settings.flash_auto_dismiss_seconds }
|
|
114
117
|
end
|
|
115
118
|
|
|
116
119
|
def row_rect(area, index, tui)
|
|
@@ -120,10 +123,12 @@ module TUI
|
|
|
120
123
|
end
|
|
121
124
|
|
|
122
125
|
def render_entry(frame, area, entry, tui)
|
|
123
|
-
|
|
126
|
+
styles = self.class.level_styles
|
|
127
|
+
config = styles.fetch(entry.level, styles[:info])
|
|
128
|
+
icon = LEVEL_ICONS.fetch(entry.level, LEVEL_ICONS[:info])
|
|
124
129
|
style = tui.style(fg: config[:fg], bg: config[:bg], modifiers: [:bold])
|
|
125
130
|
|
|
126
|
-
text = "#{
|
|
131
|
+
text = "#{icon}#{entry.message} "
|
|
127
132
|
# Pad to full width so background color fills the entire row
|
|
128
133
|
padded = text.ljust(area.width)
|
|
129
134
|
|
data/lib/tui/formatting.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "settings"
|
|
4
|
+
|
|
3
5
|
module TUI
|
|
4
6
|
# Shared formatting helpers for timestamps and token counts.
|
|
5
7
|
# Used by both the Chat screen and client-side decorators
|
|
@@ -16,6 +18,28 @@ module TUI
|
|
|
16
18
|
"[#{label} tok]"
|
|
17
19
|
end
|
|
18
20
|
|
|
21
|
+
# Returns a semantic color for token count display.
|
|
22
|
+
# Visually flags expensive messages so runaway tool calls or bloated
|
|
23
|
+
# responses jump out immediately in debug mode.
|
|
24
|
+
#
|
|
25
|
+
# Thresholds (empirically tuned from real agent sessions):
|
|
26
|
+
# < 1k → muted (routine, ignorable)
|
|
27
|
+
# < 3k → text (normal)
|
|
28
|
+
# < 10k → warning (notable)
|
|
29
|
+
# < 20k → 208/orange (expensive)
|
|
30
|
+
# ≥ 20k → error (alarm — likely runaway)
|
|
31
|
+
#
|
|
32
|
+
# @param tokens [Integer] token count
|
|
33
|
+
# @return [String, Integer] named color or 256-color index
|
|
34
|
+
def token_count_color(tokens)
|
|
35
|
+
return Settings.theme_color_muted if tokens < 1_000
|
|
36
|
+
return Settings.theme_color_text if tokens < 3_000
|
|
37
|
+
return Settings.theme_color_warning if tokens < 10_000
|
|
38
|
+
return Settings.theme_color_expensive if tokens < 20_000
|
|
39
|
+
|
|
40
|
+
Settings.theme_color_error
|
|
41
|
+
end
|
|
42
|
+
|
|
19
43
|
# Converts nanosecond-precision timestamp to human-readable HH:MM:SS.
|
|
20
44
|
# @param ns [Integer, nil] nanosecond timestamp
|
|
21
45
|
# @return [String] formatted time, or "--:--:--" when nil
|
|
@@ -24,5 +48,14 @@ module TUI
|
|
|
24
48
|
|
|
25
49
|
Time.at(ns / 1_000_000_000.0).strftime("%H:%M:%S")
|
|
26
50
|
end
|
|
51
|
+
|
|
52
|
+
# Replaces leading ASCII spaces with non-breaking spaces (\u00a0).
|
|
53
|
+
# Ratatui's Paragraph widget with wrap:true trims regular leading
|
|
54
|
+
# spaces; NBSP preserves visual indentation in wrapped text.
|
|
55
|
+
# @param text [String] text that may contain leading spaces
|
|
56
|
+
# @return [String] text with leading spaces replaced by NBSP
|
|
57
|
+
def preserve_indentation(text)
|
|
58
|
+
text.gsub(/^( +)/) { "\u00a0" * _1.length }
|
|
59
|
+
end
|
|
27
60
|
end
|
|
28
61
|
end
|
data/lib/tui/input_buffer.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "settings"
|
|
4
|
+
|
|
3
5
|
module TUI
|
|
4
6
|
# Manages editable text with cursor position tracking.
|
|
5
7
|
# Supports multiline input with newline insertion, cursor navigation
|
|
@@ -7,8 +9,6 @@ module TUI
|
|
|
7
9
|
#
|
|
8
10
|
# Pure logic object with no rendering or framework dependencies.
|
|
9
11
|
class InputBuffer
|
|
10
|
-
MAX_LENGTH = 10_000
|
|
11
|
-
|
|
12
12
|
attr_reader :text, :cursor_pos
|
|
13
13
|
|
|
14
14
|
def initialize
|
|
@@ -36,9 +36,9 @@ module TUI
|
|
|
36
36
|
@text.include?("\n")
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
# @return [Boolean] whether the buffer has reached
|
|
39
|
+
# @return [Boolean] whether the buffer has reached Settings.input_max_length
|
|
40
40
|
def full?
|
|
41
|
-
@text.length >=
|
|
41
|
+
@text.length >= Settings.input_max_length
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# Ensures cursor stays within valid bounds after external state changes.
|
|
@@ -48,9 +48,9 @@ module TUI
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
# @param char [String] character(s) to insert at cursor
|
|
51
|
-
# @return [Boolean] true if inserted, false if result would exceed
|
|
51
|
+
# @return [Boolean] true if inserted, false if result would exceed Settings.input_max_length
|
|
52
52
|
def insert(char)
|
|
53
|
-
return false if @text.length + char.length >
|
|
53
|
+
return false if @text.length + char.length > Settings.input_max_length
|
|
54
54
|
|
|
55
55
|
@text = "#{@text[0...@cursor_pos]}#{char}#{@text[@cursor_pos..]}"
|
|
56
56
|
@cursor_pos += char.length
|