tui-td 0.2.11 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -1
- data/lib/tui_td/ansi_parser.rb +3 -727
- data/lib/tui_td/ansi_utils.rb +3 -73
- data/lib/tui_td/driver.rb +11 -2
- data/lib/tui_td/mcp/server.rb +17 -3
- data/lib/tui_td/state.rb +2 -123
- data/lib/tui_td/test_runner.rb +18 -4
- data/lib/tui_td/version.rb +1 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98d74d9d7481eabaca77e302ae6e86e1c0467222ebeabb26320fc4668c96af72
|
|
4
|
+
data.tar.gz: a666b663abf34a08b08c31e57b0348ae00020f304a6296012a027a26fbbb69a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 22485763c32cac98cb98f8a2b4f2aaf8656c4955c97a28ff7cfdc3a3c2f82252c0b079c75630bb6d0e1ebd21a5c65d5f042be4c20e566502b6d747b88304a95d
|
|
7
|
+
data.tar.gz: e3d209ca2706c701efaf6e98af5d46af21ce7f1772aa0fe1a2ddbe9858442380f866883ac5c8948bb66d447843987e7ba7bfb77a6cc2aa08a6363d09deaa0320
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## 0.2.12
|
|
4
|
+
|
|
5
|
+
### Security
|
|
6
|
+
|
|
7
|
+
- Command injection prevention: use Shellwords.shellsplit + array form of PTY.spawn
|
|
8
|
+
- Environment variable sanitization: block dangerous vars (PATH, LD_PRELOAD, etc.)
|
|
9
|
+
- Path traversal prevention: validate output paths for screenshot/HTML
|
|
10
|
+
- ReDoS prevention: add regex timeout in find_text
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- ANSI erase operations (ED/EL) now reset all cell attributes (fg, bg, bold, italic,
|
|
15
|
+
underline), not just the character — colors and styles no longer leak across lines
|
|
16
|
+
|
|
17
|
+
### Architecture
|
|
18
|
+
|
|
19
|
+
- Extract ANSIParser, ANSIUtils, and State into standalone tans-parser gem (v0.1.0)
|
|
20
|
+
- Add tans-parser as a runtime dependency (~>0.1)
|
|
21
|
+
- Replace extracted unit tests with forwarder integration smoke tests
|
|
4
22
|
|
|
5
23
|
## 0.2.11
|
|
6
24
|
|
data/lib/tui_td/ansi_parser.rb
CHANGED
|
@@ -1,731 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
# Parses raw terminal output (ANSI escape sequences + text) into a
|
|
5
|
-
# structured state representation.
|
|
6
|
-
#
|
|
7
|
-
# Handles:
|
|
8
|
-
# - SGR (Select Graphic Rendition) — colors, bold, italic, underline
|
|
9
|
-
# - Cursor movement (CUU, CUD, CUF, CUB, CUP)
|
|
10
|
-
# - Erase (ED, EL)
|
|
11
|
-
# - Line feed, carriage return, backspace, tab
|
|
12
|
-
#
|
|
13
|
-
# Output: {rows: [[{char, fg, bg, bold, italic, underline}]], cursor: {row, col}, size: {rows, cols}}
|
|
14
|
-
#
|
|
15
|
-
# rubocop:disable Metrics/ModuleLength
|
|
16
|
-
module ANSIParser
|
|
17
|
-
SGR_COLORS = {
|
|
18
|
-
0 => :reset,
|
|
19
|
-
1 => :bold,
|
|
20
|
-
3 => :italic,
|
|
21
|
-
4 => :underline,
|
|
22
|
-
5 => :blink,
|
|
23
|
-
7 => :reverse,
|
|
24
|
-
22 => :normal,
|
|
25
|
-
23 => :no_italic,
|
|
26
|
-
24 => :no_underline,
|
|
27
|
-
30 => :black,
|
|
28
|
-
31 => :red,
|
|
29
|
-
32 => :green,
|
|
30
|
-
33 => :yellow,
|
|
31
|
-
34 => :blue,
|
|
32
|
-
35 => :magenta,
|
|
33
|
-
36 => :cyan,
|
|
34
|
-
37 => :white,
|
|
35
|
-
38 => :xterm_fg, # 38;5;N or 38;2;R;G;B
|
|
36
|
-
39 => :default_fg,
|
|
37
|
-
40 => :bg_black,
|
|
38
|
-
41 => :bg_red,
|
|
39
|
-
42 => :bg_green,
|
|
40
|
-
43 => :bg_yellow,
|
|
41
|
-
44 => :bg_blue,
|
|
42
|
-
45 => :bg_magenta,
|
|
43
|
-
46 => :bg_cyan,
|
|
44
|
-
47 => :bg_white,
|
|
45
|
-
48 => :xterm_bg, # 48;5;N or 48;2;R;G;B
|
|
46
|
-
49 => :default_bg,
|
|
47
|
-
90 => :bright_black,
|
|
48
|
-
91 => :bright_red,
|
|
49
|
-
92 => :bright_green,
|
|
50
|
-
93 => :bright_yellow,
|
|
51
|
-
94 => :bright_blue,
|
|
52
|
-
95 => :bright_magenta,
|
|
53
|
-
96 => :bright_cyan,
|
|
54
|
-
97 => :bright_white,
|
|
55
|
-
100 => :bg_bright_black,
|
|
56
|
-
101 => :bg_bright_red,
|
|
57
|
-
102 => :bg_bright_green,
|
|
58
|
-
103 => :bg_bright_yellow,
|
|
59
|
-
104 => :bg_bright_blue,
|
|
60
|
-
105 => :bg_bright_magenta,
|
|
61
|
-
106 => :bg_bright_cyan,
|
|
62
|
-
107 => :bg_bright_white,
|
|
63
|
-
}.freeze
|
|
64
|
-
|
|
65
|
-
SGR_16_TO_NAME = {
|
|
66
|
-
0 => "black",
|
|
67
|
-
1 => "red",
|
|
68
|
-
2 => "green",
|
|
69
|
-
3 => "yellow",
|
|
70
|
-
4 => "blue",
|
|
71
|
-
5 => "magenta",
|
|
72
|
-
6 => "cyan",
|
|
73
|
-
7 => "white",
|
|
74
|
-
8 => "bright_black",
|
|
75
|
-
9 => "bright_red",
|
|
76
|
-
10 => "bright_green",
|
|
77
|
-
11 => "bright_yellow",
|
|
78
|
-
12 => "bright_blue",
|
|
79
|
-
13 => "bright_magenta",
|
|
80
|
-
14 => "bright_cyan",
|
|
81
|
-
15 => "bright_white",
|
|
82
|
-
}.freeze
|
|
83
|
-
|
|
84
|
-
DEC_MAP = {
|
|
85
|
-
"`" => "◆",
|
|
86
|
-
"a" => "▒",
|
|
87
|
-
"b" => "\u2409",
|
|
88
|
-
"c" => "\u240C",
|
|
89
|
-
"d" => "\u240D",
|
|
90
|
-
"e" => "\u240A",
|
|
91
|
-
"f" => "°",
|
|
92
|
-
"g" => "±",
|
|
93
|
-
"h" => "\u2424",
|
|
94
|
-
"i" => "\u240B",
|
|
95
|
-
"j" => "┘",
|
|
96
|
-
"k" => "┐",
|
|
97
|
-
"l" => "┌",
|
|
98
|
-
"m" => "└",
|
|
99
|
-
"n" => "┼",
|
|
100
|
-
"o" => "⎺",
|
|
101
|
-
"p" => "⎻",
|
|
102
|
-
"q" => "─",
|
|
103
|
-
"r" => "⎼",
|
|
104
|
-
"s" => "⎽",
|
|
105
|
-
"t" => "├",
|
|
106
|
-
"u" => "┤",
|
|
107
|
-
"v" => "┴",
|
|
108
|
-
"w" => "┬",
|
|
109
|
-
"x" => "│",
|
|
110
|
-
"y" => "≤",
|
|
111
|
-
"z" => "≥",
|
|
112
|
-
"{" => "π",
|
|
113
|
-
"|" => "≠",
|
|
114
|
-
"}" => "£",
|
|
115
|
-
"~" => "·",
|
|
116
|
-
}.freeze
|
|
117
|
-
|
|
118
|
-
# Parse raw terminal output into a structured state Hash
|
|
119
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
|
|
120
|
-
def self.parse(raw, rows = 40, cols = 120)
|
|
121
|
-
grid = Array.new(rows) do
|
|
122
|
-
Array.new(cols) { default_cell.dup }
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
cursor = { row: 0, col: 0 }
|
|
126
|
-
attrs = { fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
|
|
127
|
-
saved_cursor = nil
|
|
128
|
-
scroll_region = { top: 0, bottom: rows - 1 }
|
|
129
|
-
pending_dsr = false
|
|
130
|
-
|
|
131
|
-
normal_grid = grid
|
|
132
|
-
alt_grid = nil
|
|
133
|
-
|
|
134
|
-
normal_cursor = cursor
|
|
135
|
-
alt_cursor = { row: 0, col: 0 }
|
|
136
|
-
|
|
137
|
-
normal_saved_cursor = nil
|
|
138
|
-
alt_saved_cursor = nil
|
|
139
|
-
|
|
140
|
-
use_alt_screen = false
|
|
141
|
-
|
|
142
|
-
cursor_visible = true
|
|
143
|
-
cursor_style = 1 # 1 = blinking block (default)
|
|
144
|
-
|
|
145
|
-
g0_charset = :ascii
|
|
146
|
-
g1_charset = :dec
|
|
147
|
-
active_charset = :g0
|
|
148
|
-
|
|
149
|
-
mouse_mode = :none
|
|
150
|
-
mouse_format = :normal
|
|
151
|
-
|
|
152
|
-
# Strip everything before the last full clear (if any)
|
|
153
|
-
# to avoid accumulated garbage
|
|
154
|
-
processed = raw
|
|
155
|
-
|
|
156
|
-
i = 0
|
|
157
|
-
while i < processed.length
|
|
158
|
-
if processed[i] == "\e" && processed[i + 1] == "["
|
|
159
|
-
# Find end of CSI sequence
|
|
160
|
-
j = i + 2
|
|
161
|
-
j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@`fhlmnRrsuq]/)
|
|
162
|
-
seq = processed[i..j]
|
|
163
|
-
|
|
164
|
-
dsr, new_saved, action = _apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
|
|
165
|
-
pending_dsr ||= dsr
|
|
166
|
-
|
|
167
|
-
if new_saved
|
|
168
|
-
if use_alt_screen
|
|
169
|
-
alt_saved_cursor = new_saved
|
|
170
|
-
saved_cursor = alt_saved_cursor
|
|
171
|
-
else
|
|
172
|
-
normal_saved_cursor = new_saved
|
|
173
|
-
saved_cursor = normal_saved_cursor
|
|
174
|
-
end
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
if action.key?(:alt_screen)
|
|
178
|
-
new_alt = action[:alt_screen]
|
|
179
|
-
code = action[:alt_screen_code]
|
|
180
|
-
if new_alt != use_alt_screen
|
|
181
|
-
if new_alt
|
|
182
|
-
# Switch to Alternate Screen
|
|
183
|
-
# Save normal cursor
|
|
184
|
-
normal_cursor = { row: cursor[:row], col: cursor[:col] }
|
|
185
|
-
|
|
186
|
-
# Lazy initialize alt grid
|
|
187
|
-
alt_grid ||= Array.new(rows) do
|
|
188
|
-
Array.new(cols) { default_cell.dup }
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# For \e[?1049h, clear alternate screen and reset cursor to 0,0
|
|
192
|
-
if code == 1049
|
|
193
|
-
alt_grid = Array.new(rows) do
|
|
194
|
-
Array.new(cols) { default_cell.dup }
|
|
195
|
-
end
|
|
196
|
-
alt_cursor = { row: 0, col: 0 }
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
grid = alt_grid
|
|
200
|
-
cursor = alt_cursor
|
|
201
|
-
saved_cursor = alt_saved_cursor
|
|
202
|
-
use_alt_screen = true
|
|
203
|
-
else
|
|
204
|
-
# Switch to Normal Screen
|
|
205
|
-
# Save alt cursor
|
|
206
|
-
alt_cursor = { row: cursor[:row], col: cursor[:col] }
|
|
207
|
-
|
|
208
|
-
grid = normal_grid
|
|
209
|
-
cursor = normal_cursor
|
|
210
|
-
saved_cursor = normal_saved_cursor
|
|
211
|
-
use_alt_screen = false
|
|
212
|
-
end
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
cursor_visible = action[:cursor_visible] if action.key?(:cursor_visible)
|
|
217
|
-
|
|
218
|
-
cursor_style = action[:cursor_style] if action.key?(:cursor_style)
|
|
219
|
-
|
|
220
|
-
mouse_mode = action[:mouse_mode] if action.key?(:mouse_mode)
|
|
221
|
-
|
|
222
|
-
mouse_format = action[:mouse_format] if action.key?(:mouse_format)
|
|
223
|
-
|
|
224
|
-
i = j + 1
|
|
225
|
-
elsif ["\n", "\r\n"].include?(processed[i])
|
|
226
|
-
cursor[:row] += 1
|
|
227
|
-
cursor[:col] = 0
|
|
228
|
-
i += processed[i..(i + 1)] == "\r\n" ? 2 : 1
|
|
229
|
-
elsif processed[i] == "\r"
|
|
230
|
-
cursor[:col] = 0
|
|
231
|
-
i += 1
|
|
232
|
-
elsif processed[i] == "\t"
|
|
233
|
-
cursor[:col] = ((cursor[:col] / 8) + 1) * 8
|
|
234
|
-
cursor[:col] = cols - 1 if cursor[:col] >= cols
|
|
235
|
-
i += 1
|
|
236
|
-
elsif processed[i] == "\b"
|
|
237
|
-
cursor[:col] -= 1 if cursor[:col].positive?
|
|
238
|
-
i += 1
|
|
239
|
-
elsif processed[i] == "\a"
|
|
240
|
-
# Bell — ignore
|
|
241
|
-
i += 1
|
|
242
|
-
elsif processed[i] == "\x0e"
|
|
243
|
-
active_charset = :g1
|
|
244
|
-
i += 1
|
|
245
|
-
elsif processed[i] == "\x0f"
|
|
246
|
-
active_charset = :g0
|
|
247
|
-
i += 1
|
|
248
|
-
elsif processed[i] == "\e"
|
|
249
|
-
# Handle non-CSI escape sequences
|
|
250
|
-
if processed[i + 1] == "7"
|
|
251
|
-
# DECSC — Save Cursor
|
|
252
|
-
if use_alt_screen
|
|
253
|
-
alt_saved_cursor = { row: cursor[:row], col: cursor[:col] }
|
|
254
|
-
saved_cursor = alt_saved_cursor
|
|
255
|
-
else
|
|
256
|
-
normal_saved_cursor = { row: cursor[:row], col: cursor[:col] }
|
|
257
|
-
saved_cursor = normal_saved_cursor
|
|
258
|
-
end
|
|
259
|
-
i += 2
|
|
260
|
-
elsif processed[i + 1] == "8"
|
|
261
|
-
# DECRC — Restore Cursor
|
|
262
|
-
if saved_cursor
|
|
263
|
-
cursor[:row] = saved_cursor[:row]
|
|
264
|
-
cursor[:col] = saved_cursor[:col]
|
|
265
|
-
end
|
|
266
|
-
i += 2
|
|
267
|
-
elsif processed[i + 1] == "(" && %w[0 B].include?(processed[i + 2])
|
|
268
|
-
g0_charset = (processed[i + 2] == "0" ? :dec : :ascii)
|
|
269
|
-
i += 3
|
|
270
|
-
elsif processed[i + 1] == ")" && %w[0 B].include?(processed[i + 2])
|
|
271
|
-
g1_charset = (processed[i + 2] == "0" ? :dec : :ascii)
|
|
272
|
-
i += 3
|
|
273
|
-
elsif processed[i + 1]&.match?(%r{[()*+\-./]})
|
|
274
|
-
# Other ISO 2022 charset sequences (e.g. G2/G3 or other charsets)
|
|
275
|
-
i += 3
|
|
276
|
-
else
|
|
277
|
-
i += 1
|
|
278
|
-
end
|
|
279
|
-
elsif (char, char_len = _utf8_char_at(processed, i))
|
|
280
|
-
# Printable character (including multi-byte UTF-8)
|
|
281
|
-
if cursor[:row] < rows && cursor[:col] < cols
|
|
282
|
-
cell = grid[cursor[:row]][cursor[:col]]
|
|
283
|
-
current_charset = (active_charset == :g1 ? g1_charset : g0_charset)
|
|
284
|
-
mapped_char = char
|
|
285
|
-
mapped_char = DEC_MAP[char] if current_charset == :dec && DEC_MAP.key?(char)
|
|
286
|
-
cell[:char] = mapped_char
|
|
287
|
-
cell.merge!(attrs)
|
|
288
|
-
cursor[:col] += 1
|
|
289
|
-
cursor[:col] = cols - 1 if cursor[:col] >= cols
|
|
290
|
-
end
|
|
291
|
-
i += char_len
|
|
292
|
-
else # rubocop:disable Lint/DuplicateBranch
|
|
293
|
-
i += 1
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
# Handle scrolling within the defined scroll region
|
|
297
|
-
region_top = scroll_region[:top]
|
|
298
|
-
region_bottom = scroll_region[:bottom]
|
|
299
|
-
|
|
300
|
-
next unless cursor[:row] > region_bottom
|
|
301
|
-
|
|
302
|
-
scroll_lines = [cursor[:row] - region_bottom, rows].min
|
|
303
|
-
# Shift lines within the scroll region up
|
|
304
|
-
(region_top..(region_bottom - scroll_lines)).each do |ri|
|
|
305
|
-
src = ri + scroll_lines
|
|
306
|
-
grid[ri] = src <= region_bottom ? grid[src] : Array.new(cols) { default_cell.dup }
|
|
307
|
-
end
|
|
308
|
-
# Fill bottom of scroll region with blank lines
|
|
309
|
-
((region_bottom - scroll_lines + 1)..region_bottom).each do |ri|
|
|
310
|
-
grid[ri] = Array.new(cols) { default_cell.dup }
|
|
311
|
-
end
|
|
312
|
-
cursor[:row] = region_bottom
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
{
|
|
316
|
-
size: { rows: rows, cols: cols },
|
|
317
|
-
cursor: {
|
|
318
|
-
row: cursor[:row],
|
|
319
|
-
col: cursor[:col],
|
|
320
|
-
visible: cursor_visible,
|
|
321
|
-
style: cursor_style,
|
|
322
|
-
},
|
|
323
|
-
rows: grid,
|
|
324
|
-
pending_dsr: pending_dsr,
|
|
325
|
-
cursor_visible: cursor_visible,
|
|
326
|
-
cursor_style: cursor_style,
|
|
327
|
-
mouse_mode: mouse_mode,
|
|
328
|
-
mouse_format: mouse_format,
|
|
329
|
-
}
|
|
330
|
-
end
|
|
331
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting
|
|
332
|
-
|
|
333
|
-
# Rebuild ANSI output from a state hash (for rendering/screenshot)
|
|
334
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
335
|
-
def self.build_frame(state)
|
|
336
|
-
rows = state.dig(:size, :rows) || state["size"]["rows"]
|
|
337
|
-
state.dig(:size, :cols) || state["size"]["cols"]
|
|
338
|
-
grid = state[:rows] || state["rows"]
|
|
339
|
-
cursor = state[:cursor] || state["cursor"]
|
|
340
|
-
mouse_mode = state[:mouse_mode] || state["mouse_mode"] || :none
|
|
341
|
-
mouse_format = state[:mouse_format] || state["mouse_format"] || :normal
|
|
342
|
-
|
|
343
|
-
out = +""
|
|
344
|
-
out << "\e[0m"
|
|
345
|
-
out << "\e[2J\e[H"
|
|
346
|
-
|
|
347
|
-
grid.each_with_index do |row, ri|
|
|
348
|
-
row.each_with_index do |cell, _ci|
|
|
349
|
-
char = cell[:char] || cell["char"] || " "
|
|
350
|
-
fg = cell[:fg] || cell["fg"] || "default"
|
|
351
|
-
bg = cell[:bg] || cell["bg"] || "default"
|
|
352
|
-
bold = cell[:bold] || cell["bold"] || false
|
|
353
|
-
italic = cell[:italic] || cell["italic"] || false
|
|
354
|
-
underline = cell[:underline] || cell["underline"] || false
|
|
355
|
-
blink = cell[:blink] || cell["blink"] || false
|
|
356
|
-
|
|
357
|
-
codes = []
|
|
358
|
-
codes << "1" if bold
|
|
359
|
-
codes << "3" if italic
|
|
360
|
-
codes << "4" if underline
|
|
361
|
-
codes << "5" if blink
|
|
3
|
+
require "tans-parser"
|
|
362
4
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
codes << fg_code if fg_code
|
|
367
|
-
codes << bg_code if bg_code
|
|
368
|
-
|
|
369
|
-
out << "\e[#{codes.join(";")}m" unless codes.empty?
|
|
370
|
-
out << char
|
|
371
|
-
end
|
|
372
|
-
out << "\n" if ri < rows - 1
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
# Reconstruct cursor visibility
|
|
376
|
-
cursor_vis = true
|
|
377
|
-
cursor_vis = cursor[:visible] != false && cursor["visible"] != false if cursor.is_a?(Hash)
|
|
378
|
-
out << (cursor_vis ? "\e[?25h" : "\e[?25l")
|
|
379
|
-
|
|
380
|
-
# Reconstruct cursor style
|
|
381
|
-
if cursor.is_a?(Hash)
|
|
382
|
-
style = cursor[:style] || cursor["style"]
|
|
383
|
-
out << "\e[#{style} q" if style
|
|
384
|
-
end
|
|
385
|
-
|
|
386
|
-
# Reconstruct mouse mode and format
|
|
387
|
-
out << case mouse_mode
|
|
388
|
-
when :normal
|
|
389
|
-
"\e[?1000h"
|
|
390
|
-
when :drag
|
|
391
|
-
"\e[?1002h"
|
|
392
|
-
when :all
|
|
393
|
-
"\e[?1003h"
|
|
394
|
-
else
|
|
395
|
-
"\e[?1000l\e[?1002l\e[?1003l"
|
|
396
|
-
end
|
|
397
|
-
|
|
398
|
-
out << if mouse_format == :sgr
|
|
399
|
-
"\e[?1006h"
|
|
400
|
-
else
|
|
401
|
-
"\e[?1006l"
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
out << "\e[0m"
|
|
405
|
-
out
|
|
406
|
-
end
|
|
407
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
408
|
-
|
|
409
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
410
|
-
def self._apply_csi(seq, cursor, attrs, grid, rows, cols, saved_cursor, scroll_region)
|
|
411
|
-
# Strip leading escape char if present
|
|
412
|
-
cleaned = seq.sub(/^\e/, "")
|
|
413
|
-
match = cleaned.match(/^\[(\??)([\d;]*)( ?)([A-HJ-KP-SX@`fhlmnRrsuq])$/)
|
|
414
|
-
return [false, nil, {}] unless match
|
|
415
|
-
|
|
416
|
-
is_private = (match[1] == "?")
|
|
417
|
-
params = match[2].split(";").map(&:to_i)
|
|
418
|
-
space = match[3]
|
|
419
|
-
command = match[4]
|
|
420
|
-
|
|
421
|
-
new_saved = nil
|
|
422
|
-
action = {}
|
|
423
|
-
|
|
424
|
-
case command
|
|
425
|
-
when "m"
|
|
426
|
-
_apply_sgr(params, attrs)
|
|
427
|
-
when "A" # CUU — Cursor Up
|
|
428
|
-
n = params[0] || 1
|
|
429
|
-
n = 1 if n.zero?
|
|
430
|
-
cursor[:row] = [cursor[:row] - n, 0].max
|
|
431
|
-
when "B" # CUD — Cursor Down
|
|
432
|
-
n = params[0] || 1
|
|
433
|
-
n = 1 if n.zero?
|
|
434
|
-
cursor[:row] = [cursor[:row] + n, rows - 1].min
|
|
435
|
-
when "C" # CUF — Cursor Forward
|
|
436
|
-
n = params[0] || 1
|
|
437
|
-
n = 1 if n.zero?
|
|
438
|
-
cursor[:col] = [cursor[:col] + n, cols - 1].min
|
|
439
|
-
when "D" # CUB — Cursor Back
|
|
440
|
-
n = params[0] || 1
|
|
441
|
-
n = 1 if n.zero?
|
|
442
|
-
cursor[:col] = [cursor[:col] - n, 0].max
|
|
443
|
-
when "H", "f" # CUP — Cursor Position
|
|
444
|
-
r = (params[0] || 1) - 1
|
|
445
|
-
c = (params[1] || 1) - 1
|
|
446
|
-
cursor[:row] = r.clamp(0, rows - 1)
|
|
447
|
-
cursor[:col] = c.clamp(0, cols - 1)
|
|
448
|
-
when "J" # ED — Erase in Display
|
|
449
|
-
case params[0]
|
|
450
|
-
when nil, 0
|
|
451
|
-
_erase_down(cursor, grid, rows, cols)
|
|
452
|
-
when 1
|
|
453
|
-
_erase_up(cursor, grid, cols)
|
|
454
|
-
when 2, 3
|
|
455
|
-
_erase_all(grid, rows, cols)
|
|
456
|
-
cursor[:row] = 0
|
|
457
|
-
cursor[:col] = 0
|
|
458
|
-
end
|
|
459
|
-
when "K" # EL — Erase in Line
|
|
460
|
-
case params[0]
|
|
461
|
-
when nil, 0
|
|
462
|
-
_erase_line_right(cursor, grid, cols)
|
|
463
|
-
when 1
|
|
464
|
-
_erase_line_left(cursor, grid, cols)
|
|
465
|
-
when 2
|
|
466
|
-
_erase_line(cursor, grid, cols)
|
|
467
|
-
end
|
|
468
|
-
when "X" # Erase Characters
|
|
469
|
-
n = params[0] || 1
|
|
470
|
-
n.times do |i|
|
|
471
|
-
next unless cursor[:row] < rows && cursor[:col] + i < cols
|
|
472
|
-
|
|
473
|
-
grid[cursor[:row]][cursor[:col] + i][:char] = " "
|
|
474
|
-
end
|
|
475
|
-
when "s" # DECSC — Save Cursor (CSI variant)
|
|
476
|
-
new_saved = { row: cursor[:row], col: cursor[:col] }
|
|
477
|
-
when "u" # DECRC — Restore Cursor (CSI variant)
|
|
478
|
-
if saved_cursor
|
|
479
|
-
cursor[:row] = saved_cursor[:row]
|
|
480
|
-
cursor[:col] = saved_cursor[:col]
|
|
481
|
-
end
|
|
482
|
-
when "r" # DECSTBM — Set Scroll Region
|
|
483
|
-
top = (params[0] || 1) - 1
|
|
484
|
-
bottom = (params[1] || rows) - 1
|
|
485
|
-
top = top.clamp(0, rows - 1)
|
|
486
|
-
bottom = bottom.clamp(0, rows - 1)
|
|
487
|
-
if top < bottom
|
|
488
|
-
scroll_region[:top] = top
|
|
489
|
-
scroll_region[:bottom] = bottom
|
|
490
|
-
else
|
|
491
|
-
scroll_region[:top] = 0
|
|
492
|
-
scroll_region[:bottom] = rows - 1
|
|
493
|
-
end
|
|
494
|
-
cursor[:row] = 0
|
|
495
|
-
cursor[:col] = 0
|
|
496
|
-
when "h"
|
|
497
|
-
if is_private
|
|
498
|
-
params.each do |p|
|
|
499
|
-
case p
|
|
500
|
-
when 47, 1047, 1049
|
|
501
|
-
action[:alt_screen] = true
|
|
502
|
-
action[:alt_screen_code] = p
|
|
503
|
-
when 25
|
|
504
|
-
action[:cursor_visible] = true
|
|
505
|
-
when 1000
|
|
506
|
-
action[:mouse_mode] = :normal
|
|
507
|
-
when 1002
|
|
508
|
-
action[:mouse_mode] = :drag
|
|
509
|
-
when 1003
|
|
510
|
-
action[:mouse_mode] = :all
|
|
511
|
-
when 1006
|
|
512
|
-
action[:mouse_format] = :sgr
|
|
513
|
-
end
|
|
514
|
-
end
|
|
515
|
-
end
|
|
516
|
-
when "l"
|
|
517
|
-
if is_private
|
|
518
|
-
params.each do |p|
|
|
519
|
-
case p
|
|
520
|
-
when 47, 1047, 1049
|
|
521
|
-
action[:alt_screen] = false
|
|
522
|
-
action[:alt_screen_code] = p
|
|
523
|
-
when 25
|
|
524
|
-
action[:cursor_visible] = false
|
|
525
|
-
when 1000, 1002, 1003
|
|
526
|
-
action[:mouse_mode] = :none
|
|
527
|
-
when 1006
|
|
528
|
-
action[:mouse_format] = :normal
|
|
529
|
-
end
|
|
530
|
-
end
|
|
531
|
-
end
|
|
532
|
-
when "q"
|
|
533
|
-
if space == " "
|
|
534
|
-
style_val = params[0] || 0
|
|
535
|
-
action[:cursor_style] = style_val
|
|
536
|
-
end
|
|
537
|
-
when "n" # DSR — Device Status Report request
|
|
538
|
-
return [params[0] == 6, nil, {}]
|
|
539
|
-
when "R" # DSR response (from terminal side) or CPR — ignore
|
|
540
|
-
nil
|
|
541
|
-
end
|
|
542
|
-
|
|
543
|
-
[false, new_saved, action]
|
|
544
|
-
end
|
|
545
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
546
|
-
|
|
547
|
-
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
548
|
-
def self._apply_sgr(params, attrs)
|
|
549
|
-
if params.empty? || params == [0]
|
|
550
|
-
return attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false,
|
|
551
|
-
blink: false,)
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
i = 0
|
|
555
|
-
while i < params.length
|
|
556
|
-
p = params[i]
|
|
557
|
-
case p
|
|
558
|
-
when 0
|
|
559
|
-
attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false)
|
|
560
|
-
when 1
|
|
561
|
-
attrs[:bold] = true
|
|
562
|
-
when 3
|
|
563
|
-
attrs[:italic] = true
|
|
564
|
-
when 4
|
|
565
|
-
attrs[:underline] = true
|
|
566
|
-
when 5, 6
|
|
567
|
-
attrs[:blink] = true
|
|
568
|
-
when 22
|
|
569
|
-
attrs[:bold] = false
|
|
570
|
-
when 23
|
|
571
|
-
attrs[:italic] = false
|
|
572
|
-
when 24
|
|
573
|
-
attrs[:underline] = false
|
|
574
|
-
when 25
|
|
575
|
-
attrs[:blink] = false
|
|
576
|
-
when 7, 27
|
|
577
|
-
# Reverse — swap fg and bg
|
|
578
|
-
attrs[:fg], attrs[:bg] = attrs[:bg], attrs[:fg]
|
|
579
|
-
when 30..37
|
|
580
|
-
attrs[:fg] = SGR_16_TO_NAME[p - 30] || "color#{p - 30}"
|
|
581
|
-
when 38
|
|
582
|
-
# Extended foreground
|
|
583
|
-
if params[i + 1] == 5
|
|
584
|
-
color = params[i + 2]
|
|
585
|
-
attrs[:fg] = "color#{color}"
|
|
586
|
-
i += 2
|
|
587
|
-
elsif params[i + 1] == 2
|
|
588
|
-
r = params[i + 2]
|
|
589
|
-
g = params[i + 3]
|
|
590
|
-
b = params[i + 4]
|
|
591
|
-
attrs[:fg] = format("#%<r>02x%<g>02x%<b>02x", r: r, g: g, b: b)
|
|
592
|
-
i += 4
|
|
593
|
-
end
|
|
594
|
-
when 39
|
|
595
|
-
attrs[:fg] = "default"
|
|
596
|
-
when 40..47
|
|
597
|
-
attrs[:bg] = SGR_16_TO_NAME[p - 40] || "bg_color#{p - 40}"
|
|
598
|
-
when 48
|
|
599
|
-
# Extended background
|
|
600
|
-
if params[i + 1] == 5
|
|
601
|
-
color = params[i + 2]
|
|
602
|
-
attrs[:bg] = "color#{color}"
|
|
603
|
-
i += 2
|
|
604
|
-
elsif params[i + 1] == 2
|
|
605
|
-
r = params[i + 2]
|
|
606
|
-
g = params[i + 3]
|
|
607
|
-
b = params[i + 4]
|
|
608
|
-
attrs[:bg] = format("#%<r>02x%<g>02x%<b>02x", r: r, g: g, b: b)
|
|
609
|
-
i += 4
|
|
610
|
-
end
|
|
611
|
-
when 49
|
|
612
|
-
attrs[:bg] = "default"
|
|
613
|
-
when 90..97
|
|
614
|
-
attrs[:fg] = "bright_#{SGR_16_TO_NAME[p - 90] || "color#{p - 90 + 8}"}"
|
|
615
|
-
when 100..107
|
|
616
|
-
attrs[:bg] = "bright_#{SGR_16_TO_NAME[p - 100] || "color#{p - 100 + 8}"}"
|
|
617
|
-
end
|
|
618
|
-
i += 1
|
|
619
|
-
end
|
|
620
|
-
end
|
|
621
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
622
|
-
|
|
623
|
-
def self._erase_down(cursor, grid, rows, cols)
|
|
624
|
-
r = cursor[:row]
|
|
625
|
-
c = cursor[:col]
|
|
626
|
-
|
|
627
|
-
# Erase from cursor to end of line
|
|
628
|
-
(c...cols).each { |ci| grid[r][ci][:char] = " " if r < rows }
|
|
629
|
-
|
|
630
|
-
# Erase remaining lines
|
|
631
|
-
((r + 1)...rows).each do |ri|
|
|
632
|
-
cols.times { |ci| grid[ri][ci][:char] = " " }
|
|
633
|
-
end
|
|
634
|
-
end
|
|
635
|
-
|
|
636
|
-
def self._erase_up(cursor, grid, cols)
|
|
637
|
-
r = cursor[:row]
|
|
638
|
-
c = cursor[:col]
|
|
639
|
-
|
|
640
|
-
# Erase lines above cursor
|
|
641
|
-
(0...r).each do |ri|
|
|
642
|
-
cols.times { |ci| grid[ri][ci][:char] = " " }
|
|
643
|
-
end
|
|
644
|
-
|
|
645
|
-
# Erase from start of line to cursor
|
|
646
|
-
(0..c).each { |ci| grid[r][ci][:char] = " " }
|
|
647
|
-
end
|
|
648
|
-
|
|
649
|
-
def self._erase_all(grid, rows, cols)
|
|
650
|
-
rows.times do |ri|
|
|
651
|
-
cols.times { |ci| grid[ri][ci][:char] = " " }
|
|
652
|
-
end
|
|
653
|
-
end
|
|
654
|
-
|
|
655
|
-
def self._erase_line_right(cursor, grid, cols)
|
|
656
|
-
r = cursor[:row]
|
|
657
|
-
c = cursor[:col]
|
|
658
|
-
(c...cols).each { |ci| grid[r][ci][:char] = " " if r < grid.length }
|
|
659
|
-
end
|
|
660
|
-
|
|
661
|
-
def self._erase_line_left(cursor, grid, _cols)
|
|
662
|
-
r = cursor[:row]
|
|
663
|
-
c = cursor[:col]
|
|
664
|
-
(0..c).each { |ci| grid[r][ci][:char] = " " if r < grid.length }
|
|
665
|
-
end
|
|
666
|
-
|
|
667
|
-
def self._erase_line(cursor, grid, cols)
|
|
668
|
-
r = cursor[:row]
|
|
669
|
-
cols.times { |ci| grid[r][ci][:char] = " " if r < grid.length }
|
|
670
|
-
end
|
|
671
|
-
|
|
672
|
-
# rubocop:disable Metrics/CyclomaticComplexity
|
|
673
|
-
def self._color_code(name, prefix)
|
|
674
|
-
case name
|
|
675
|
-
when "default" then nil
|
|
676
|
-
when /^#([0-9a-fA-F]{6})$/
|
|
677
|
-
r = ::Regexp.last_match(1)[0..1].to_i(16)
|
|
678
|
-
g = ::Regexp.last_match(1)[2..3].to_i(16)
|
|
679
|
-
b = ::Regexp.last_match(1)[4..5].to_i(16)
|
|
680
|
-
"#{prefix};2;#{r};#{g};#{b}"
|
|
681
|
-
when /^(bright_)?(.+)$/
|
|
682
|
-
base_name = ::Regexp.last_match(2)
|
|
683
|
-
index = SGR_16_TO_NAME.key(base_name)
|
|
684
|
-
index += 8 if ::Regexp.last_match(1) && index && index < 8
|
|
685
|
-
index ? "#{prefix};5;#{index}" : nil
|
|
686
|
-
end
|
|
687
|
-
end
|
|
688
|
-
# rubocop:enable Metrics/CyclomaticComplexity
|
|
689
|
-
|
|
690
|
-
def self.default_cell
|
|
691
|
-
{ char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false, blink: false }
|
|
692
|
-
end
|
|
693
|
-
|
|
694
|
-
# Extract a single UTF-8 character at position i in a binary string.
|
|
695
|
-
# Returns [char_string, byte_length] or nil if the byte is not printable/valid.
|
|
696
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Naming/MethodParameterName
|
|
697
|
-
def self._utf8_char_at(str, i)
|
|
698
|
-
byte = str.getbyte(i)
|
|
699
|
-
return nil unless byte
|
|
700
|
-
|
|
701
|
-
if byte < 0x80
|
|
702
|
-
# Single-byte ASCII
|
|
703
|
-
return nil unless byte >= 0x20 # only printable, skip control chars
|
|
704
|
-
|
|
705
|
-
return [byte.chr, 1]
|
|
706
|
-
end
|
|
707
|
-
|
|
708
|
-
# Multi-byte UTF-8
|
|
709
|
-
len = if byte & 0xE0 == 0xC0
|
|
710
|
-
2
|
|
711
|
-
elsif byte & 0xF0 == 0xE0
|
|
712
|
-
3
|
|
713
|
-
elsif byte & 0xF8 == 0xF0
|
|
714
|
-
4
|
|
715
|
-
else
|
|
716
|
-
return nil # continuation byte or invalid — let main loop advance
|
|
717
|
-
end
|
|
718
|
-
return nil if i + len > str.bytesize
|
|
719
|
-
|
|
720
|
-
bytes = str.byteslice(i, len)
|
|
721
|
-
char = bytes.dup.force_encoding("UTF-8")
|
|
722
|
-
return nil unless char.valid_encoding?
|
|
723
|
-
|
|
724
|
-
[char, len]
|
|
725
|
-
rescue StandardError
|
|
726
|
-
nil
|
|
727
|
-
end
|
|
728
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Naming/MethodParameterName
|
|
729
|
-
end
|
|
730
|
-
# rubocop:enable Metrics/ModuleLength
|
|
5
|
+
module TUITD
|
|
6
|
+
ANSIParser = TansParser::ANSIParser
|
|
731
7
|
end
|
data/lib/tui_td/ansi_utils.rb
CHANGED
|
@@ -1,77 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
# Shared ANSI color constants and helpers.
|
|
5
|
-
# Used by Screenshot, HtmlRenderer, and other color-aware renderers.
|
|
6
|
-
module ANSIUtils
|
|
7
|
-
ANSI_RGB = {
|
|
8
|
-
"black" => [0x00, 0x00, 0x00],
|
|
9
|
-
"red" => [0xAA, 0x00, 0x00],
|
|
10
|
-
"green" => [0x00, 0xAA, 0x00],
|
|
11
|
-
"yellow" => [0xAA, 0x55, 0x00],
|
|
12
|
-
"blue" => [0x00, 0x00, 0xAA],
|
|
13
|
-
"magenta" => [0xAA, 0x00, 0xAA],
|
|
14
|
-
"cyan" => [0x00, 0xAA, 0xAA],
|
|
15
|
-
"white" => [0xAA, 0xAA, 0xAA],
|
|
16
|
-
"bright_black" => [0x55, 0x55, 0x55],
|
|
17
|
-
"bright_red" => [0xFF, 0x55, 0x55],
|
|
18
|
-
"bright_green" => [0x55, 0xFF, 0x55],
|
|
19
|
-
"bright_yellow" => [0xFF, 0xFF, 0x55],
|
|
20
|
-
"bright_blue" => [0x55, 0x55, 0xFF],
|
|
21
|
-
"bright_magenta" => [0xFF, 0x55, 0xFF],
|
|
22
|
-
"bright_cyan" => [0x55, 0xFF, 0xFF],
|
|
23
|
-
"bright_white" => [0xFF, 0xFF, 0xFF],
|
|
24
|
-
}.freeze
|
|
25
|
-
|
|
26
|
-
CUBE = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF].freeze
|
|
27
|
-
|
|
28
|
-
ANSI_INDEX = %w[
|
|
29
|
-
black red green yellow blue magenta cyan white
|
|
30
|
-
bright_black bright_red bright_green bright_yellow
|
|
31
|
-
bright_blue bright_magenta bright_cyan bright_white
|
|
32
|
-
].freeze
|
|
33
|
-
|
|
34
|
-
DEFAULT_FG = [0xC0, 0xC0, 0xC0].freeze
|
|
35
|
-
DEFAULT_BG = [0x00, 0x00, 0x00].freeze
|
|
3
|
+
require "tans-parser"
|
|
36
4
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
when "default"
|
|
40
|
-
fallback
|
|
41
|
-
when /^#([0-9a-fA-F]{6})$/
|
|
42
|
-
[::Regexp.last_match(1)[0..1].to_i(16), ::Regexp.last_match(1)[2..3].to_i(16),
|
|
43
|
-
::Regexp.last_match(1)[4..5].to_i(16),]
|
|
44
|
-
when /\Acolor(\d+)\z/
|
|
45
|
-
xterm_256(::Regexp.last_match(1).to_i)
|
|
46
|
-
when /\Abright_(.+)\z/
|
|
47
|
-
ANSI_RGB[name] || fallback
|
|
48
|
-
else # rubocop:disable Lint/DuplicateBranch
|
|
49
|
-
ANSI_RGB[name] || fallback
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def xterm_256(index) # rubocop:disable Naming/VariableNumber
|
|
54
|
-
if index < 16
|
|
55
|
-
name = ANSI_INDEX[index]
|
|
56
|
-
ANSI_RGB[name] || DEFAULT_FG
|
|
57
|
-
elsif index < 232
|
|
58
|
-
r = CUBE[((index - 16) / 36) % 6]
|
|
59
|
-
g = CUBE[((index - 16) / 6) % 6]
|
|
60
|
-
b = CUBE[(index - 16) % 6]
|
|
61
|
-
[r, g, b]
|
|
62
|
-
else
|
|
63
|
-
v = 8 + ((index - 232) * 10)
|
|
64
|
-
[v, v, v]
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def _dig(hash, *keys)
|
|
69
|
-
keys.each do |k|
|
|
70
|
-
return nil unless hash
|
|
71
|
-
|
|
72
|
-
hash = hash[k] || hash[k.to_s]
|
|
73
|
-
end
|
|
74
|
-
hash
|
|
75
|
-
end
|
|
76
|
-
end
|
|
5
|
+
module TUITD
|
|
6
|
+
ANSIUtils = TansParser::ANSIUtils
|
|
77
7
|
end
|
data/lib/tui_td/driver.rb
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
require "pty"
|
|
7
7
|
require "io/console"
|
|
8
8
|
require "json"
|
|
9
|
+
require "shellwords"
|
|
9
10
|
|
|
10
11
|
module TUITD
|
|
11
12
|
# Drives a TUI application in a pseudo-terminal (PTY).
|
|
@@ -20,6 +21,9 @@ module TUITD
|
|
|
20
21
|
# driver.close
|
|
21
22
|
#
|
|
22
23
|
class Driver
|
|
24
|
+
FORBIDDEN_ENV = %w[PATH LD_PRELOAD LD_LIBRARY_PATH DYLD_INSERT_LIBRARIES
|
|
25
|
+
DYLD_FRAMEWORK_PATH RUBYOPT HOME RUBYLIB GEM_HOME GEM_PATH].freeze
|
|
26
|
+
|
|
23
27
|
attr_reader :command, :state
|
|
24
28
|
|
|
25
29
|
def initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil, env: {})
|
|
@@ -28,7 +32,7 @@ module TUITD
|
|
|
28
32
|
@cols = cols
|
|
29
33
|
@timeout = timeout
|
|
30
34
|
@chdir = chdir
|
|
31
|
-
@env = env
|
|
35
|
+
@env = sanitize_env(env)
|
|
32
36
|
@state = nil
|
|
33
37
|
@stdin = nil
|
|
34
38
|
@stdout = nil
|
|
@@ -46,7 +50,8 @@ module TUITD
|
|
|
46
50
|
spawn_opts = {}
|
|
47
51
|
spawn_opts[:chdir] = @chdir if @chdir
|
|
48
52
|
|
|
49
|
-
|
|
53
|
+
cmd_args = Shellwords.shellsplit(@command)
|
|
54
|
+
@stdout, @stdin, @pid = PTY.spawn(env, *cmd_args, spawn_opts)
|
|
50
55
|
@stdout.winsize = [@rows, @cols] # Set PTY window size for TUIs that check winsize
|
|
51
56
|
@wait_thr = Process.detach(@pid)
|
|
52
57
|
|
|
@@ -246,6 +251,10 @@ module TUITD
|
|
|
246
251
|
@reader_thread = nil
|
|
247
252
|
end
|
|
248
253
|
|
|
254
|
+
def sanitize_env(env)
|
|
255
|
+
env.reject { |k, _| FORBIDDEN_ENV.include?(k.to_s.upcase) }
|
|
256
|
+
end
|
|
257
|
+
|
|
249
258
|
def ensure_running!
|
|
250
259
|
raise Error, "Driver not started. Call #start first." if @stdin.nil?
|
|
251
260
|
raise Error, "Process exited (status: #{@wait_thr&.value&.exitstatus})" unless @wait_thr&.alive?
|
data/lib/tui_td/mcp/server.rb
CHANGED
|
@@ -60,6 +60,8 @@ module TUITD
|
|
|
60
60
|
@driver&.close
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
ALLOWED_OUTPUT_DIRS = ["/tmp"].freeze
|
|
64
|
+
|
|
63
65
|
private
|
|
64
66
|
|
|
65
67
|
def handle_request(request)
|
|
@@ -433,7 +435,7 @@ module TUITD
|
|
|
433
435
|
|
|
434
436
|
def call_tui_screenshot(args)
|
|
435
437
|
ensure_driver!
|
|
436
|
-
path = args["path"]
|
|
438
|
+
path = safe_path(args["path"], ext: "png")
|
|
437
439
|
result = @driver.screenshot(path)
|
|
438
440
|
"OK: Screenshot saved to #{result}"
|
|
439
441
|
end
|
|
@@ -444,8 +446,9 @@ module TUITD
|
|
|
444
446
|
renderer = HtmlRenderer.new(@driver.state_data)
|
|
445
447
|
|
|
446
448
|
if path
|
|
447
|
-
|
|
448
|
-
|
|
449
|
+
safe = safe_path(path, ext: "html")
|
|
450
|
+
renderer.render(safe)
|
|
451
|
+
"OK: HTML saved to #{safe}"
|
|
449
452
|
else
|
|
450
453
|
renderer.to_html
|
|
451
454
|
end
|
|
@@ -493,6 +496,17 @@ module TUITD
|
|
|
493
496
|
|
|
494
497
|
# --- Helpers ---
|
|
495
498
|
|
|
499
|
+
def safe_path(user_path, ext:)
|
|
500
|
+
default = File.join("/tmp", "tui_td_#{Time.now.to_i}.#{ext}")
|
|
501
|
+
resolved = File.expand_path(user_path || default)
|
|
502
|
+
|
|
503
|
+
unless ALLOWED_OUTPUT_DIRS.any? { |dir| resolved.start_with?(File.expand_path(dir)) }
|
|
504
|
+
raise TUITD::Error, "Output path must be under one of: #{ALLOWED_OUTPUT_DIRS.join(", ")}"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
resolved
|
|
508
|
+
end
|
|
509
|
+
|
|
496
510
|
def ensure_driver!
|
|
497
511
|
raise Error, "No TUI session active. Call tui_start first." if @driver.nil?
|
|
498
512
|
end
|
data/lib/tui_td/state.rb
CHANGED
|
@@ -1,128 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require "tans-parser"
|
|
4
4
|
|
|
5
5
|
module TUITD
|
|
6
|
-
|
|
7
|
-
# Provides high-level query methods for AI consumption.
|
|
8
|
-
class State
|
|
9
|
-
attr_reader :rows, :cols, :grid, :cursor, :cursor_visible, :cursor_style, :mouse_mode, :mouse_format
|
|
10
|
-
|
|
11
|
-
def initialize(data)
|
|
12
|
-
raise ArgumentError, "State data must include :size key" unless data[:size]
|
|
13
|
-
raise ArgumentError, "State data must include :rows key" unless data[:rows]
|
|
14
|
-
|
|
15
|
-
@rows = data[:size][:rows]
|
|
16
|
-
@cols = data[:size][:cols]
|
|
17
|
-
@grid = data[:rows]
|
|
18
|
-
@cursor = data[:cursor]
|
|
19
|
-
|
|
20
|
-
cursor_info = data[:cursor].is_a?(Hash) ? data[:cursor] : {}
|
|
21
|
-
@cursor_visible = data.key?(:cursor_visible) ? data[:cursor_visible] : (cursor_info[:visible] != false)
|
|
22
|
-
@cursor_style = data.key?(:cursor_style) ? data[:cursor_style] : (cursor_info[:style] || 1)
|
|
23
|
-
|
|
24
|
-
@mouse_mode = data[:mouse_mode] || :none
|
|
25
|
-
@mouse_format = data[:mouse_format] || :normal
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
# Get plain text of the entire terminal (no ANSI)
|
|
29
|
-
def plain_text
|
|
30
|
-
@grid.map { |row| row.map { |c| c[:char] }.join.rstrip }.join("\n")
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Get text at a specific position
|
|
34
|
-
def text_at(row, col, length = @cols - col)
|
|
35
|
-
return "" if row >= @rows || col >= @cols
|
|
36
|
-
|
|
37
|
-
@grid[row][col, length].map { |c| c[:char] }.join
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Search for text across the entire terminal
|
|
41
|
-
def find_text(pattern)
|
|
42
|
-
results = []
|
|
43
|
-
@grid.each_with_index do |row, ri|
|
|
44
|
-
text = row.map { |c| c[:char] }.join
|
|
45
|
-
pos = 0
|
|
46
|
-
while (match = text.index(pattern, pos))
|
|
47
|
-
results << { row: ri, col: match, text: pattern, full_line: text }
|
|
48
|
-
pos = match + 1
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
results
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Get the color at a specific cell
|
|
55
|
-
def foreground_at(row, col)
|
|
56
|
-
return nil if row >= @rows || col >= @cols
|
|
57
|
-
|
|
58
|
-
@grid[row][col][:fg]
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def background_at(row, col)
|
|
62
|
-
return nil if row >= @rows || col >= @cols
|
|
63
|
-
|
|
64
|
-
@grid[row][col][:bg]
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def style_at(row, col)
|
|
68
|
-
return nil if row >= @rows || col >= @cols
|
|
69
|
-
|
|
70
|
-
cell = @grid[row][col]
|
|
71
|
-
{ bold: cell[:bold], italic: cell[:italic], underline: cell[:underline] }
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def to_ai_json
|
|
75
|
-
h = extract_highlights
|
|
76
|
-
cursor_info = @cursor.is_a?(Hash) ? @cursor : {}
|
|
77
|
-
r = cursor_info[:row] || cursor_info["row"] || 0
|
|
78
|
-
c = cursor_info[:col] || cursor_info["col"] || 0
|
|
79
|
-
styled_count = h.count { |hl| hl[:bold] || hl[:italic] || hl[:underline] || hl[:fg] || hl[:bg] }
|
|
80
|
-
|
|
81
|
-
summary = "Cursor at [#{r},#{c}]. "
|
|
82
|
-
summary << "#{styled_count} styled row#{"s" unless styled_count == 1}"
|
|
83
|
-
fgs = h.flat_map { |hl| hl[:fg] }.compact.uniq
|
|
84
|
-
bgs = h.flat_map { |hl| hl[:bg] }.compact.uniq
|
|
85
|
-
summary << ", colors: fg=#{fgs.sort.join(",")}" unless fgs.empty?
|
|
86
|
-
summary << ", bg=#{bgs.sort.join(",")}" unless bgs.empty?
|
|
87
|
-
summary << "."
|
|
88
|
-
|
|
89
|
-
{
|
|
90
|
-
size: { rows: @rows, cols: @cols },
|
|
91
|
-
cursor: cursor_info,
|
|
92
|
-
text: plain_text,
|
|
93
|
-
highlights: h,
|
|
94
|
-
summary: summary,
|
|
95
|
-
}
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
private
|
|
99
|
-
|
|
100
|
-
def extract_highlights
|
|
101
|
-
highlights = []
|
|
102
|
-
@grid.each_with_index do |row, ri|
|
|
103
|
-
row_text = row.map { |c| c[:char] }.join
|
|
104
|
-
next if row_text.strip.empty?
|
|
105
|
-
|
|
106
|
-
fgs = row.map { |c| c[:fg] || c["fg"] || "default" }
|
|
107
|
-
.uniq.reject { |c| c == "default" }
|
|
108
|
-
bgs = row.map { |c| c[:bg] || c["bg"] || "default" }
|
|
109
|
-
.uniq.reject { |c| c == "default" }
|
|
110
|
-
bold = row.any? { |c| c[:bold] || c["bold"] }
|
|
111
|
-
italic = row.any? { |c| c[:italic] || c["italic"] }
|
|
112
|
-
underline = row.any? { |c| c[:underline] || c["underline"] }
|
|
113
|
-
|
|
114
|
-
next if fgs.empty? && bgs.empty? && !bold && !italic && !underline
|
|
115
|
-
|
|
116
|
-
h = { row: ri, text: row_text }
|
|
117
|
-
h[:bold] = true if bold
|
|
118
|
-
h[:italic] = true if italic
|
|
119
|
-
h[:underline] = true if underline
|
|
120
|
-
h[:fg] = fgs.size == 1 ? fgs.first : fgs unless fgs.empty?
|
|
121
|
-
h[:bg] = bgs.size == 1 ? bgs.first : bgs unless bgs.empty?
|
|
122
|
-
highlights << h
|
|
123
|
-
end
|
|
124
|
-
highlights
|
|
125
|
-
end
|
|
126
|
-
end
|
|
6
|
+
State = TansParser::State
|
|
127
7
|
end
|
|
128
|
-
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
data/lib/tui_td/test_runner.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
3
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength, Metrics/ClassLength
|
|
4
4
|
|
|
5
5
|
require "json"
|
|
6
|
+
require "shellwords"
|
|
6
7
|
|
|
7
8
|
module TUITD
|
|
8
9
|
# Executes TUI tests defined in JSON format.
|
|
@@ -124,13 +125,13 @@ module TUITD
|
|
|
124
125
|
|
|
125
126
|
when "screenshot"
|
|
126
127
|
ensure_driver!(driver)
|
|
127
|
-
path =
|
|
128
|
+
path = safe_output_path(value, "png")
|
|
128
129
|
driver.screenshot(path)
|
|
129
130
|
Result.new(step: action, passed: true, message: "Saved: #{path}")
|
|
130
131
|
|
|
131
132
|
when "html"
|
|
132
133
|
ensure_driver!(driver)
|
|
133
|
-
path =
|
|
134
|
+
path = safe_output_path(value, "html")
|
|
134
135
|
HtmlRenderer.new(driver.state_data).render(path)
|
|
135
136
|
Result.new(step: action, passed: true, message: "Saved: #{path}")
|
|
136
137
|
|
|
@@ -194,8 +195,21 @@ module TUITD
|
|
|
194
195
|
}
|
|
195
196
|
end
|
|
196
197
|
|
|
198
|
+
ALLOWED_OUTPUT_DIRS = ["/tmp"].freeze
|
|
199
|
+
|
|
197
200
|
private
|
|
198
201
|
|
|
202
|
+
def safe_output_path(value, ext)
|
|
203
|
+
default = File.join("/tmp", "tui_td_#{Time.now.to_i}.#{ext}")
|
|
204
|
+
resolved = File.expand_path(value.is_a?(String) ? value : default)
|
|
205
|
+
|
|
206
|
+
unless ALLOWED_OUTPUT_DIRS.any? { |dir| resolved.start_with?(File.expand_path(dir)) }
|
|
207
|
+
raise TUITD::Error, "Output path must be under one of: #{ALLOWED_OUTPUT_DIRS.join(", ")}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
resolved
|
|
211
|
+
end
|
|
212
|
+
|
|
199
213
|
def ensure_driver!(driver)
|
|
200
214
|
raise Error, "No session. Add a 'start' step first." if driver.nil?
|
|
201
215
|
end
|
|
@@ -265,4 +279,4 @@ module TUITD
|
|
|
265
279
|
end
|
|
266
280
|
end
|
|
267
281
|
end
|
|
268
|
-
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength
|
|
282
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/BlockLength, Metrics/ClassLength
|
data/lib/tui_td/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tui-td
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.12
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Haluk Durmus
|
|
@@ -65,6 +65,20 @@ dependencies:
|
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
67
|
version: '3.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: tans-parser
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.1'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.1'
|
|
68
82
|
- !ruby/object:Gem::Dependency
|
|
69
83
|
name: bundler-audit
|
|
70
84
|
requirement: !ruby/object:Gem::Requirement
|