Class: TUITD::Driver

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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

#commandObject (readonly)

Returns the value of attribute command.



27
28
29
# File 'lib/tui_td/driver.rb', line 27

def command
  @command
end

#stateObject (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

#closeObject

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

#exitstatusObject

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_outputObject

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

#refreshObject

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

#startObject

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_dataObject

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_exitObject

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