Class: TUITD::Driver
- Inherits:
-
Object
- Object
- TUITD::Driver
- Defined in:
- lib/tui_td/driver.rb
Overview
Drives a TUI application in a pseudo-terminal (PTY).
Usage:
driver = Driver.new("my_tui_app")
driver.start
driver.wait_for_text("> ")
driver.send("Write hello\n")
state = driver.state_json # => structured JSON for AI
driver.screenshot("out.png")
driver.close
Constant Summary collapse
- FORBIDDEN_ENV =
%w[PATH LD_PRELOAD LD_LIBRARY_PATH DYLD_INSERT_LIBRARIES DYLD_FRAMEWORK_PATH RUBYOPT HOME RUBYLIB GEM_HOME GEM_PATH].freeze
- MAX_BUFFER_SIZE =
10 MB ring buffer
10 * 1024 * 1024
Instance Attribute Summary collapse
-
#command ⇒ Object
readonly
Returns the value of attribute command.
-
#state ⇒ Object
readonly
Returns the value of attribute state.
Instance Method Summary collapse
-
#close ⇒ Object
Close the driver and clean up.
-
#exitstatus ⇒ Object
Get the process exit status (nil if still running).
-
#initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil, env: {}, poll_interval: nil) ⇒ Driver
constructor
A new instance of Driver.
-
#raw_output ⇒ Object
Get the terminal output (raw ANSI + text).
-
#refresh ⇒ Object
Refresh the terminal state by re-parsing the output buffer.
-
#screenshot(output_path) ⇒ Object
Capture a PNG screenshot of the current terminal state.
-
#send(text) ⇒ Object
Send text to the TUI.
-
#send_keys(keys) ⇒ Object
Send keys (escape sequences, control characters).
-
#start ⇒ Object
Start the TUI application in a PTY.
-
#state_data ⇒ Object
Get structured terminal state as a Hash.
-
#state_json(pretty: false) ⇒ Object
Get structured terminal state as JSON string.
-
#wait_for(timeout: nil, &predicate) ⇒ Object
Wait until the predicate returns true for the current terminal state.
-
#wait_for_exit ⇒ Object
Wait until the process finishes.
-
#wait_for_stable(stable_ms: 300) ⇒ Object
Wait for output to stabilize (grid content unchanged for N milliseconds).
-
#wait_for_text(text) ⇒ Object
Wait until output contains the given text.
Constructor Details
#initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil, env: {}, poll_interval: nil) ⇒ Driver
Returns a new instance of Driver.
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
# File 'lib/tui_td/driver.rb', line 31 def initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil, env: {}, poll_interval: nil) @command = command @rows = rows @cols = cols @timeout = timeout @chdir = chdir @env = sanitize_env(env) @state = nil @stdin = nil @stdout = nil @wait_thr = nil @output_buffer = +"" @output_mutex = Mutex.new @reader_thread = nil @reader_running = false @poll_interval = poll_interval end |
Instance Attribute Details
#command ⇒ Object (readonly)
Returns the value of attribute command.
27 28 29 |
# File 'lib/tui_td/driver.rb', line 27 def command @command end |
#state ⇒ Object (readonly)
Returns the value of attribute state.
27 28 29 |
# File 'lib/tui_td/driver.rb', line 27 def state @state end |
Instance Method Details
#close ⇒ Object
Close the driver and clean up
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 |
# File 'lib/tui_td/driver.rb', line 215 def close _stop_reader_thread # Kill the process if still running if @pid begin if Process.waitpid(@pid, Process::WNOHANG).nil? begin Process.kill("TERM", @pid) rescue StandardError nil end sleep 0.05 begin Process.kill("KILL", @pid) rescue StandardError nil end end rescue Errno::ECHILD # Already reaped by Process.detach end end begin @stdin&.close rescue StandardError nil end begin @stdout&.close rescue StandardError nil end @stdin = @stdout = @pid = nil end |
#exitstatus ⇒ Object
Get the process exit status (nil if still running)
174 175 176 177 178 179 180 181 |
# File 'lib/tui_td/driver.rb', line 174 def exitstatus return nil unless @wait_thr status = @wait_thr.value status&.exitstatus rescue NoMethodError nil end |
#raw_output ⇒ Object
Get the terminal output (raw ANSI + text)
184 185 186 187 |
# File 'lib/tui_td/driver.rb', line 184 def raw_output read_available! @output_mutex.synchronize { @output_buffer.dup } end |
#refresh ⇒ Object
Refresh the terminal state by re-parsing the output buffer. Call this if the terminal content has changed and you need an up-to-date state.
191 192 193 194 |
# File 'lib/tui_td/driver.rb', line 191 def refresh refresh_state! @state end |
#screenshot(output_path) ⇒ Object
Capture a PNG screenshot of the current terminal state
209 210 211 212 |
# File 'lib/tui_td/driver.rb', line 209 def screenshot(output_path) state_data Screenshot.new(@state).render(output_path) end |
#send(text) ⇒ Object
Send text to the TUI
71 72 73 74 75 76 |
# File 'lib/tui_td/driver.rb', line 71 def send(text) ensure_running! @stdin&.print(text) @stdin&.flush true end |
#send_keys(keys) ⇒ Object
Send keys (escape sequences, control characters)
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
# File 'lib/tui_td/driver.rb', line 79 def send_keys(keys) ensure_running! case keys when :enter then send("\r") when :tab then send("\t") when :escape then send("\e") when :up then send("\e[A") when :down then send("\e[B") when :left then send("\e[D") when :right then send("\e[C") when :backspace then send("\u007f") when :ctrl_c then send("\u0003") when :ctrl_d then send("\u0004") when :page_up then send("\e[5~") when :page_down then send("\e[6~") else send(keys.to_s) end end |
#start ⇒ Object
Start the TUI application in a PTY
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/tui_td/driver.rb', line 50 def start env = { "TERM" => "xterm-256color", "COLUMNS" => @cols.to_s, "LINES" => @rows.to_s, }.merge(@env.transform_keys(&:to_s)) spawn_opts = {} spawn_opts[:chdir] = @chdir if @chdir cmd_args = Shellwords.shellsplit(@command) @stdout, @stdin, @pid = PTY.spawn(env, *cmd_args, spawn_opts) @stdout.winsize = [@rows, @cols] # Set PTY window size for TUIs that check winsize @wait_thr = Process.detach(@pid) # Read until initial output stabilizes wait_for_stable refresh_state! _start_reader_thread true end |
#state_data ⇒ Object
Get structured terminal state as a Hash
197 198 199 200 |
# File 'lib/tui_td/driver.rb', line 197 def state_data refresh_state! @state end |
#state_json(pretty: false) ⇒ Object
Get structured terminal state as JSON string
203 204 205 206 |
# File 'lib/tui_td/driver.rb', line 203 def state_json(pretty: false) state_data pretty ? JSON.pretty_generate(@state) : JSON.generate(@state) end |
#wait_for(timeout: nil, &predicate) ⇒ Object
Wait until the predicate returns true for the current terminal state. Polls with adaptive intervals: 10ms → 25ms → 50ms → 100ms. Use a custom poll_interval to bypass adaptive behavior.
driver.wait_for { |state| state.find_text("Ready").any? }
driver.wait_for(timeout: 5) { |state| state.foreground_at(0, 0) == "green" }
105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/tui_td/driver.rb', line 105 def wait_for(timeout: nil, &predicate) deadline = monotonic + (timeout || @timeout) loop_count = 0 loop do raise TimeoutError, "Timeout waiting for predicate" if monotonic > deadline read_available! refresh_state! state_obj = State.new(@state) break if predicate.call(state_obj) adaptive_sleep(loop_count) loop_count += 1 end @state end |
#wait_for_exit ⇒ Object
Wait until the process finishes
169 170 171 |
# File 'lib/tui_td/driver.rb', line 169 def wait_for_exit @wait_thr&.value end |
#wait_for_stable(stable_ms: 300) ⇒ Object
Wait for output to stabilize (grid content unchanged for N milliseconds)
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 |
# File 'lib/tui_td/driver.rb', line 140 def wait_for_stable(stable_ms: 300) deadline = monotonic + @timeout last_change = monotonic last_buffer_size = @output_mutex.synchronize { @output_buffer.bytesize } loop_count = 0 loop do raise TimeoutError, "Timeout waiting for stable output" if monotonic > deadline read_available! current_buffer_size = @output_mutex.synchronize { @output_buffer.bytesize } process_alive = process_alive? if current_buffer_size != last_buffer_size last_buffer_size = current_buffer_size last_change = monotonic elsif !process_alive break elsif (monotonic - last_change) * 1000 >= stable_ms # rubocop:disable Lint/DuplicateBranch break end adaptive_sleep(loop_count) loop_count += 1 end refresh_state! end |
#wait_for_text(text) ⇒ Object
Wait until output contains the given text
123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/tui_td/driver.rb', line 123 def wait_for_text(text) deadline = monotonic + @timeout loop_count = 0 loop do raise TimeoutError, "Timeout waiting for: #{text.inspect}" if monotonic > deadline read_available! found = @output_mutex.synchronize { @output_buffer.include?(text) } break if found adaptive_sleep(loop_count) loop_count += 1 end refresh_state! end |