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
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: {}) ⇒ 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_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: {}) ⇒ Driver
Returns a new instance of Driver.
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
# File 'lib/tui_td/driver.rb', line 29 def initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil, env: {}) @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 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
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 |
# File 'lib/tui_td/driver.rb', line 187 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)
146 147 148 149 150 151 152 153 |
# File 'lib/tui_td/driver.rb', line 146 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)
156 157 158 159 |
# File 'lib/tui_td/driver.rb', line 156 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.
163 164 165 166 |
# File 'lib/tui_td/driver.rb', line 163 def refresh refresh_state! @state end |
#screenshot(output_path) ⇒ Object
Capture a PNG screenshot of the current terminal state
181 182 183 184 |
# File 'lib/tui_td/driver.rb', line 181 def screenshot(output_path) state_data Screenshot.new(@state).render(output_path) end |
#send(text) ⇒ Object
Send text to the TUI
68 69 70 71 72 73 |
# File 'lib/tui_td/driver.rb', line 68 def send(text) ensure_running! @stdin&.print(text) @stdin&.flush true end |
#send_keys(keys) ⇒ Object
Send keys (escape sequences, control characters)
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 |
# File 'lib/tui_td/driver.rb', line 76 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
47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/tui_td/driver.rb', line 47 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
169 170 171 172 |
# File 'lib/tui_td/driver.rb', line 169 def state_data refresh_state! @state end |
#state_json(pretty: false) ⇒ Object
Get structured terminal state as JSON string
175 176 177 178 |
# File 'lib/tui_td/driver.rb', line 175 def state_json(pretty: false) state_data pretty ? JSON.pretty_generate(@state) : JSON.generate(@state) end |
#wait_for_exit ⇒ Object
Wait until the process finishes
141 142 143 |
# File 'lib/tui_td/driver.rb', line 141 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)
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 |
# File 'lib/tui_td/driver.rb', line 111 def wait_for_stable(stable_ms: 300) deadline = monotonic + @timeout last_change = monotonic last_grid = nil loop do raise TimeoutError, "Timeout waiting for stable output" if monotonic > deadline had_data = read_available! process_alive = process_alive? if had_data current_grid = parse_grid_snapshot if current_grid != last_grid last_grid = current_grid last_change = monotonic end elsif !process_alive # Process exited and no more data — final state reached break elsif last_grid && (monotonic - last_change) * 1000 >= stable_ms # rubocop:disable Lint/DuplicateBranch break end sleep 0.05 end refresh_state! end |
#wait_for_text(text) ⇒ Object
Wait until output contains the given text
96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/tui_td/driver.rb', line 96 def wait_for_text(text) deadline = monotonic + @timeout 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 sleep 0.05 end refresh_state! end |