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

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.



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/tui_td/driver.rb', line 25

def initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil, env: {})
  @command = command
  @rows = rows
  @cols = cols
  @timeout = timeout
  @chdir = chdir
  @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.



23
24
25
# File 'lib/tui_td/driver.rb', line 23

def command
  @command
end

#stateObject (readonly)

Returns the value of attribute state.



23
24
25
# File 'lib/tui_td/driver.rb', line 23

def state
  @state
end

Instance Method Details

#closeObject

Close the driver and clean up



182
183
184
185
186
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
# File 'lib/tui_td/driver.rb', line 182

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)



141
142
143
144
145
146
147
148
# File 'lib/tui_td/driver.rb', line 141

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)



151
152
153
154
# File 'lib/tui_td/driver.rb', line 151

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.



158
159
160
161
# File 'lib/tui_td/driver.rb', line 158

def refresh
  refresh_state!
  @state
end

#screenshot(output_path) ⇒ Object

Capture a PNG screenshot of the current terminal state



176
177
178
179
# File 'lib/tui_td/driver.rb', line 176

def screenshot(output_path)
  state_data
  Screenshot.new(@state).render(output_path)
end

#send(text) ⇒ Object

Send text to the TUI



63
64
65
66
67
68
# File 'lib/tui_td/driver.rb', line 63

def send(text)
  ensure_running!
  @stdin&.print(text)
  @stdin&.flush
  true
end

#send_keys(keys) ⇒ Object

Send keys (escape sequences, control characters)



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/tui_td/driver.rb', line 71

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



43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/tui_td/driver.rb', line 43

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

  @stdout, @stdin, @pid = PTY.spawn(env, @command, 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



164
165
166
167
# File 'lib/tui_td/driver.rb', line 164

def state_data
  refresh_state!
  @state
end

#state_json(pretty: false) ⇒ Object

Get structured terminal state as JSON string



170
171
172
173
# File 'lib/tui_td/driver.rb', line 170

def state_json(pretty: false)
  state_data
  pretty ? JSON.pretty_generate(@state) : JSON.generate(@state)
end

#wait_for_exitObject

Wait until the process finishes



136
137
138
# File 'lib/tui_td/driver.rb', line 136

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)



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/tui_td/driver.rb', line 106

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



91
92
93
94
95
96
97
98
99
100
101
102
103
# File 'lib/tui_td/driver.rb', line 91

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