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.
@@ -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