tui-td 0.1.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 +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +479 -0
- data/bin/tui-td +9 -0
- data/lib/tui_td/ansi_parser.rb +405 -0
- data/lib/tui_td/cli.rb +232 -0
- data/lib/tui_td/driver.rb +188 -0
- data/lib/tui_td/html_renderer.rb +228 -0
- data/lib/tui_td/matchers.rb +72 -0
- data/lib/tui_td/mcp/server.rb +463 -0
- data/lib/tui_td/screenshot.rb +271 -0
- data/lib/tui_td/state.rb +111 -0
- data/lib/tui_td/test_runner.rb +178 -0
- data/lib/tui_td/version.rb +5 -0
- data/lib/tui_td.rb +25 -0
- metadata +159 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TUITD
|
|
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
|
+
module ANSIParser
|
|
16
|
+
SGR_COLORS = {
|
|
17
|
+
0 => :reset,
|
|
18
|
+
1 => :bold,
|
|
19
|
+
3 => :italic,
|
|
20
|
+
4 => :underline,
|
|
21
|
+
5 => :blink,
|
|
22
|
+
7 => :reverse,
|
|
23
|
+
22 => :normal,
|
|
24
|
+
23 => :no_italic,
|
|
25
|
+
24 => :no_underline,
|
|
26
|
+
30 => :black,
|
|
27
|
+
31 => :red,
|
|
28
|
+
32 => :green,
|
|
29
|
+
33 => :yellow,
|
|
30
|
+
34 => :blue,
|
|
31
|
+
35 => :magenta,
|
|
32
|
+
36 => :cyan,
|
|
33
|
+
37 => :white,
|
|
34
|
+
38 => :xterm_fg, # 38;5;N or 38;2;R;G;B
|
|
35
|
+
39 => :default_fg,
|
|
36
|
+
40 => :bg_black,
|
|
37
|
+
41 => :bg_red,
|
|
38
|
+
42 => :bg_green,
|
|
39
|
+
43 => :bg_yellow,
|
|
40
|
+
44 => :bg_blue,
|
|
41
|
+
45 => :bg_magenta,
|
|
42
|
+
46 => :bg_cyan,
|
|
43
|
+
47 => :bg_white,
|
|
44
|
+
48 => :xterm_bg, # 48;5;N or 48;2;R;G;B
|
|
45
|
+
49 => :default_bg,
|
|
46
|
+
90 => :bright_black,
|
|
47
|
+
91 => :bright_red,
|
|
48
|
+
92 => :bright_green,
|
|
49
|
+
93 => :bright_yellow,
|
|
50
|
+
94 => :bright_blue,
|
|
51
|
+
95 => :bright_magenta,
|
|
52
|
+
96 => :bright_cyan,
|
|
53
|
+
97 => :bright_white,
|
|
54
|
+
100 => :bg_bright_black,
|
|
55
|
+
101 => :bg_bright_red,
|
|
56
|
+
102 => :bg_bright_green,
|
|
57
|
+
103 => :bg_bright_yellow,
|
|
58
|
+
104 => :bg_bright_blue,
|
|
59
|
+
105 => :bg_bright_magenta,
|
|
60
|
+
106 => :bg_bright_cyan,
|
|
61
|
+
107 => :bg_bright_white,
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
SGR_16_TO_NAME = {
|
|
65
|
+
0 => "black",
|
|
66
|
+
1 => "red",
|
|
67
|
+
2 => "green",
|
|
68
|
+
3 => "yellow",
|
|
69
|
+
4 => "blue",
|
|
70
|
+
5 => "magenta",
|
|
71
|
+
6 => "cyan",
|
|
72
|
+
7 => "white",
|
|
73
|
+
8 => "bright_black",
|
|
74
|
+
9 => "bright_red",
|
|
75
|
+
10 => "bright_green",
|
|
76
|
+
11 => "bright_yellow",
|
|
77
|
+
12 => "bright_blue",
|
|
78
|
+
13 => "bright_magenta",
|
|
79
|
+
14 => "bright_cyan",
|
|
80
|
+
15 => "bright_white",
|
|
81
|
+
}.freeze
|
|
82
|
+
|
|
83
|
+
# Parse raw terminal output into a structured state Hash
|
|
84
|
+
def self.parse(raw, rows = 40, cols = 120)
|
|
85
|
+
grid = Array.new(rows) do
|
|
86
|
+
Array.new(cols) do
|
|
87
|
+
{ char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
cursor = { row: 0, col: 0 }
|
|
92
|
+
attrs = { fg: "default", bg: "default", bold: false, italic: false, underline: false }
|
|
93
|
+
saved_cursor = nil
|
|
94
|
+
scroll_region = nil
|
|
95
|
+
|
|
96
|
+
# Strip everything before the last full clear (if any)
|
|
97
|
+
# to avoid accumulated garbage
|
|
98
|
+
processed = raw
|
|
99
|
+
|
|
100
|
+
i = 0
|
|
101
|
+
while i < processed.length
|
|
102
|
+
if processed[i] == "\e" && processed[i + 1] == "["
|
|
103
|
+
# Find end of CSI sequence
|
|
104
|
+
j = i + 2
|
|
105
|
+
j += 1 while j < processed.length && !processed[j].match?(/[A-HJ-KP-SX@`fm]/)
|
|
106
|
+
seq = processed[i..j]
|
|
107
|
+
|
|
108
|
+
_apply_csi(seq, cursor, attrs, grid, rows, cols)
|
|
109
|
+
|
|
110
|
+
i = j + 1
|
|
111
|
+
elsif processed[i] == "\n" || processed[i] == "\r\n"
|
|
112
|
+
cursor[:row] += 1
|
|
113
|
+
cursor[:col] = 0
|
|
114
|
+
i += processed[i..i + 1] == "\r\n" ? 2 : 1
|
|
115
|
+
elsif processed[i] == "\r"
|
|
116
|
+
cursor[:col] = 0
|
|
117
|
+
i += 1
|
|
118
|
+
elsif processed[i] == "\t"
|
|
119
|
+
cursor[:col] = ((cursor[:col] / 8) + 1) * 8
|
|
120
|
+
cursor[:col] = cols - 1 if cursor[:col] >= cols
|
|
121
|
+
i += 1
|
|
122
|
+
elsif processed[i] == "\b"
|
|
123
|
+
cursor[:col] -= 1 if cursor[:col] > 0
|
|
124
|
+
i += 1
|
|
125
|
+
elsif processed[i] == "\a"
|
|
126
|
+
# Bell — ignore
|
|
127
|
+
i += 1
|
|
128
|
+
elsif processed[i] == "\e"
|
|
129
|
+
# Skip escape sequences:
|
|
130
|
+
# CSI: \e[... (already handled above)
|
|
131
|
+
# ISO 2022 charset: \e( B \e) 0 etc. (3 chars total)
|
|
132
|
+
# Other: just the ESC
|
|
133
|
+
if processed[i + 1] && processed[i + 1].match?(/[()*+\-.\/]/)
|
|
134
|
+
i += 3
|
|
135
|
+
else
|
|
136
|
+
i += 1
|
|
137
|
+
end
|
|
138
|
+
elsif processed[i] =~ /[[:print:]]/
|
|
139
|
+
# Printable character
|
|
140
|
+
if cursor[:row] < rows && cursor[:col] < cols
|
|
141
|
+
cell = grid[cursor[:row]][cursor[:col]]
|
|
142
|
+
cell[:char] = processed[i]
|
|
143
|
+
cell.merge!(attrs)
|
|
144
|
+
cursor[:col] += 1
|
|
145
|
+
cursor[:col] = cols - 1 if cursor[:col] >= cols
|
|
146
|
+
end
|
|
147
|
+
i += 1
|
|
148
|
+
else
|
|
149
|
+
i += 1
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Handle scrolling
|
|
153
|
+
if cursor[:row] >= rows
|
|
154
|
+
scroll_lines = cursor[:row] - rows + 1
|
|
155
|
+
grid.shift(scroll_lines)
|
|
156
|
+
scroll_lines.times do
|
|
157
|
+
grid << Array.new(cols) { { char: " ", fg: "default", bg: "default", bold: false, italic: false, underline: false } }
|
|
158
|
+
end
|
|
159
|
+
cursor[:row] = rows - 1
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
{
|
|
164
|
+
size: { rows: rows, cols: cols },
|
|
165
|
+
cursor: cursor,
|
|
166
|
+
rows: grid,
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Rebuild ANSI output from a state hash (for rendering/screenshot)
|
|
171
|
+
def self.build_frame(state)
|
|
172
|
+
rows = state.dig(:size, :rows) || state["size"]["rows"]
|
|
173
|
+
cols = state.dig(:size, :cols) || state["size"]["cols"]
|
|
174
|
+
grid = state[:rows] || state["rows"]
|
|
175
|
+
cursor = state[:cursor] || state["cursor"]
|
|
176
|
+
|
|
177
|
+
out = +""
|
|
178
|
+
out << "\e[0m"
|
|
179
|
+
out << "\e[2J\e[H"
|
|
180
|
+
|
|
181
|
+
grid.each_with_index do |row, ri|
|
|
182
|
+
row.each_with_index do |cell, ci|
|
|
183
|
+
char = cell[:char] || cell["char"] || " "
|
|
184
|
+
fg = cell[:fg] || cell["fg"] || "default"
|
|
185
|
+
bg = cell[:bg] || cell["bg"] || "default"
|
|
186
|
+
bold = cell[:bold] || cell["bold"] || false
|
|
187
|
+
italic = cell[:italic] || cell["italic"] || false
|
|
188
|
+
underline = cell[:underline] || cell["underline"] || false
|
|
189
|
+
|
|
190
|
+
codes = []
|
|
191
|
+
codes << "1" if bold
|
|
192
|
+
codes << "3" if italic
|
|
193
|
+
codes << "4" if underline
|
|
194
|
+
|
|
195
|
+
fg_code = _color_code(fg, "38")
|
|
196
|
+
bg_code = _color_code(bg, "48")
|
|
197
|
+
|
|
198
|
+
codes << fg_code if fg_code
|
|
199
|
+
codes << bg_code if bg_code
|
|
200
|
+
|
|
201
|
+
out << "\e[#{codes.join(";")}m" unless codes.empty?
|
|
202
|
+
out << char
|
|
203
|
+
end
|
|
204
|
+
out << "\n" if ri < rows - 1
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
out << "\e[0m"
|
|
208
|
+
out
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def self._apply_csi(seq, cursor, attrs, grid, rows, cols)
|
|
212
|
+
# Strip leading escape char if present
|
|
213
|
+
cleaned = seq.sub(/^\e/, "")
|
|
214
|
+
match = cleaned.match(/^\[([\d;]*)([A-HJ-KP-SX@`fhm])$/)
|
|
215
|
+
return unless match
|
|
216
|
+
|
|
217
|
+
params = match[1].split(";").map(&:to_i)
|
|
218
|
+
command = match[2]
|
|
219
|
+
|
|
220
|
+
case command
|
|
221
|
+
when "m"
|
|
222
|
+
_apply_sgr(params, attrs)
|
|
223
|
+
when "A" # CUU — Cursor Up
|
|
224
|
+
n = params[0] || 1
|
|
225
|
+
n = 1 if n == 0
|
|
226
|
+
cursor[:row] = [cursor[:row] - n, 0].max
|
|
227
|
+
when "B" # CUD — Cursor Down
|
|
228
|
+
n = params[0] || 1
|
|
229
|
+
n = 1 if n == 0
|
|
230
|
+
cursor[:row] = [cursor[:row] + n, rows - 1].min
|
|
231
|
+
when "C" # CUF — Cursor Forward
|
|
232
|
+
n = params[0] || 1
|
|
233
|
+
n = 1 if n == 0
|
|
234
|
+
cursor[:col] = [cursor[:col] + n, cols - 1].min
|
|
235
|
+
when "D" # CUB — Cursor Back
|
|
236
|
+
n = params[0] || 1
|
|
237
|
+
n = 1 if n == 0
|
|
238
|
+
cursor[:col] = [cursor[:col] - n, 0].max
|
|
239
|
+
when "H", "f" # CUP — Cursor Position
|
|
240
|
+
r = (params[0] || 1) - 1
|
|
241
|
+
c = (params[1] || 1) - 1
|
|
242
|
+
cursor[:row] = r.clamp(0, rows - 1)
|
|
243
|
+
cursor[:col] = c.clamp(0, cols - 1)
|
|
244
|
+
when "J" # ED — Erase in Display
|
|
245
|
+
case params[0]
|
|
246
|
+
when nil, 0
|
|
247
|
+
_erase_down(cursor, grid, rows, cols)
|
|
248
|
+
when 1
|
|
249
|
+
_erase_up(cursor, grid, cols)
|
|
250
|
+
when 2, 3
|
|
251
|
+
_erase_all(grid, rows, cols)
|
|
252
|
+
cursor[:row] = 0
|
|
253
|
+
cursor[:col] = 0
|
|
254
|
+
end
|
|
255
|
+
when "K" # EL — Erase in Line
|
|
256
|
+
case params[0]
|
|
257
|
+
when nil, 0
|
|
258
|
+
_erase_line_right(cursor, grid, cols)
|
|
259
|
+
when 1
|
|
260
|
+
_erase_line_left(cursor, grid, cols)
|
|
261
|
+
when 2
|
|
262
|
+
_erase_line(cursor, grid, cols)
|
|
263
|
+
end
|
|
264
|
+
when "X" # Erase Characters
|
|
265
|
+
n = params[0] || 1
|
|
266
|
+
n.times do |i|
|
|
267
|
+
next unless cursor[:row] < rows && cursor[:col] + i < cols
|
|
268
|
+
grid[cursor[:row]][cursor[:col] + i][:char] = " "
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def self._apply_sgr(params, attrs)
|
|
274
|
+
return attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false) if params.empty? || params == [0]
|
|
275
|
+
|
|
276
|
+
i = 0
|
|
277
|
+
while i < params.length
|
|
278
|
+
p = params[i]
|
|
279
|
+
case p
|
|
280
|
+
when 0
|
|
281
|
+
attrs.merge!(fg: "default", bg: "default", bold: false, italic: false, underline: false)
|
|
282
|
+
when 1
|
|
283
|
+
attrs[:bold] = true
|
|
284
|
+
when 3
|
|
285
|
+
attrs[:italic] = true
|
|
286
|
+
when 4
|
|
287
|
+
attrs[:underline] = true
|
|
288
|
+
when 22
|
|
289
|
+
attrs[:bold] = false
|
|
290
|
+
when 23
|
|
291
|
+
attrs[:italic] = false
|
|
292
|
+
when 24
|
|
293
|
+
attrs[:underline] = false
|
|
294
|
+
when 7
|
|
295
|
+
# Reverse — swap fg and bg
|
|
296
|
+
attrs[:fg], attrs[:bg] = attrs[:bg], attrs[:fg]
|
|
297
|
+
when 27
|
|
298
|
+
attrs[:fg], attrs[:bg] = attrs[:bg], attrs[:fg]
|
|
299
|
+
when 30..37
|
|
300
|
+
attrs[:fg] = SGR_16_TO_NAME[p - 30] || "color#{p - 30}"
|
|
301
|
+
when 38
|
|
302
|
+
# Extended foreground
|
|
303
|
+
if params[i + 1] == 5
|
|
304
|
+
color = params[i + 2]
|
|
305
|
+
attrs[:fg] = "color#{color}"
|
|
306
|
+
i += 2
|
|
307
|
+
elsif params[i + 1] == 2
|
|
308
|
+
r, g, b = params[i + 2], params[i + 3], params[i + 4]
|
|
309
|
+
attrs[:fg] = format("#%02x%02x%02x", r, g, b)
|
|
310
|
+
i += 4
|
|
311
|
+
end
|
|
312
|
+
when 39
|
|
313
|
+
attrs[:fg] = "default"
|
|
314
|
+
when 40..47
|
|
315
|
+
attrs[:bg] = SGR_16_TO_NAME[p - 40] || "bg_color#{p - 40}"
|
|
316
|
+
when 48
|
|
317
|
+
# Extended background
|
|
318
|
+
if params[i + 1] == 5
|
|
319
|
+
color = params[i + 2]
|
|
320
|
+
attrs[:bg] = "color#{color}"
|
|
321
|
+
i += 2
|
|
322
|
+
elsif params[i + 1] == 2
|
|
323
|
+
r, g, b = params[i + 2], params[i + 3], params[i + 4]
|
|
324
|
+
attrs[:bg] = format("#%02x%02x%02x", r, g, b)
|
|
325
|
+
i += 4
|
|
326
|
+
end
|
|
327
|
+
when 49
|
|
328
|
+
attrs[:bg] = "default"
|
|
329
|
+
when 90..97
|
|
330
|
+
attrs[:fg] = "bright_#{SGR_16_TO_NAME[p - 90] || "color#{p - 90 + 8}"}"
|
|
331
|
+
when 100..107
|
|
332
|
+
attrs[:bg] = "bright_#{SGR_16_TO_NAME[p - 100] || "color#{p - 100 + 8}"}"
|
|
333
|
+
end
|
|
334
|
+
i += 1
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def self._erase_down(cursor, grid, rows, cols)
|
|
339
|
+
r = cursor[:row]
|
|
340
|
+
c = cursor[:col]
|
|
341
|
+
|
|
342
|
+
# Erase from cursor to end of line
|
|
343
|
+
(c...cols).each { |ci| grid[r][ci][:char] = " " if r < rows }
|
|
344
|
+
|
|
345
|
+
# Erase remaining lines
|
|
346
|
+
((r + 1)...rows).each do |ri|
|
|
347
|
+
cols.times { |ci| grid[ri][ci][:char] = " " }
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def self._erase_up(cursor, grid, cols)
|
|
352
|
+
r = cursor[:row]
|
|
353
|
+
c = cursor[:col]
|
|
354
|
+
|
|
355
|
+
# Erase lines above cursor
|
|
356
|
+
(0...r).each do |ri|
|
|
357
|
+
cols.times { |ci| grid[ri][ci][:char] = " " }
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Erase from start of line to cursor
|
|
361
|
+
(0..c).each { |ci| grid[r][ci][:char] = " " }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def self._erase_all(grid, rows, cols)
|
|
365
|
+
rows.times do |ri|
|
|
366
|
+
cols.times { |ci| grid[ri][ci][:char] = " " }
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def self._erase_line_right(cursor, grid, cols)
|
|
371
|
+
r = cursor[:row]
|
|
372
|
+
c = cursor[:col]
|
|
373
|
+
(c...cols).each { |ci| grid[r][ci][:char] = " " if r < grid.length }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def self._erase_line_left(cursor, grid, cols)
|
|
377
|
+
r = cursor[:row]
|
|
378
|
+
c = cursor[:col]
|
|
379
|
+
(0..c).each { |ci| grid[r][ci][:char] = " " if r < grid.length }
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def self._erase_line(cursor, grid, cols)
|
|
383
|
+
r = cursor[:row]
|
|
384
|
+
cols.times { |ci| grid[r][ci][:char] = " " if r < grid.length }
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def self._color_code(name, prefix)
|
|
388
|
+
case name
|
|
389
|
+
when "default" then nil
|
|
390
|
+
when /^(bright_)?(.+)$/
|
|
391
|
+
base_name = $2
|
|
392
|
+
index = SGR_16_TO_NAME.key(base_name)
|
|
393
|
+
index += 8 if $1 && index && index < 8
|
|
394
|
+
index ? "#{prefix};5;#{index}" : nil
|
|
395
|
+
when /^#([0-9a-fA-F]{6})$/
|
|
396
|
+
r = $1[0..1].to_i(16)
|
|
397
|
+
g = $1[2..3].to_i(16)
|
|
398
|
+
b = $1[4..5].to_i(16)
|
|
399
|
+
"#{prefix};2;#{r};#{g};#{b}"
|
|
400
|
+
else
|
|
401
|
+
nil
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
data/lib/tui_td/cli.rb
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module TUITD
|
|
6
|
+
# Command-line interface for tui-td
|
|
7
|
+
class CLI
|
|
8
|
+
def self.run(argv = ARGV)
|
|
9
|
+
new.run(argv)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run(argv)
|
|
13
|
+
global_opts = {}
|
|
14
|
+
command = nil
|
|
15
|
+
command_opts = {}
|
|
16
|
+
|
|
17
|
+
OptionParser.new do |opts|
|
|
18
|
+
opts.banner = "Usage: tui-td <command> [options]"
|
|
19
|
+
opts.separator ""
|
|
20
|
+
opts.separator "Commands:"
|
|
21
|
+
opts.separator " serve Start MCP server (JSON-RPC over stdio)"
|
|
22
|
+
opts.separator " run <command> Run a TUI app and show live output"
|
|
23
|
+
opts.separator " drive <command> Drive a TUI with structured state output"
|
|
24
|
+
opts.separator " capture <command> Run once, capture and display state"
|
|
25
|
+
opts.separator " test <file> Run JSON test file"
|
|
26
|
+
opts.separator " help Show this help"
|
|
27
|
+
opts.separator ""
|
|
28
|
+
opts.separator "Global options:"
|
|
29
|
+
|
|
30
|
+
opts.on("-r", "--rows N", Integer, "Terminal rows (default: 40)") do |r|
|
|
31
|
+
global_opts[:rows] = r
|
|
32
|
+
end
|
|
33
|
+
opts.on("-c", "--cols N", Integer, "Terminal cols (default: 120)") do |c|
|
|
34
|
+
global_opts[:cols] = c
|
|
35
|
+
end
|
|
36
|
+
opts.on("-t", "--timeout SECONDS", Integer, "Timeout in seconds (default: 30)") do |t|
|
|
37
|
+
global_opts[:timeout] = t
|
|
38
|
+
end
|
|
39
|
+
opts.on("-C", "--chdir PATH", "Working directory for the command") do |d|
|
|
40
|
+
global_opts[:chdir] = d
|
|
41
|
+
end
|
|
42
|
+
opts.on("--screenshot PATH", "Save screenshot (e.g., output.png)") do |p|
|
|
43
|
+
global_opts[:screenshot] = p
|
|
44
|
+
end
|
|
45
|
+
opts.on("--html PATH", "Save HTML render (e.g., output.html)") do |p|
|
|
46
|
+
global_opts[:html] = p
|
|
47
|
+
end
|
|
48
|
+
opts.on("--json", "Output state as compact JSON") do |_|
|
|
49
|
+
global_opts[:format] = :json
|
|
50
|
+
end
|
|
51
|
+
opts.on("--pretty", "Output state as pretty JSON") do |_|
|
|
52
|
+
global_opts[:format] = :pretty_json
|
|
53
|
+
end
|
|
54
|
+
opts.on("--text", "Output state as plain text table") do |_|
|
|
55
|
+
global_opts[:format] = :text
|
|
56
|
+
end
|
|
57
|
+
opts.on("-h", "--help", "Show help") do
|
|
58
|
+
puts opts
|
|
59
|
+
exit 0
|
|
60
|
+
end
|
|
61
|
+
end.permute!(argv)
|
|
62
|
+
|
|
63
|
+
command = argv.shift
|
|
64
|
+
command_opts[:args] = argv
|
|
65
|
+
|
|
66
|
+
case command
|
|
67
|
+
when "serve"
|
|
68
|
+
cmd_serve(global_opts)
|
|
69
|
+
when "run"
|
|
70
|
+
cmd_run(command_opts, global_opts)
|
|
71
|
+
when "drive"
|
|
72
|
+
cmd_drive(command_opts, global_opts)
|
|
73
|
+
when "capture"
|
|
74
|
+
cmd_capture(command_opts, global_opts)
|
|
75
|
+
when "test"
|
|
76
|
+
cmd_test(command_opts, global_opts)
|
|
77
|
+
when nil, "help"
|
|
78
|
+
puts OptionParser.new { |o| o.banner = "Usage: tui-td <command> [options]" }
|
|
79
|
+
exit 0
|
|
80
|
+
else
|
|
81
|
+
abort "Unknown command: #{command.inspect}\nUse tui-td --help for usage"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def cmd_serve(globals)
|
|
88
|
+
server = MCP::Server.new(
|
|
89
|
+
rows: globals[:rows] || 40,
|
|
90
|
+
cols: globals[:cols] || 120,
|
|
91
|
+
timeout: globals[:timeout] || 30
|
|
92
|
+
)
|
|
93
|
+
server.start
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def cmd_run(opts, globals)
|
|
97
|
+
args = opts[:args]
|
|
98
|
+
abort "Usage: tui-td run <command>" if args.empty?
|
|
99
|
+
cmd = args.join(" ")
|
|
100
|
+
|
|
101
|
+
driver = Driver.new(cmd, **globals.slice(:rows, :cols, :timeout, :chdir))
|
|
102
|
+
puts "Starting: #{cmd}"
|
|
103
|
+
puts "─" * (globals[:cols] || 80)
|
|
104
|
+
driver.start
|
|
105
|
+
|
|
106
|
+
driver.wait_for_stable
|
|
107
|
+
|
|
108
|
+
if globals[:format] == :json || globals[:format] == :pretty_json
|
|
109
|
+
puts driver.state_json(pretty: globals[:format] == :pretty_json)
|
|
110
|
+
else
|
|
111
|
+
_render_text(driver.state_data)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
if globals[:screenshot]
|
|
115
|
+
path = driver.screenshot(globals[:screenshot])
|
|
116
|
+
puts "Screenshot saved: #{path}"
|
|
117
|
+
end
|
|
118
|
+
if globals[:html]
|
|
119
|
+
path = HtmlRenderer.new(driver.state_data).render(globals[:html])
|
|
120
|
+
puts "HTML saved: #{path}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
driver.close
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def cmd_drive(opts, globals)
|
|
127
|
+
args = opts[:args]
|
|
128
|
+
abort "Usage: tui-td drive <command>" if args.empty?
|
|
129
|
+
cmd = args.join(" ")
|
|
130
|
+
|
|
131
|
+
driver = Driver.new(cmd, **globals.slice(:rows, :cols, :timeout, :chdir))
|
|
132
|
+
puts "Starting interactive drive: #{cmd}"
|
|
133
|
+
puts "Type commands to send. Exit with Ctrl+C."
|
|
134
|
+
driver.start
|
|
135
|
+
|
|
136
|
+
begin
|
|
137
|
+
loop do
|
|
138
|
+
driver.wait_for_stable
|
|
139
|
+
print "> "
|
|
140
|
+
input = $stdin.gets
|
|
141
|
+
break unless input
|
|
142
|
+
input = input.chomp
|
|
143
|
+
break if input == "exit"
|
|
144
|
+
|
|
145
|
+
if input == "state"
|
|
146
|
+
puts driver.state_json(pretty: true)
|
|
147
|
+
elsif input == "raw"
|
|
148
|
+
puts driver.raw_output[0..2000]
|
|
149
|
+
elsif input.start_with?("key ")
|
|
150
|
+
driver.send_keys(input.split(" ", 2).last.to_sym)
|
|
151
|
+
else
|
|
152
|
+
driver.send(input + "\n")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
rescue Interrupt
|
|
156
|
+
puts "\nDone."
|
|
157
|
+
ensure
|
|
158
|
+
driver.close
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def cmd_capture(opts, globals)
|
|
163
|
+
args = opts[:args]
|
|
164
|
+
abort "Usage: tui-td capture <command>" if args.empty?
|
|
165
|
+
cmd = args.join(" ")
|
|
166
|
+
|
|
167
|
+
driver = Driver.new(cmd, **globals.slice(:rows, :cols, :timeout, :chdir))
|
|
168
|
+
driver.start
|
|
169
|
+
driver.wait_for_stable
|
|
170
|
+
|
|
171
|
+
case globals[:format]
|
|
172
|
+
when :json
|
|
173
|
+
puts driver.state_json(pretty: false)
|
|
174
|
+
when :pretty_json
|
|
175
|
+
puts driver.state_json(pretty: true)
|
|
176
|
+
else
|
|
177
|
+
_render_text(driver.state_data)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
if globals[:screenshot]
|
|
181
|
+
path = driver.screenshot(globals[:screenshot])
|
|
182
|
+
puts "Screenshot saved: #{path}"
|
|
183
|
+
end
|
|
184
|
+
if globals[:html]
|
|
185
|
+
path = HtmlRenderer.new(driver.state_data).render(globals[:html])
|
|
186
|
+
puts "HTML saved: #{path}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
driver.close
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def cmd_test(opts, globals)
|
|
193
|
+
args = opts[:args]
|
|
194
|
+
abort "Usage: tui-td test <file.json>" if args.empty?
|
|
195
|
+
|
|
196
|
+
path = args.first
|
|
197
|
+
abort "File not found: #{path}" unless File.exist?(path)
|
|
198
|
+
|
|
199
|
+
require "json"
|
|
200
|
+
plan = JSON.parse(File.read(path), symbolize_names: true)
|
|
201
|
+
runner = TestRunner.new(plan)
|
|
202
|
+
result = runner.run
|
|
203
|
+
|
|
204
|
+
puts
|
|
205
|
+
puts "Test: #{result[:name]}"
|
|
206
|
+
puts "Status: #{result[:passed] ? 'PASSED' : 'FAILED'}"
|
|
207
|
+
puts "-" * 40
|
|
208
|
+
|
|
209
|
+
result[:results].each do |r|
|
|
210
|
+
status = r[:passed] ? "PASS" : "FAIL"
|
|
211
|
+
puts " [#{status}] #{r[:step]}: #{r[:message]}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
exit 1 unless result[:passed]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def _render_text(state)
|
|
218
|
+
rows = state.dig(:size, :rows) || 40
|
|
219
|
+
cols = state.dig(:size, :cols) || 120
|
|
220
|
+
grid = state[:rows] || []
|
|
221
|
+
cursor = state[:cursor] || {}
|
|
222
|
+
|
|
223
|
+
puts "Terminal: #{cols}x#{rows} Cursor: [#{cursor[:row]}, #{cursor[:col]}]"
|
|
224
|
+
puts "─" * [cols, 80].min
|
|
225
|
+
|
|
226
|
+
grid.each_with_index do |row, _ri|
|
|
227
|
+
line = row.map { |cell| cell[:char] }.join
|
|
228
|
+
puts line.empty? ? "~" : line
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|