muxr 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ module Muxr
2
+ # Looks up the foreground command running inside a PTY by walking from the
3
+ # shell's pid → its tpgid (foreground process group on the controlling tty)
4
+ # → that process's command name. Hides the result when the shell itself is
5
+ # foreground so titles aren't full of "bash" / "zsh" noise.
6
+ #
7
+ # Two platform paths:
8
+ # Linux: /proc/<pid>/stat (no fork — runs fast even on the main thread,
9
+ # though Application uses a background thread anyway)
10
+ # macOS: ps -o tpgid=,pgid= -p <pid> + ps -o comm= -p <tpgid>. Two
11
+ # fork+execs, ~10–20ms total — exactly the reason callers run
12
+ # this off the event-loop thread.
13
+ #
14
+ # Returns the command name string or nil. nil also covers "couldn't read"
15
+ # so callers degrade silently rather than risk showing stale data.
16
+ module ForegroundCommand
17
+ # Command names we never want to surface — these are the empty-prompt
18
+ # case. If a user genuinely runs `bash` inside `bash` we'll under-report
19
+ # rather than mis-report.
20
+ SHELLS = %w[bash zsh fish sh dash ksh tcsh csh].freeze
21
+
22
+ module_function
23
+
24
+ def lookup(pid)
25
+ return nil unless pid.is_a?(Integer) && pid > 0
26
+ tpgid, pgid =
27
+ if File.exist?("/proc/#{pid}/stat")
28
+ linux_tpgid(pid)
29
+ else
30
+ macos_tpgid(pid)
31
+ end
32
+ return nil unless tpgid && pgid
33
+ return nil if tpgid <= 0
34
+ return nil if tpgid == pgid # shell is its own foreground — empty prompt
35
+
36
+ name =
37
+ if File.exist?("/proc/#{tpgid}/comm")
38
+ File.read("/proc/#{tpgid}/comm").strip
39
+ else
40
+ `ps -o comm= -p #{tpgid} 2>/dev/null`.strip
41
+ end
42
+ normalize(name)
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ # Public for testing — strips path/dash/whitespace and filters shells.
48
+ def normalize(name)
49
+ return nil if name.nil? || name.empty?
50
+ name = name.strip
51
+ name = name.sub(/\A-/, "") # login shells appear as "-bash"
52
+ name = File.basename(name)
53
+ return nil if name.empty?
54
+ return nil if SHELLS.include?(name)
55
+ name
56
+ end
57
+
58
+ # Public for testing — parses Linux /proc/<pid>/stat into [tpgid, pgid].
59
+ # The comm field can contain spaces and parens, so we slice from the
60
+ # last ')' rather than splitting from the start.
61
+ def parse_linux_stat(raw)
62
+ idx = raw.rindex(")")
63
+ return [nil, nil] unless idx
64
+ tail = raw[(idx + 2)..]
65
+ return [nil, nil] unless tail
66
+ fields = tail.split(" ")
67
+ # After the closing paren the fields are:
68
+ # state(0) ppid(1) pgrp(2) session(3) tty_nr(4) tpgid(5) ...
69
+ pgid = fields[2]&.to_i
70
+ tpgid = fields[5]&.to_i
71
+ [tpgid, pgid]
72
+ end
73
+
74
+ def linux_tpgid(pid)
75
+ raw = File.read("/proc/#{pid}/stat")
76
+ parse_linux_stat(raw)
77
+ end
78
+
79
+ def macos_tpgid(pid)
80
+ out = `ps -o tpgid=,pgid= -p #{pid} 2>/dev/null`.strip
81
+ return [nil, nil] if out.empty?
82
+ tpgid, pgid = out.split.map(&:to_i)
83
+ [tpgid, pgid]
84
+ end
85
+ end
86
+ end
@@ -1,21 +1,67 @@
1
1
  module Muxr
2
- # Translates raw keystrokes into either commands (when the Ctrl-a prefix is
3
- # active) or passthrough bytes to the focused pane. The handler is a small
4
- # state machine: :idle → :prefix → :idle, with a separate :command branch
5
- # for the ":"-driven mini-command line.
2
+ # Translates raw keystrokes into either commands or pane input. Two top-level
3
+ # modes:
4
+ #
5
+ # :normal Default. Single-key bindings (hjkl navigation, t/g/m for
6
+ # layouts, c/K for create/kill, etc.) act directly without
7
+ # any prefix. `i` drops into passthrough.
8
+ #
9
+ # :passthrough Historical mode: every key is forwarded to the focused
10
+ # pane unless prefixed by Ctrl-a. `Ctrl-a Esc` returns to
11
+ # normal mode.
12
+ #
13
+ # Plus the sub-states pre-existing from before modes existed:
14
+ # :prefix, :command, :confirm_quit, :help, :scrollback, :selection.
15
+ #
16
+ # One-shot sub-states (prefix, command, confirm_quit, help) return to
17
+ # @base_mode (whichever of :normal/:passthrough is active) when they
18
+ # finish. :scrollback and :selection also return to @base_mode so that
19
+ # exiting back from a scroll/yank lands you back in passthrough if that's
20
+ # where you came from.
6
21
  class InputHandler
7
22
  PREFIX = "\x01".freeze # Ctrl-a
8
23
 
24
+ # Single-key bindings in normal mode. Same actions as their Ctrl-a-
25
+ # prefixed counterparts in passthrough, just without the prefix.
26
+ # Value forms:
27
+ # :symbol → @app.public_send(:symbol)
28
+ # [:symbol, *args] → @app.public_send(:symbol, *args)
29
+ NORMAL_BINDINGS = {
30
+ "c" => :new_pane,
31
+ "K" => :close_focused,
32
+ "t" => [:set_layout, :tall],
33
+ "g" => [:set_layout, :grid],
34
+ "m" => [:set_layout, :monocle],
35
+ "\t" => :cycle_layout,
36
+ "\r" => :promote_master,
37
+ "\n" => :promote_master,
38
+ "h" => [:focus_direction, :left],
39
+ "j" => [:focus_direction, :down],
40
+ "k" => [:focus_direction, :up],
41
+ "l" => [:focus_direction, :right],
42
+ "a" => :focus_last,
43
+ "~" => :toggle_drawer,
44
+ "C" => :toggle_claude_drawer,
45
+ "P" => :toggle_private_focused,
46
+ "d" => :detach,
47
+ "?" => :show_help,
48
+ "q" => :quit_immediate,
49
+ "s" => :enter_scrollback,
50
+ "]" => :paste_from_buffer
51
+ }.freeze
52
+
9
53
  PREFIX_BINDINGS = {
10
54
  "c" => :new_pane,
11
55
  "n" => :focus_next,
12
56
  "p" => :focus_prev,
13
57
  "a" => :focus_last,
14
- "k" => :close_focused,
58
+ "K" => :close_focused,
15
59
  "\t" => :cycle_layout,
16
60
  "\r" => :promote_master,
17
61
  "\n" => :promote_master,
18
62
  "~" => :toggle_drawer,
63
+ "C" => :toggle_claude_drawer,
64
+ "P" => :toggle_private_focused,
19
65
  "d" => :detach,
20
66
  "?" => :show_help,
21
67
  "q" => :quit_immediate,
@@ -67,8 +113,10 @@ module Muxr
67
113
  "u" => :half_up,
68
114
  "\x06" => :full_down, # Ctrl-f
69
115
  "\x02" => :full_up, # Ctrl-b
70
- "f" => :full_down,
71
- " " => :full_down
116
+ "f" => :full_down
117
+ # NOTE: space is intentionally absent here — it's a top-level toggle
118
+ # for linear selection (see handle_selection_input), mirroring vim's
119
+ # `v` so the right thumb has a one-key way to anchor/release.
72
120
  }.freeze
73
121
 
74
122
  SELECTION_YANK = ["\r", "\n", "y"].freeze
@@ -76,22 +124,22 @@ module Muxr
76
124
 
77
125
  DIGIT_RE = /\A[1-9]\z/.freeze
78
126
 
79
- attr_reader :state, :command_buffer
127
+ attr_reader :state, :command_buffer, :base_mode
80
128
 
81
129
  def initialize(app)
82
130
  @app = app
83
- @state = :idle
131
+ @state = :normal
132
+ @base_mode = :normal
84
133
  @command_buffer = +""
85
134
  end
86
135
 
87
136
  def feed(data)
88
137
  remaining = data
89
138
  until remaining.empty?
90
- if @state == :idle
91
- # Fast path for pass-through: forward everything up to the next
92
- # Ctrl-a as a single chunk so a large paste doesn't turn into one
93
- # PTY write per byte. PREFIX is single-byte ASCII (\x01) and never
94
- # appears mid-UTF-8, so byte/char index match.
139
+ if @state == :passthrough
140
+ # Fast path: batch everything up to the next Ctrl-a as one chunk so
141
+ # a large paste doesn't turn into one PTY write per byte. PREFIX is
142
+ # single-byte ASCII (\x01) and never appears mid-UTF-8.
95
143
  idx = remaining.index(PREFIX)
96
144
  if idx.nil?
97
145
  @app.send_to_focused(remaining)
@@ -106,9 +154,11 @@ module Muxr
106
154
  ch = remaining[0]
107
155
  remaining = remaining[1..] || ""
108
156
  case @state
157
+ when :normal
158
+ handle_normal(ch)
109
159
  when :help
110
160
  @app.dismiss_help
111
- @state = :idle
161
+ @state = @base_mode
112
162
  when :confirm_quit
113
163
  handle_confirm_quit(ch)
114
164
  when :prefix
@@ -139,40 +189,96 @@ module Muxr
139
189
  @state = :selection
140
190
  end
141
191
 
192
+ # Drop into passthrough — every key reaches the focused pane until the
193
+ # user issues Ctrl-a Esc.
194
+ def enter_passthrough_mode
195
+ @state = :passthrough
196
+ @base_mode = :passthrough
197
+ end
198
+
199
+ # Return to normal mode. Used by the `Ctrl-a Esc` binding from
200
+ # passthrough — explicitly resets @base_mode so the user genuinely
201
+ # leaves passthrough.
202
+ def enter_normal_mode
203
+ @state = :normal
204
+ @base_mode = :normal
205
+ end
206
+
207
+ # Exit a sub-state (scrollback, selection-yank) and resume the mode the
208
+ # user was in before they entered scrollback. Preserves @base_mode so
209
+ # a passthrough → scrollback → exit round-trip lands back in passthrough.
142
210
  def enter_idle_mode
143
- @state = :idle
211
+ @state = @base_mode
144
212
  end
145
213
 
146
214
  def cancel
147
- @state = :idle
215
+ @state = @base_mode
148
216
  @command_buffer = +""
149
217
  end
150
218
 
151
219
  private
152
220
 
221
+ def handle_normal(ch)
222
+ if ch == "i"
223
+ # Internal state flip happens here so a bare FakeApp in tests still
224
+ # transitions; the Application callback redundantly flips state
225
+ # (idempotent) and adds the user-visible flash.
226
+ enter_passthrough_mode
227
+ @app.enter_passthrough_mode
228
+ return
229
+ end
230
+ if ch == ":"
231
+ @state = :command
232
+ @command_buffer = +""
233
+ return
234
+ end
235
+ if DIGIT_RE.match?(ch)
236
+ @app.focus_pane_number(ch.to_i)
237
+ return
238
+ end
239
+
240
+ action = NORMAL_BINDINGS[ch]
241
+ case action
242
+ when Symbol
243
+ @app.public_send(action)
244
+ when Array
245
+ @app.public_send(*action)
246
+ end
247
+ # Unknown key: ignore. Avoids accidental side-effects when the user
248
+ # mistypes — same rationale as scrollback mode.
249
+ end
250
+
153
251
  def handle_prefix(ch)
154
252
  action = PREFIX_BINDINGS[ch]
155
253
  case
254
+ when ch == "\e"
255
+ # Ctrl-a Esc → return to normal mode. Flip state directly so tests
256
+ # with a bare FakeApp transition; the Application callback is
257
+ # idempotent and adds the flash message.
258
+ enter_normal_mode
259
+ @app.enter_normal_mode
156
260
  when ch == ":"
157
261
  @state = :command
158
262
  @command_buffer = +""
159
263
  when ch == PREFIX
160
264
  @app.send_to_focused(PREFIX)
161
- @state = :idle
265
+ @state = @base_mode
162
266
  when DIGIT_RE.match?(ch)
163
267
  @app.focus_pane_number(ch.to_i)
164
- @state = :idle
268
+ @state = @base_mode
165
269
  when action
166
270
  @app.public_send(action)
167
- @state = :idle if @state == :prefix
271
+ # The action may have set a new state (confirm_quit, scrollback,
272
+ # help). Only revert to base mode if we're still in :prefix.
273
+ @state = @base_mode if @state == :prefix
168
274
  else
169
- # Unknown prefix-key: return to idle silently.
170
- @state = :idle
275
+ # Unknown prefix key: return to base mode silently.
276
+ @state = @base_mode
171
277
  end
172
278
  end
173
279
 
174
280
  def handle_confirm_quit(ch)
175
- @state = :idle
281
+ @state = @base_mode
176
282
  if ch == "y" || ch == "Y"
177
283
  @app.confirm_quit
178
284
  else
@@ -182,7 +288,7 @@ module Muxr
182
288
 
183
289
  def handle_scrollback_input(ch)
184
290
  if SCROLLBACK_EXITS.include?(ch)
185
- @state = :idle
291
+ enter_idle_mode
186
292
  @app.exit_scrollback
187
293
  return
188
294
  end
@@ -206,7 +312,7 @@ module Muxr
206
312
  return
207
313
  end
208
314
  case ch
209
- when "v"
315
+ when "v", " "
210
316
  @app.toggle_selection(:linear)
211
317
  return
212
318
  when "\x16" # Ctrl-v
@@ -223,11 +329,11 @@ module Muxr
223
329
  when "\r", "\n"
224
330
  cmd = @command_buffer.dup
225
331
  @command_buffer = +""
226
- @state = :idle
332
+ @state = @base_mode
227
333
  @app.run_command(cmd)
228
334
  when "\e"
229
335
  @command_buffer = +""
230
- @state = :idle
336
+ @state = @base_mode
231
337
  @app.invalidate
232
338
  when "\x7f", "\b"
233
339
  @command_buffer.chop!
@@ -0,0 +1,89 @@
1
+ module Muxr
2
+ # Translates an array of "keys" entries (a mix of literal text and vim-style
3
+ # named keys like "<esc>") into a stream of byte segments tagged by kind.
4
+ #
5
+ # Each input element is either:
6
+ #
7
+ # - Literal text (UTF-8) — emitted as [:literal, bytes].
8
+ # - A named key wrapped in angle brackets like "<esc>", "<c-c>", "<up>" —
9
+ # emitted as [:special, bytes] using the appropriate control sequence.
10
+ #
11
+ # The tagged form lets callers wrap *only* literal segments in
12
+ # bracketed-paste markers when desired. Concatenating the bytes of all
13
+ # segments gives the raw byte stream to write to the PTY.
14
+ module KeyParser
15
+ NAMED_KEY_RE = /\A<([^<>]+)>\z/.freeze
16
+
17
+ # CSI = ESC [ , SS3 = ESC O — both standard xterm key encodings.
18
+ CSI = "\e[".b.freeze
19
+ SS3 = "\eO".b.freeze
20
+
21
+ # Single canonical map. Keys are lowercased; aliases live alongside their
22
+ # canonical forms. Values are ASCII byte strings.
23
+ NAMED_KEYS = {
24
+ "esc" => "\e".b,
25
+ "escape" => "\e".b,
26
+ "enter" => "\r".b,
27
+ "cr" => "\r".b,
28
+ "return" => "\r".b,
29
+ "tab" => "\t".b,
30
+ "s-tab" => "#{CSI}Z".b,
31
+ "bs" => "\x7f".b, # what real backspace keys send through a PTY
32
+ "backspace"=> "\x7f".b,
33
+ "space" => " ".b,
34
+ "up" => "#{CSI}A".b,
35
+ "down" => "#{CSI}B".b,
36
+ "right" => "#{CSI}C".b,
37
+ "left" => "#{CSI}D".b,
38
+ "home" => "#{CSI}H".b,
39
+ "end" => "#{CSI}F".b,
40
+ "pageup" => "#{CSI}5~".b,
41
+ "pagedown" => "#{CSI}6~".b,
42
+ "f1" => "#{SS3}P".b,
43
+ "f2" => "#{SS3}Q".b,
44
+ "f3" => "#{SS3}R".b,
45
+ "f4" => "#{SS3}S".b,
46
+ "f5" => "#{CSI}15~".b,
47
+ "f6" => "#{CSI}17~".b,
48
+ "f7" => "#{CSI}18~".b,
49
+ "f8" => "#{CSI}19~".b,
50
+ "f9" => "#{CSI}20~".b,
51
+ "f10" => "#{CSI}21~".b,
52
+ "f11" => "#{CSI}23~".b,
53
+ "f12" => "#{CSI}24~".b
54
+ }.freeze
55
+
56
+ CTRL_RE = /\Ac-([a-z])\z/.freeze
57
+
58
+ module_function
59
+
60
+ # Returns an array of [kind, bytes] pairs. Raises Dispatcher::Error on a
61
+ # non-array input or an unrecognized `<name>`.
62
+ def translate(entries)
63
+ raise Dispatcher::Error.new("`keys` must be an array of strings") unless entries.is_a?(Array)
64
+
65
+ entries.map do |entry|
66
+ raise Dispatcher::Error.new("`keys` entries must be strings") unless entry.is_a?(String)
67
+ classify(entry)
68
+ end
69
+ end
70
+
71
+ def classify(entry)
72
+ m = NAMED_KEY_RE.match(entry)
73
+ return [:literal, entry.b] unless m
74
+
75
+ name = m[1].downcase
76
+ bytes = NAMED_KEYS[name] || ctrl_bytes(name)
77
+ raise Dispatcher::Error.new("unknown named key #{entry.inspect}") unless bytes
78
+
79
+ [:special, bytes]
80
+ end
81
+
82
+ def ctrl_bytes(name)
83
+ m = CTRL_RE.match(name)
84
+ return nil unless m
85
+ # <c-a>=0x01 .. <c-z>=0x1A
86
+ (m[1].ord - "a".ord + 1).chr.b
87
+ end
88
+ end
89
+ end
@@ -87,5 +87,64 @@ module Muxr
87
87
  def monocle(count, area, _focused_index = 0)
88
88
  Array.new(count) { Rect.new(area.x, area.y, area.w, area.h) }
89
89
  end
90
+
91
+ # Return the index of the closest pane in `direction` (:left/:right/:up/:down)
92
+ # from the focused pane. Pure function over the rect list — does not know
93
+ # about the layout that produced the rects.
94
+ #
95
+ # Selection rule: among panes strictly on the requested side, prefer the
96
+ # one with the largest perpendicular overlap with the focused pane;
97
+ # tie-break by smallest axis-distance, then by smallest center offset.
98
+ # Returns nil when nothing qualifies (e.g. focused is the rightmost pane
99
+ # and direction is :right, or monocle where every rect is identical).
100
+ def neighbor(rects, focused_index, direction)
101
+ return nil if rects.nil? || rects.empty?
102
+ return nil unless focused_index.is_a?(Integer)
103
+ return nil unless focused_index.between?(0, rects.length - 1)
104
+ focused = rects[focused_index]
105
+ return nil unless focused
106
+
107
+ best = nil
108
+ rects.each_with_index do |rect, idx|
109
+ next if idx == focused_index || rect.nil?
110
+
111
+ case direction
112
+ when :right
113
+ next unless rect.x >= focused.x + focused.w
114
+ axis_dist = rect.x - (focused.x + focused.w)
115
+ overlap = overlap_extent(focused.y, focused.h, rect.y, rect.h)
116
+ center = ((rect.y + rect.h / 2.0) - (focused.y + focused.h / 2.0)).abs
117
+ when :left
118
+ next unless rect.x + rect.w <= focused.x
119
+ axis_dist = focused.x - (rect.x + rect.w)
120
+ overlap = overlap_extent(focused.y, focused.h, rect.y, rect.h)
121
+ center = ((rect.y + rect.h / 2.0) - (focused.y + focused.h / 2.0)).abs
122
+ when :down
123
+ next unless rect.y >= focused.y + focused.h
124
+ axis_dist = rect.y - (focused.y + focused.h)
125
+ overlap = overlap_extent(focused.x, focused.w, rect.x, rect.w)
126
+ center = ((rect.x + rect.w / 2.0) - (focused.x + focused.w / 2.0)).abs
127
+ when :up
128
+ next unless rect.y + rect.h <= focused.y
129
+ axis_dist = focused.y - (rect.y + rect.h)
130
+ overlap = overlap_extent(focused.x, focused.w, rect.x, rect.w)
131
+ center = ((rect.x + rect.w / 2.0) - (focused.x + focused.w / 2.0)).abs
132
+ else
133
+ return nil
134
+ end
135
+
136
+ score = [-overlap, axis_dist, center]
137
+ if best.nil? || (score <=> best[0]) < 0
138
+ best = [score, idx]
139
+ end
140
+ end
141
+ best && best[1]
142
+ end
143
+
144
+ def overlap_extent(a_start, a_size, b_start, b_size)
145
+ finish = [a_start + a_size, b_start + b_size].min
146
+ start = [a_start, b_start].max
147
+ [finish - start, 0].max
148
+ end
90
149
  end
91
150
  end
data/lib/muxr/pane.rb CHANGED
@@ -1,19 +1,66 @@
1
+ require "securerandom"
2
+
1
3
  module Muxr
2
4
  # A Pane bundles a Terminal emulator buffer with the PTYProcess running the
3
5
  # shell that feeds it. The Window keeps a list of panes; the Renderer asks
4
6
  # each pane for its current grid contents and cursor position.
7
+ #
8
+ # Each pane has a stable 6-hex id generated at creation. The id survives
9
+ # promote_to_master and other array reshuffles (since it lives on the Pane,
10
+ # not on the index) and is persisted in the session JSON so it also survives
11
+ # a full cold restart. The drawer pane uses the symbol `:drawer` as its id;
12
+ # the MCP control surface treats it specially and never lists it under
13
+ # `panes.list`.
5
14
  class Pane
6
15
  attr_reader :id, :terminal, :process
7
16
  attr_accessor :rect
17
+ # Last value written by Application's foreground poller thread. nil when
18
+ # the shell itself is foreground (the common empty-prompt case) or when
19
+ # the lookup hasn't run / couldn't read. Renderer surfaces this in the
20
+ # pane title.
21
+ attr_accessor :foreground_command
8
22
 
9
- def initialize(id:, rows: 24, cols: 80, cwd: nil, command: nil, process: nil)
10
- @id = id
23
+ def initialize(id: nil, rows: 24, cols: 80, cwd: nil, command: nil, env_overrides: nil, process: nil)
24
+ @id = id || SecureRandom.hex(3)
11
25
  @rows = rows
12
26
  @cols = cols
13
27
  @terminal = Terminal.new(rows: rows, cols: cols)
14
- @process = process || PTYProcess.new(rows: rows, cols: cols, cwd: cwd, command: command)
28
+ @process = process || PTYProcess.new(
29
+ rows: rows,
30
+ cols: cols,
31
+ cwd: cwd,
32
+ command: command,
33
+ env_overrides: env_overrides || {}
34
+ )
15
35
  @rect = nil
16
36
  @initial_cwd = cwd || @process.cwd
37
+ @private_flag = false
38
+ @foreground_command = nil
39
+ end
40
+
41
+ def pid
42
+ @process.pid
43
+ end
44
+
45
+ # Private panes are invisible to the MCP control surface — their cwd is
46
+ # stripped from panes.list and pane.read/send_input/run/subscribe/kill
47
+ # all refuse. Toggled by the human via Ctrl-a P or `:private`. Never
48
+ # settable from the control surface itself (so a misbehaving MCP client
49
+ # can't unmark a pane it shouldn't see).
50
+ def private?
51
+ @private_flag
52
+ end
53
+
54
+ def mark_private!
55
+ @private_flag = true
56
+ end
57
+
58
+ def mark_public!
59
+ @private_flag = false
60
+ end
61
+
62
+ def toggle_private!
63
+ @private_flag = !@private_flag
17
64
  end
18
65
 
19
66
  def io
@@ -51,6 +98,16 @@ module Muxr
51
98
  @terminal.feed(chunk)
52
99
  total += chunk.bytesize
53
100
  end
101
+ # The emulator may owe the inner program a reply (DSR / CPR — see
102
+ # Terminal#take_pending_replies!). Ship it back through the PTY's
103
+ # input side as if it had been typed. Failure here is non-fatal: the
104
+ # process can have exited between read and write.
105
+ if (reply = @terminal.take_pending_replies!)
106
+ begin
107
+ @process.write(reply)
108
+ rescue Errno::EIO, Errno::EPIPE
109
+ end
110
+ end
54
111
  total.positive? ? total : nil
55
112
  end
56
113