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) ⇒ Driver

Returns a new instance of Driver.



22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/tui_td/driver.rb', line 22

def initialize(command, rows: 40, cols: 120, timeout: 30, chdir: nil)
  @command = command
  @rows = rows
  @cols = cols
  @timeout = timeout
  @chdir = chdir
  @state = nil
  @stdin = nil
  @stdout = nil
  @wait_thr = nil
  @output_buffer = +""
end

Instance Attribute Details

#commandObject (readonly)

Returns the value of attribute command.



20
21
22
# File 'lib/tui_td/driver.rb', line 20

def command
  @command
end

#stateObject (readonly)

Returns the value of attribute state.



20
21
22
# File 'lib/tui_td/driver.rb', line 20

def state
  @state
end

Instance Method Details

#closeObject

Close the driver and clean up



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/tui_td/driver.rb', line 139

def close
  # Kill the process if still running
  if @pid
    begin
      if Process.waitpid(@pid, Process::WNOHANG).nil?
        Process.kill("TERM", @pid) rescue nil
        sleep 0.05
        Process.kill("KILL", @pid) rescue nil
      end
    rescue Errno::ECHILD
      # Already reaped by Process.detach
    end
  end
  @stdin&.close rescue nil
  @stdout&.close rescue nil
  @stdin = @stdout = @pid = nil
end

#raw_outputObject

Get the terminal output (raw ANSI + text)



115
116
117
118
# File 'lib/tui_td/driver.rb', line 115

def raw_output
  read_available!
  @output_buffer
end

#screenshot(output_path) ⇒ Object

Capture a PNG screenshot of the current terminal state



133
134
135
136
# File 'lib/tui_td/driver.rb', line 133

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

#send(text) ⇒ Object

Send text to the TUI



53
54
55
56
57
58
# File 'lib/tui_td/driver.rb', line 53

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

#send_keys(keys) ⇒ Object

Send keys (escape sequences, control characters)



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/tui_td/driver.rb', line 61

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")
  else send(keys.to_s)
  end
end

#startObject

Start the TUI application in a PTY



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/tui_td/driver.rb', line 36

def start
  env = { "TERM" => "xterm-256color", "COLUMNS" => @cols.to_s, "LINES" => @rows.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!

  true
end

#state_dataObject

Get structured terminal state as a Hash



121
122
123
124
# File 'lib/tui_td/driver.rb', line 121

def state_data
  refresh_state! if @state.nil?
  @state
end

#state_json(pretty: false) ⇒ Object

Get structured terminal state as JSON string



127
128
129
130
# File 'lib/tui_td/driver.rb', line 127

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

#wait_for_exitObject

Wait until the process finishes



110
111
112
# File 'lib/tui_td/driver.rb', line 110

def wait_for_exit
  @wait_thr&.value
end

#wait_for_stable(stable_ms: 300) ⇒ Object

Wait for output to stabilize (no new data for N milliseconds)



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

def wait_for_stable(stable_ms: 300)
  deadline = monotonic + @timeout
  last_change = monotonic

  loop do
    raise TimeoutError, "Timeout waiting for stable output" if monotonic > deadline

    if read_available!
      last_change = monotonic
    elsif (monotonic - last_change) * 1000 >= stable_ms
      break
    end

    sleep 0.05
  end
  refresh_state!
end

#wait_for_text(text) ⇒ Object

Wait until output contains the given text



79
80
81
82
83
84
85
86
87
88
# File 'lib/tui_td/driver.rb', line 79

def wait_for_text(text)
  deadline = monotonic + @timeout
  loop do
    raise TimeoutError, "Timeout waiting for: #{text.inspect}" if monotonic > deadline
    read_available!
    break if @output_buffer.include?(text)
    sleep 0.05
  end
  refresh_state!
end