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,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pty"
4
+ require "io/console"
5
+ require "json"
6
+
7
+ module TUITD
8
+ # Drives a TUI application in a pseudo-terminal (PTY).
9
+ #
10
+ # Usage:
11
+ # driver = Driver.new("my_tui_app")
12
+ # driver.start
13
+ # driver.wait_for_text("> ")
14
+ # driver.send("Write hello\n")
15
+ # state = driver.state_json # => structured JSON for AI
16
+ # driver.screenshot("out.png")
17
+ # driver.close
18
+ #
19
+ class Driver
20
+ attr_reader :command, :state
21
+
22
+ def initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil)
23
+ @command = command
24
+ @rows = rows
25
+ @cols = cols
26
+ @timeout = timeout
27
+ @chdir = chdir
28
+ @state = nil
29
+ @stdin = nil
30
+ @stdout = nil
31
+ @wait_thr = nil
32
+ @output_buffer = +""
33
+ end
34
+
35
+ # Start the TUI application in a PTY
36
+ def start
37
+ env = { "TERM" => "xterm-256color", "COLUMNS" => @cols.to_s, "LINES" => @rows.to_s }
38
+ spawn_opts = {}
39
+ spawn_opts[:chdir] = @chdir if @chdir
40
+
41
+ @stdout, @stdin, @pid = PTY.spawn(env, @command, spawn_opts)
42
+ @stdout.winsize = [@rows, @cols] # Set PTY window size for TUIs that check winsize
43
+ @wait_thr = Process.detach(@pid)
44
+
45
+ # Read until initial output stabilizes
46
+ wait_for_stable
47
+ refresh_state!
48
+
49
+ true
50
+ end
51
+
52
+ # Send text to the TUI
53
+ def send(text)
54
+ ensure_running!
55
+ @stdin&.print(text)
56
+ @stdin&.flush
57
+ true
58
+ end
59
+
60
+ # Send keys (escape sequences, control characters)
61
+ def send_keys(keys)
62
+ ensure_running!
63
+ case keys
64
+ when :enter then send("\r")
65
+ when :tab then send("\t")
66
+ when :escape then send("\e")
67
+ when :up then send("\e[A")
68
+ when :down then send("\e[B")
69
+ when :left then send("\e[D")
70
+ when :right then send("\e[C")
71
+ when :backspace then send("\u007f")
72
+ when :ctrl_c then send("\u0003")
73
+ when :ctrl_d then send("\u0004")
74
+ else send(keys.to_s)
75
+ end
76
+ end
77
+
78
+ # Wait until output contains the given text
79
+ def wait_for_text(text)
80
+ deadline = monotonic + @timeout
81
+ loop do
82
+ raise TimeoutError, "Timeout waiting for: #{text.inspect}" if monotonic > deadline
83
+ read_available!
84
+ break if @output_buffer.include?(text)
85
+ sleep 0.05
86
+ end
87
+ refresh_state!
88
+ end
89
+
90
+ # Wait for output to stabilize (no new data for N milliseconds)
91
+ def wait_for_stable(stable_ms: 300)
92
+ deadline = monotonic + @timeout
93
+ last_change = monotonic
94
+
95
+ loop do
96
+ raise TimeoutError, "Timeout waiting for stable output" if monotonic > deadline
97
+
98
+ if read_available!
99
+ last_change = monotonic
100
+ elsif (monotonic - last_change) * 1000 >= stable_ms
101
+ break
102
+ end
103
+
104
+ sleep 0.05
105
+ end
106
+ refresh_state!
107
+ end
108
+
109
+ # Wait until the process finishes
110
+ def wait_for_exit
111
+ @wait_thr&.value
112
+ end
113
+
114
+ # Get the terminal output (raw ANSI + text)
115
+ def raw_output
116
+ read_available!
117
+ @output_buffer
118
+ end
119
+
120
+ # Get structured terminal state as a Hash
121
+ def state_data
122
+ refresh_state! if @state.nil?
123
+ @state
124
+ end
125
+
126
+ # Get structured terminal state as JSON string
127
+ def state_json(pretty: false)
128
+ state_data
129
+ pretty ? JSON.pretty_generate(@state) : JSON.generate(@state)
130
+ end
131
+
132
+ # Capture a PNG screenshot of the current terminal state
133
+ def screenshot(output_path)
134
+ state_data
135
+ Screenshot.new(@state).render(output_path)
136
+ end
137
+
138
+ # Close the driver and clean up
139
+ def close
140
+ # Kill the process if still running
141
+ if @pid
142
+ begin
143
+ if Process.waitpid(@pid, Process::WNOHANG).nil?
144
+ Process.kill("TERM", @pid) rescue nil
145
+ sleep 0.05
146
+ Process.kill("KILL", @pid) rescue nil
147
+ end
148
+ rescue Errno::ECHILD
149
+ # Already reaped by Process.detach
150
+ end
151
+ end
152
+ @stdin&.close rescue nil
153
+ @stdout&.close rescue nil
154
+ @stdin = @stdout = @pid = nil
155
+ end
156
+
157
+ private
158
+
159
+ def ensure_running!
160
+ raise Error, "Driver not started. Call #start first." if @stdin.nil?
161
+ raise Error, "Process exited (status: #{@wait_thr&.value&.exitstatus})" unless @wait_thr&.alive?
162
+ end
163
+
164
+ def read_available!
165
+ return false unless @stdout
166
+
167
+ ready, = IO.select([@stdout], nil, nil, 0.01)
168
+ return false unless ready
169
+
170
+ data = @stdout.readpartial(4096)
171
+ @output_buffer << data
172
+ true
173
+ rescue EOFError
174
+ false
175
+ end
176
+
177
+ def refresh_state!
178
+ @state = ANSIParser.parse(@output_buffer, @rows, @cols)
179
+ @state[:raw] = @output_buffer.dup
180
+ end
181
+
182
+ def monotonic
183
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
184
+ end
185
+ end
186
+
187
+ class TimeoutError < Error; end
188
+ end
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUITD
4
+ # Renders terminal state as a self-contained HTML document.
5
+ # Faithfully reproduces what a TUI application shows — colors, styles,
6
+ # cursor position — so an LLM or human can "see" the terminal.
7
+ class HtmlRenderer
8
+ ANSI_RGB = {
9
+ "black" => [0x00, 0x00, 0x00],
10
+ "red" => [0xAA, 0x00, 0x00],
11
+ "green" => [0x00, 0xAA, 0x00],
12
+ "yellow" => [0xAA, 0x55, 0x00],
13
+ "blue" => [0x00, 0x00, 0xAA],
14
+ "magenta" => [0xAA, 0x00, 0xAA],
15
+ "cyan" => [0x00, 0xAA, 0xAA],
16
+ "white" => [0xAA, 0xAA, 0xAA],
17
+ "bright_black" => [0x55, 0x55, 0x55],
18
+ "bright_red" => [0xFF, 0x55, 0x55],
19
+ "bright_green" => [0x55, 0xFF, 0x55],
20
+ "bright_yellow" => [0xFF, 0xFF, 0x55],
21
+ "bright_blue" => [0x55, 0x55, 0xFF],
22
+ "bright_magenta"=> [0xFF, 0x55, 0xFF],
23
+ "bright_cyan" => [0x55, 0xFF, 0xFF],
24
+ "bright_white" => [0xFF, 0xFF, 0xFF],
25
+ }.freeze
26
+
27
+ CUBE = [0x00, 0x5F, 0x87, 0xAF, 0xD7, 0xFF].freeze
28
+
29
+ ANSI_INDEX = %w[
30
+ black red green yellow blue magenta cyan white
31
+ bright_black bright_red bright_green bright_yellow
32
+ bright_blue bright_magenta bright_cyan bright_white
33
+ ].freeze
34
+
35
+ DEFAULT_FG = [0xC0, 0xC0, 0xC0].freeze
36
+ DEFAULT_BG = [0x00, 0x00, 0x00].freeze
37
+
38
+ def initialize(state)
39
+ @state = state
40
+ @rows = _dig(state, :size, :rows) || 40
41
+ @cols = _dig(state, :size, :cols) || 120
42
+ @grid = state[:rows] || state["rows"] || []
43
+ @cursor = state[:cursor] || state["cursor"] || { row: 0, col: 0 }
44
+ end
45
+
46
+ # Return HTML string
47
+ def to_html
48
+ css = render_css
49
+ body = render_body
50
+ <<~HTML
51
+ <!DOCTYPE html>
52
+ <html lang="en">
53
+ <head>
54
+ <meta charset="UTF-8">
55
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
56
+ <title>TUI Terminal</title>
57
+ <style>#{css}</style>
58
+ </head>
59
+ <body>#{body}</body>
60
+ </html>
61
+ HTML
62
+ end
63
+
64
+ # Write HTML to a file
65
+ def render(output_path)
66
+ File.write(output_path, to_html)
67
+ output_path
68
+ end
69
+
70
+ private
71
+
72
+ def render_css
73
+ <<~CSS
74
+ * { margin: 0; padding: 0; box-sizing: border-box; }
75
+ body {
76
+ background: #111;
77
+ color: #{css_color(DEFAULT_FG)};
78
+ font-family: "SF Mono", "Fira Code", "Cascadia Code", "JetBrains Mono", "DejaVu Sans Mono", "Menlo", "Monaco", "Courier New", monospace;
79
+ font-size: 14px;
80
+ line-height: 1.3;
81
+ padding: 16px;
82
+ }
83
+ .term {
84
+ display: inline-block;
85
+ background: #{css_color(DEFAULT_BG)};
86
+ border: 1px solid #333;
87
+ border-radius: 4px;
88
+ padding: 8px;
89
+ }
90
+ .line {
91
+ white-space: pre;
92
+ line-height: 1.3;
93
+ min-height: 1.3em;
94
+ }
95
+ .cursor-cell {
96
+ outline: 2px solid #ff0;
97
+ outline-offset: -1px;
98
+ z-index: 1;
99
+ position: relative;
100
+ }
101
+ CSS
102
+ end
103
+
104
+ def render_body
105
+ lines = @grid.map.with_index do |row, ri|
106
+ line_html = if row.nil? || row.empty?
107
+ '<span class="line"></span>'
108
+ else
109
+ runs = build_runs(row, ri)
110
+ spans = runs.map do |run|
111
+ render_run(run)
112
+ end
113
+ %(<span class="line">#{spans.join}</span>)
114
+ end
115
+ line_html
116
+ end
117
+
118
+ %(<pre class="term">\n#{lines.join("\n")}\n</pre>)
119
+ end
120
+
121
+ def build_runs(row, ri)
122
+ runs = []
123
+ current_run = nil
124
+
125
+ row.each_with_index do |cell, ci|
126
+ char = (cell[:char] || cell["char"] || " ")
127
+ fg = cell[:fg] || cell["fg"] || "default"
128
+ bg = cell[:bg] || cell["bg"] || "default"
129
+ bold = cell[:bold] || cell["bold"] || false
130
+ italic = cell[:italic] || cell["italic"] || false
131
+ underline = cell[:underline] || cell["underline"] || false
132
+
133
+ style_key = [fg, bg, bold, italic, underline]
134
+
135
+ if current_run && current_run[:key] == style_key
136
+ current_run[:chars] << char
137
+ else
138
+ current_run = {
139
+ key: style_key,
140
+ chars: [char],
141
+ style: cell_style(fg, bg, bold, italic, underline),
142
+ has_cursor: is_cursor?(ri, ci)
143
+ }
144
+ runs << current_run
145
+ end
146
+ end
147
+
148
+ runs
149
+ end
150
+
151
+ def cell_style(fg, bg, bold, italic, underline)
152
+ parts = []
153
+ parts << "color:#{css_color(resolve_color(fg, DEFAULT_FG))}"
154
+ parts << "background-color:#{css_color(resolve_color(bg, DEFAULT_BG))}" unless bg == "default"
155
+ parts << "font-weight:bold" if bold
156
+ parts << "font-style:italic" if italic
157
+ parts << "text-decoration:underline" if underline
158
+ parts.join(";")
159
+ end
160
+
161
+ def render_run(run)
162
+ chars = run[:chars].map { |c| escape_html(c) }.join
163
+ return chars if run[:style].empty? && !run[:has_cursor]
164
+
165
+ classes = []
166
+ classes << "cursor-cell" if run[:has_cursor]
167
+ cls = classes.empty? ? "" : %( class="#{classes.join(" ")}")
168
+ style = run[:style].empty? ? "" : %( style="#{run[:style]}")
169
+ %(<span#{cls}#{style}>#{chars}</span>)
170
+ end
171
+
172
+ def is_cursor?(ri, ci)
173
+ @cursor[:row] == ri && @cursor[:col] == ci
174
+ end
175
+
176
+ def resolve_color(name, fallback)
177
+ case name
178
+ when "default"
179
+ fallback
180
+ when /^#([0-9a-fA-F]{6})$/
181
+ [$1[0..1].to_i(16), $1[2..3].to_i(16), $1[4..5].to_i(16)]
182
+ when /\Acolor(\d+)\z/
183
+ xterm_256($1.to_i)
184
+ when /\Abright_(.+)\z/
185
+ ANSI_RGB[name] || fallback
186
+ else
187
+ ANSI_RGB[name] || fallback
188
+ end
189
+ end
190
+
191
+ def xterm_256(index)
192
+ if index < 16
193
+ name = ANSI_INDEX[index]
194
+ ANSI_RGB[name] || DEFAULT_FG
195
+ elsif index < 232
196
+ r = CUBE[((index - 16) / 36) % 6]
197
+ g = CUBE[((index - 16) / 6) % 6]
198
+ b = CUBE[(index - 16) % 6]
199
+ [r, g, b]
200
+ else
201
+ v = 8 + (index - 232) * 10
202
+ [v, v, v]
203
+ end
204
+ end
205
+
206
+ def css_color(rgb)
207
+ format("#%02x%02x%02x", *rgb)
208
+ end
209
+
210
+ def escape_html(char)
211
+ case char
212
+ when "&" then "&amp;"
213
+ when "<" then "&lt;"
214
+ when ">" then "&gt;"
215
+ when '"' then "&quot;"
216
+ else char
217
+ end
218
+ end
219
+
220
+ def _dig(hash, *keys)
221
+ keys.each do |k|
222
+ return nil unless hash
223
+ hash = hash[k] || hash[k.to_s]
224
+ end
225
+ hash
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+
5
+ # RSpec matchers for TUITD::State objects.
6
+ #
7
+ # Usage:
8
+ # require "tui_td/matchers"
9
+ #
10
+ # state = TUITD::State.new(driver.state_data)
11
+ # expect(state).to have_text("Welcome")
12
+ # expect(state).to have_fg("cyan").at(0, 0)
13
+ #
14
+ module TUITD
15
+ module Matchers
16
+ RSpec::Matchers.define :have_text do |expected|
17
+ match do |state|
18
+ state.find_text(expected).any?
19
+ end
20
+
21
+ description { "have text #{expected.inspect}" }
22
+ failure_message { |state| "expected terminal to contain #{expected.inspect}" }
23
+ failure_message_when_negated { |state| "expected terminal NOT to contain #{expected.inspect}" }
24
+ end
25
+
26
+ RSpec::Matchers.define :have_fg do |expected|
27
+ chain(:at) { |row, col| @row, @col = row, col }
28
+
29
+ match do |state|
30
+ @actual = state.foreground_at(@row, @col)
31
+ @actual == expected
32
+ end
33
+
34
+ description { "have foreground #{expected.inspect} at [#{@row},#{@col}]" }
35
+ failure_message do |state|
36
+ "expected FG at [#{@row},#{@col}] to be #{expected.inspect}, but was #{@actual.inspect}"
37
+ end
38
+ end
39
+
40
+ RSpec::Matchers.define :have_bg do |expected|
41
+ chain(:at) { |row, col| @row, @col = row, col }
42
+
43
+ match do |state|
44
+ @actual = state.background_at(@row, @col)
45
+ @actual == expected
46
+ end
47
+
48
+ description { "have background #{expected.inspect} at [#{@row},#{@col}]" }
49
+ failure_message do |state|
50
+ "expected BG at [#{@row},#{@col}] to be #{expected.inspect}, but was #{@actual.inspect}"
51
+ end
52
+ end
53
+
54
+ RSpec::Matchers.define :have_style do
55
+ chain(:at) { |row, col| @row, @col = row, col }
56
+ chain(:with) { |expected| @expected = expected }
57
+
58
+ match do |state|
59
+ @actual = state.style_at(@row, @col)
60
+ @expected ||= {}
61
+ @expected.all? { |k, v| @actual[k] == v }
62
+ end
63
+
64
+ description do
65
+ "have style #{@expected.inspect} at [#{@row},#{@col}]"
66
+ end
67
+ failure_message do |state|
68
+ "expected style at [#{@row},#{@col}] to be #{@expected.inspect}, but was #{@actual.inspect}"
69
+ end
70
+ end
71
+ end
72
+ end