log_bench 0.2.6 → 0.2.9
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/README.md +20 -0
- data/lib/log_bench/app/clipboard.rb +31 -0
- data/lib/log_bench/app/copy_handler.rb +94 -0
- data/lib/log_bench/app/input_handler.rb +66 -9
- data/lib/log_bench/app/mouse_handler.rb +6 -0
- data/lib/log_bench/app/query_summary.rb +101 -0
- data/lib/log_bench/app/renderer/ansi.rb +74 -53
- data/lib/log_bench/app/renderer/details.rb +123 -113
- data/lib/log_bench/app/renderer/header.rb +1 -1
- data/lib/log_bench/app/renderer/main.rb +4 -0
- data/lib/log_bench/app/state.rb +45 -3
- data/lib/log_bench/version.rb +1 -1
- data/release_gem.sh +208 -0
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a7bf1724c565d5848f6867b14ec19675a50594b369cb3d7729d117b310f4d399
|
4
|
+
data.tar.gz: cc2c21dc13a0981b11c92933ecbe967a6cf98b7a7ff21e27d322e156932e42a0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 61423f51d81a4c5b30d963fb3da1ba4739aa0420b7b388e6b5fb01952cbb7c60eba2b3091ef520fefb81ab67310611d175d45bacb9d00f63199911cf3ab4cc44
|
7
|
+
data.tar.gz: 5a4c48ed233d247ca70d1aaa36f9eb676b598282dfb573055a3200d9002f7978f31c46a44f57e0e4270308a296523862f4670360ab8df3eabb530ac5f7365bcd
|
data/README.md
CHANGED
@@ -116,6 +116,8 @@ log_bench log/development.log
|
|
116
116
|
- **Clear filter**: `c` to clear an active filter (press `escape` or `enter` before pressing `c` to clear)
|
117
117
|
- **Sorting**: `s` to cycle through sort options (timestamp, duration, status)
|
118
118
|
- **Auto-scroll**: `a` to toggle auto-scroll mode
|
119
|
+
- **Copy**: `y` to copy the selected item to clipboard (request details or SQL query)
|
120
|
+
- **Text selection**: `t` to toggle text selection mode (enables mouse text selection)
|
119
121
|
- **Quit**: `q` to exit
|
120
122
|
|
121
123
|
### Filtering
|
@@ -142,6 +144,24 @@ Examples:
|
|
142
144
|
In the right pane you can filter related log lines by text content to find specific SQL queries or anything else
|
143
145
|
you want to find in the logs.
|
144
146
|
|
147
|
+
### Copying Content
|
148
|
+
|
149
|
+
LogBench provides multiple ways to copy content:
|
150
|
+
|
151
|
+
**Smart Copy with `y` key:**
|
152
|
+
- **Left pane**: Copies complete request details (method, path, status, duration, etc.)
|
153
|
+
- **Right pane**: Copies the selected SQL query with its call source location
|
154
|
+
|
155
|
+
**Text Selection Mode:**
|
156
|
+
- Press `t` to toggle text selection mode
|
157
|
+
- When enabled, you can use your mouse to select and copy text normally
|
158
|
+
- When disabled, mouse clicks navigate the interface
|
159
|
+
|
160
|
+
**Clipboard Support:**
|
161
|
+
- **macOS**: Uses `pbcopy` (built-in)
|
162
|
+
- **Linux**: Uses `xclip` or `xsel` (install with your package manager)
|
163
|
+
- **Fallback**: Saves to `/tmp/logbench_copy.txt` if no clipboard tool available
|
164
|
+
|
145
165
|
|
146
166
|
## Log Format
|
147
167
|
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LogBench
|
4
|
+
module App
|
5
|
+
class Clipboard
|
6
|
+
def self.copy(text)
|
7
|
+
new.copy(text)
|
8
|
+
end
|
9
|
+
|
10
|
+
def copy(text)
|
11
|
+
# Try different clipboard commands based on the platform
|
12
|
+
if system("which pbcopy > /dev/null 2>&1")
|
13
|
+
# macOS
|
14
|
+
IO.popen("pbcopy", "w") { |io| io.write(text) }
|
15
|
+
elsif system("which xclip > /dev/null 2>&1")
|
16
|
+
# Linux with xclip
|
17
|
+
IO.popen("xclip -selection clipboard", "w") { |io| io.write(text) }
|
18
|
+
elsif system("which xsel > /dev/null 2>&1")
|
19
|
+
# Linux with xsel
|
20
|
+
IO.popen("xsel --clipboard --input", "w") { |io| io.write(text) }
|
21
|
+
else
|
22
|
+
# Fallback: write to a temporary file
|
23
|
+
temp_file = "/tmp/logbench_copy.txt"
|
24
|
+
File.write(temp_file, text)
|
25
|
+
end
|
26
|
+
rescue
|
27
|
+
# Silently fail - we don't want to crash the TUI
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LogBench
|
4
|
+
module App
|
5
|
+
class CopyHandler
|
6
|
+
def initialize(state, renderer)
|
7
|
+
self.state = state
|
8
|
+
self.renderer = renderer
|
9
|
+
end
|
10
|
+
|
11
|
+
def copy_to_clipboard
|
12
|
+
if state.left_pane_focused?
|
13
|
+
copy_selected_request
|
14
|
+
else
|
15
|
+
copy_selected_detail_entry
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
attr_accessor :state, :renderer
|
22
|
+
|
23
|
+
def copy_selected_request
|
24
|
+
request = state.current_request
|
25
|
+
return unless request
|
26
|
+
|
27
|
+
# Create a comprehensive text representation of the request
|
28
|
+
content = []
|
29
|
+
content << "```"
|
30
|
+
content << "#{request.method} #{request.path} #{request.status}"
|
31
|
+
content << "Duration: #{request.duration}ms" if request.duration
|
32
|
+
content << "Controller: #{request.controller}" if request.controller
|
33
|
+
content << "Action: #{request.action}" if request.action
|
34
|
+
content << "Request ID: #{request.request_id}" if request.request_id
|
35
|
+
content << "Timestamp: #{request.timestamp}" if request.timestamp
|
36
|
+
content << "Params: #{request.params}" if request.params && !request.params.empty?
|
37
|
+
|
38
|
+
# Add query summary if there are related logs
|
39
|
+
if request.related_logs && !request.related_logs.empty?
|
40
|
+
query_summary = QuerySummary.new(request).build_text_summary
|
41
|
+
content << ""
|
42
|
+
content << query_summary
|
43
|
+
end
|
44
|
+
|
45
|
+
content << "```"
|
46
|
+
|
47
|
+
Clipboard.copy(content.join("\n"))
|
48
|
+
end
|
49
|
+
|
50
|
+
def copy_selected_detail_entry
|
51
|
+
request = state.current_request
|
52
|
+
return unless request
|
53
|
+
|
54
|
+
# Get the detail lines for the current request
|
55
|
+
lines = renderer&.get_cached_detail_lines(request)
|
56
|
+
return unless lines
|
57
|
+
|
58
|
+
# Find all unique entry IDs, excluding separator lines
|
59
|
+
entry_ids = lines.reject { |line| line[:separator] }.map { |line| line[:entry_id] }.compact.uniq
|
60
|
+
return if state.detail_selected_entry >= entry_ids.size
|
61
|
+
|
62
|
+
# Get the selected entry ID
|
63
|
+
selected_entry_id = entry_ids[state.detail_selected_entry]
|
64
|
+
return unless selected_entry_id
|
65
|
+
|
66
|
+
# Find all lines belonging to the selected entry
|
67
|
+
selected_lines = lines.select { |line| line[:entry_id] == selected_entry_id }
|
68
|
+
|
69
|
+
# Extract the text content, removing ANSI codes and padding
|
70
|
+
content = selected_lines.map do |line|
|
71
|
+
text = line[:text] || ""
|
72
|
+
# Remove ANSI escape codes and trim padding
|
73
|
+
text.gsub(/\e\[[0-9;]*m/, "").strip
|
74
|
+
end.reject(&:empty?)
|
75
|
+
|
76
|
+
# Check if this is a SQL query by looking for SQL keywords in the content
|
77
|
+
content_text = content.join(" ")
|
78
|
+
is_sql_query = sql_query?(content_text)
|
79
|
+
|
80
|
+
if is_sql_query
|
81
|
+
Clipboard.copy("```sql\n#{content.join("\n")}\n```")
|
82
|
+
else
|
83
|
+
Clipboard.copy(content.join("\n"))
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def sql_query?(text)
|
88
|
+
# Check for common SQL keywords that indicate this is a SQL query
|
89
|
+
sql_keywords = %w[SELECT INSERT UPDATE DELETE TRANSACTION BEGIN COMMIT ROLLBACK SAVEPOINT]
|
90
|
+
sql_keywords.any? { |keyword| text.upcase.include?(keyword) }
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -22,6 +22,7 @@ module LogBench
|
|
22
22
|
self.screen = screen
|
23
23
|
self.renderer = renderer
|
24
24
|
self.mouse_handler = MouseHandler.new(state, screen)
|
25
|
+
self.copy_handler = CopyHandler.new(state, renderer)
|
25
26
|
end
|
26
27
|
|
27
28
|
def handle_input
|
@@ -44,7 +45,7 @@ module LogBench
|
|
44
45
|
|
45
46
|
private
|
46
47
|
|
47
|
-
attr_accessor :state, :screen, :renderer, :mouse_handler
|
48
|
+
attr_accessor :state, :screen, :renderer, :mouse_handler, :copy_handler
|
48
49
|
|
49
50
|
def filter_mode_active?
|
50
51
|
state.filter_mode || state.detail_filter_mode
|
@@ -138,6 +139,8 @@ module LogBench
|
|
138
139
|
when "t", "T"
|
139
140
|
state.toggle_text_selection_mode
|
140
141
|
screen.turn_text_selection_mode(state.text_selection_mode?)
|
142
|
+
when "y", "Y"
|
143
|
+
copy_handler.copy_to_clipboard
|
141
144
|
when ESC
|
142
145
|
handle_escape
|
143
146
|
end
|
@@ -152,16 +155,30 @@ module LogBench
|
|
152
155
|
end
|
153
156
|
|
154
157
|
def handle_up_navigation
|
158
|
+
old_selected = state.selected if state.left_pane_focused?
|
155
159
|
state.navigate_up
|
156
|
-
|
160
|
+
if state.left_pane_focused?
|
161
|
+
state.adjust_scroll_for_selection(visible_height)
|
162
|
+
|
163
|
+
# Reset detail selection when switching requests
|
164
|
+
if old_selected != state.selected
|
165
|
+
state.reset_detail_selection
|
166
|
+
end
|
167
|
+
end
|
157
168
|
end
|
158
169
|
|
159
170
|
def handle_down_navigation
|
160
171
|
if state.left_pane_focused?
|
172
|
+
old_selected = state.selected
|
161
173
|
max_index = state.filtered_requests.size - 1
|
162
174
|
state.selected = [state.selected + 1, max_index].min
|
163
175
|
state.auto_scroll = false
|
164
176
|
state.adjust_scroll_for_selection(visible_height)
|
177
|
+
|
178
|
+
# Reset detail selection when switching requests
|
179
|
+
if old_selected != state.selected
|
180
|
+
state.reset_detail_selection
|
181
|
+
end
|
165
182
|
else
|
166
183
|
state.navigate_down
|
167
184
|
end
|
@@ -169,69 +186,109 @@ module LogBench
|
|
169
186
|
|
170
187
|
def handle_page_down
|
171
188
|
if state.left_pane_focused?
|
189
|
+
old_selected = state.selected
|
172
190
|
page_size = visible_height
|
173
191
|
max_index = state.filtered_requests.size - 1
|
174
192
|
state.selected = [state.selected + page_size, max_index].min
|
175
193
|
state.auto_scroll = false
|
176
194
|
state.adjust_scroll_for_selection(visible_height)
|
195
|
+
|
196
|
+
# Reset detail selection when switching requests
|
197
|
+
if old_selected != state.selected
|
198
|
+
state.reset_detail_selection
|
199
|
+
end
|
177
200
|
else
|
178
|
-
|
201
|
+
page_size = visible_height
|
202
|
+
state.detail_selected_entry += page_size
|
179
203
|
end
|
180
204
|
end
|
181
205
|
|
182
206
|
def handle_page_up
|
183
207
|
if state.left_pane_focused?
|
208
|
+
old_selected = state.selected
|
184
209
|
page_size = visible_height
|
185
210
|
state.selected = [state.selected - page_size, 0].max
|
186
211
|
state.auto_scroll = false
|
187
212
|
state.adjust_scroll_for_selection(visible_height)
|
213
|
+
|
214
|
+
# Reset detail selection when switching requests
|
215
|
+
if old_selected != state.selected
|
216
|
+
state.reset_detail_selection
|
217
|
+
end
|
188
218
|
else
|
189
|
-
|
219
|
+
page_size = visible_height
|
220
|
+
state.detail_selected_entry = [state.detail_selected_entry - page_size, 0].max
|
190
221
|
end
|
191
222
|
end
|
192
223
|
|
193
224
|
def handle_half_page_down
|
194
225
|
if state.left_pane_focused?
|
226
|
+
old_selected = state.selected
|
195
227
|
half_page = visible_height / 2
|
196
228
|
max_index = state.filtered_requests.size - 1
|
197
229
|
state.selected = [state.selected + half_page, max_index].min
|
198
230
|
state.auto_scroll = false
|
199
231
|
state.adjust_scroll_for_selection(visible_height)
|
232
|
+
|
233
|
+
# Reset detail selection when switching requests
|
234
|
+
if old_selected != state.selected
|
235
|
+
state.reset_detail_selection
|
236
|
+
end
|
200
237
|
else
|
201
|
-
|
238
|
+
half_page = visible_height / 2
|
239
|
+
state.detail_selected_entry += half_page
|
202
240
|
end
|
203
241
|
end
|
204
242
|
|
205
243
|
def handle_half_page_up
|
206
244
|
if state.left_pane_focused?
|
245
|
+
old_selected = state.selected
|
207
246
|
half_page = visible_height / 2
|
208
247
|
state.selected = [state.selected - half_page, 0].max
|
209
248
|
state.auto_scroll = false
|
210
249
|
state.adjust_scroll_for_selection(visible_height)
|
250
|
+
|
251
|
+
# Reset detail selection when switching requests
|
252
|
+
if old_selected != state.selected
|
253
|
+
state.reset_detail_selection
|
254
|
+
end
|
211
255
|
else
|
212
|
-
|
256
|
+
half_page = visible_height / 2
|
257
|
+
state.detail_selected_entry = [state.detail_selected_entry - half_page, 0].max
|
213
258
|
end
|
214
259
|
end
|
215
260
|
|
216
261
|
def handle_go_to_top
|
217
262
|
if state.left_pane_focused?
|
263
|
+
old_selected = state.selected
|
218
264
|
state.selected = 0
|
219
265
|
state.auto_scroll = false
|
220
266
|
state.adjust_scroll_for_selection(visible_height)
|
267
|
+
|
268
|
+
# Reset detail selection when switching requests
|
269
|
+
if old_selected != state.selected
|
270
|
+
state.reset_detail_selection
|
271
|
+
end
|
221
272
|
else
|
222
|
-
state.
|
273
|
+
state.detail_selected_entry = 0
|
223
274
|
end
|
224
275
|
end
|
225
276
|
|
226
277
|
def handle_go_to_bottom
|
227
278
|
if state.left_pane_focused?
|
279
|
+
old_selected = state.selected
|
228
280
|
max_index = state.filtered_requests.size - 1
|
229
281
|
state.selected = [max_index, 0].max
|
230
282
|
state.auto_scroll = false
|
231
283
|
state.adjust_scroll_for_selection(visible_height)
|
284
|
+
|
285
|
+
# Reset detail selection when switching requests
|
286
|
+
if old_selected != state.selected
|
287
|
+
state.reset_detail_selection
|
288
|
+
end
|
232
289
|
else
|
233
|
-
#
|
234
|
-
state.
|
290
|
+
# Set to a high value, will be adjusted by bounds checking
|
291
|
+
state.detail_selected_entry = 999999
|
235
292
|
end
|
236
293
|
end
|
237
294
|
|
@@ -40,9 +40,15 @@ module LogBench
|
|
40
40
|
request_index = click_to_request_index(y)
|
41
41
|
return unless request_index
|
42
42
|
|
43
|
+
old_selected = state.selected
|
43
44
|
max_index = state.filtered_requests.size - 1
|
44
45
|
state.selected = [request_index, max_index].min
|
45
46
|
state.auto_scroll = false
|
47
|
+
|
48
|
+
# Reset detail selection when switching requests
|
49
|
+
if old_selected != state.selected
|
50
|
+
state.reset_detail_selection
|
51
|
+
end
|
46
52
|
elsif click_in_right_pane?(x, y)
|
47
53
|
# Switch to right pane
|
48
54
|
state.switch_to_right_pane unless state.right_pane_focused?
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module LogBench
|
4
|
+
module App
|
5
|
+
class QuerySummary
|
6
|
+
def initialize(request)
|
7
|
+
self.request = request
|
8
|
+
end
|
9
|
+
|
10
|
+
def build_stats
|
11
|
+
# Use memoized methods from request object for better performance
|
12
|
+
stats = {
|
13
|
+
total_queries: request.query_count,
|
14
|
+
total_time: request.total_query_time,
|
15
|
+
cached_queries: request.cached_query_count,
|
16
|
+
select: 0,
|
17
|
+
insert: 0,
|
18
|
+
update: 0,
|
19
|
+
delete: 0,
|
20
|
+
transaction: 0
|
21
|
+
}
|
22
|
+
|
23
|
+
# Categorize by operation type for breakdown
|
24
|
+
request.related_logs.each do |log|
|
25
|
+
next unless [:sql, :cache].include?(log.type)
|
26
|
+
|
27
|
+
categorize_sql_operation(log, stats)
|
28
|
+
end
|
29
|
+
|
30
|
+
stats
|
31
|
+
end
|
32
|
+
|
33
|
+
def build_text_summary
|
34
|
+
query_stats = build_stats
|
35
|
+
|
36
|
+
summary_lines = []
|
37
|
+
summary_lines << "Query Summary:"
|
38
|
+
|
39
|
+
if query_stats[:total_queries] > 0
|
40
|
+
summary_lines << build_summary_line(query_stats)
|
41
|
+
|
42
|
+
breakdown_line = build_breakdown_line(query_stats)
|
43
|
+
summary_lines << breakdown_line unless breakdown_line.empty?
|
44
|
+
end
|
45
|
+
|
46
|
+
summary_lines.join("\n")
|
47
|
+
end
|
48
|
+
|
49
|
+
def build_summary_line(query_stats = nil)
|
50
|
+
query_stats ||= build_stats
|
51
|
+
|
52
|
+
summary_parts = ["#{query_stats[:total_queries]} queries"]
|
53
|
+
|
54
|
+
if query_stats[:total_time] > 0
|
55
|
+
time_part = "#{query_stats[:total_time].round(1)}ms total"
|
56
|
+
time_part += ", #{query_stats[:cached_queries]} cached" if query_stats[:cached_queries] > 0
|
57
|
+
summary_parts << "(#{time_part})"
|
58
|
+
elsif query_stats[:cached_queries] > 0
|
59
|
+
summary_parts << "(#{query_stats[:cached_queries]} cached)"
|
60
|
+
end
|
61
|
+
|
62
|
+
summary_parts.join(" ")
|
63
|
+
end
|
64
|
+
|
65
|
+
def build_breakdown_line(query_stats = nil)
|
66
|
+
query_stats ||= build_stats
|
67
|
+
|
68
|
+
breakdown_parts = [
|
69
|
+
("#{query_stats[:select]} SELECT" if query_stats[:select] > 0),
|
70
|
+
("#{query_stats[:insert]} INSERT" if query_stats[:insert] > 0),
|
71
|
+
("#{query_stats[:update]} UPDATE" if query_stats[:update] > 0),
|
72
|
+
("#{query_stats[:delete]} DELETE" if query_stats[:delete] > 0),
|
73
|
+
("#{query_stats[:transaction]} TRANSACTION" if query_stats[:transaction] > 0)
|
74
|
+
].compact
|
75
|
+
|
76
|
+
breakdown_parts.join(", ")
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
attr_accessor :request
|
82
|
+
|
83
|
+
def categorize_sql_operation(log, stats)
|
84
|
+
# Use unified QueryEntry for both SQL and CACHE entries
|
85
|
+
return unless log.is_a?(LogBench::Log::QueryEntry)
|
86
|
+
|
87
|
+
if log.select?
|
88
|
+
stats[:select] += 1
|
89
|
+
elsif log.insert?
|
90
|
+
stats[:insert] += 1
|
91
|
+
elsif log.update?
|
92
|
+
stats[:update] += 1
|
93
|
+
elsif log.delete?
|
94
|
+
stats[:delete] += 1
|
95
|
+
elsif log.transaction? || log.begin? || log.commit? || log.rollback? || log.savepoint?
|
96
|
+
stats[:transaction] += 1
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -39,73 +39,60 @@ module LogBench
|
|
39
39
|
[text]
|
40
40
|
else
|
41
41
|
chunks = []
|
42
|
-
remaining = text
|
43
|
-
active_colors = []
|
44
42
|
|
45
|
-
#
|
46
|
-
text
|
47
|
-
if /\e\[0m/.match?(ansi_code)
|
48
|
-
active_colors.clear
|
49
|
-
else
|
50
|
-
active_colors << ansi_code
|
51
|
-
end
|
52
|
-
end
|
43
|
+
# Parse the text to extract segments with their colors
|
44
|
+
segments = parse_ansi_segments(text)
|
53
45
|
|
54
|
-
|
55
|
-
|
46
|
+
current_chunk = ""
|
47
|
+
current_chunk_length = 0
|
48
|
+
active_color_state = ""
|
56
49
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
50
|
+
segments.each do |segment|
|
51
|
+
if segment[:type] == :ansi
|
52
|
+
# Track color state
|
53
|
+
active_color_state = if segment[:text] == "\e[0m"
|
54
|
+
""
|
61
55
|
else
|
62
|
-
|
56
|
+
segment[:text]
|
63
57
|
end
|
64
|
-
|
58
|
+
current_chunk += segment[:text]
|
65
59
|
else
|
66
|
-
#
|
67
|
-
|
68
|
-
original_pos = 0
|
69
|
-
clean_pos = 0
|
70
|
-
chunk_colors = active_colors.dup
|
71
|
-
|
72
|
-
remaining.each_char.with_index do |char, idx|
|
73
|
-
if /^\e\[[0-9;]*m/.match?(remaining[idx..])
|
74
|
-
# Found ANSI sequence
|
75
|
-
ansi_match = remaining[idx..].match(/^(\e\[[0-9;]*m)/)
|
76
|
-
ansi_code = ansi_match[1]
|
77
|
-
|
78
|
-
if /\e\[0m/.match?(ansi_code)
|
79
|
-
chunk_colors.clear
|
80
|
-
active_colors.clear
|
81
|
-
else
|
82
|
-
chunk_colors << ansi_code unless chunk_colors.include?(ansi_code)
|
83
|
-
active_colors << ansi_code unless active_colors.include?(ansi_code)
|
84
|
-
end
|
60
|
+
# Text segment - check if it fits
|
61
|
+
text_content = segment[:text]
|
85
62
|
|
86
|
-
|
87
|
-
|
88
|
-
else
|
89
|
-
clean_pos += 1
|
90
|
-
original_pos += 1
|
63
|
+
while text_content.length > 0
|
64
|
+
remaining_space = max_width - current_chunk_length
|
91
65
|
|
92
|
-
|
93
|
-
|
66
|
+
if text_content.length <= remaining_space
|
67
|
+
# Entire text fits in current chunk
|
68
|
+
current_chunk += text_content
|
69
|
+
current_chunk_length += text_content.length
|
70
|
+
break
|
71
|
+
else
|
72
|
+
# Need to split the text
|
73
|
+
if remaining_space > 0
|
74
|
+
# Take what fits in current chunk
|
75
|
+
chunk_part = text_content[0...remaining_space]
|
76
|
+
current_chunk += chunk_part
|
77
|
+
text_content = text_content[remaining_space..]
|
94
78
|
end
|
95
|
-
end
|
96
|
-
end
|
97
79
|
|
98
|
-
|
99
|
-
|
100
|
-
active_colors.join("") + chunk_text
|
101
|
-
else
|
102
|
-
chunk_text
|
103
|
-
end
|
80
|
+
# Finish current chunk
|
81
|
+
chunks << current_chunk
|
104
82
|
|
105
|
-
|
83
|
+
# Start new chunk with color state
|
84
|
+
current_chunk = active_color_state
|
85
|
+
current_chunk_length = 0
|
86
|
+
end
|
87
|
+
end
|
106
88
|
end
|
107
89
|
end
|
108
90
|
|
91
|
+
# Add final chunk if it has content
|
92
|
+
if current_chunk.length > 0
|
93
|
+
chunks << current_chunk
|
94
|
+
end
|
95
|
+
|
109
96
|
chunks
|
110
97
|
end
|
111
98
|
end
|
@@ -143,6 +130,40 @@ module LogBench
|
|
143
130
|
|
144
131
|
attr_accessor :screen
|
145
132
|
|
133
|
+
def parse_ansi_segments(text)
|
134
|
+
segments = []
|
135
|
+
remaining = text
|
136
|
+
|
137
|
+
while remaining.length > 0
|
138
|
+
# Look for next ANSI sequence
|
139
|
+
ansi_match = remaining.match(/^(\e\[[0-9;]*m)/)
|
140
|
+
|
141
|
+
if ansi_match
|
142
|
+
# Found ANSI sequence at start
|
143
|
+
segments << {type: :ansi, text: ansi_match[1]}
|
144
|
+
remaining = remaining[ansi_match[1].length..]
|
145
|
+
else
|
146
|
+
# Look for ANSI sequence anywhere in remaining text
|
147
|
+
next_ansi = remaining.match(/(\e\[[0-9;]*m)/)
|
148
|
+
|
149
|
+
if next_ansi
|
150
|
+
# Text before ANSI sequence
|
151
|
+
text_before = remaining[0...next_ansi.begin(1)]
|
152
|
+
if text_before.length > 0
|
153
|
+
segments << {type: :text, text: text_before}
|
154
|
+
end
|
155
|
+
remaining = remaining[next_ansi.begin(1)..]
|
156
|
+
else
|
157
|
+
# No more ANSI sequences, rest is text
|
158
|
+
segments << {type: :text, text: remaining}
|
159
|
+
break
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
segments
|
165
|
+
end
|
166
|
+
|
146
167
|
def ansi_to_curses_color(codes)
|
147
168
|
# Convert ANSI color codes to curses color pairs
|
148
169
|
return nil if codes.empty? || codes == [0]
|