Class: Echoes::Pane

Inherits:
Object
  • Object
show all
Defined in:
lib/echoes/pane.rb

Overview

A Pane is one shell session within a Tab. It owns a Screen (the cell grid the user sees) and a backing shell — either an external program spawned via PTY (the default), or a Rubish::REPL running in a per-pane helper subprocess via Echoes::EmbeddedShell. The helper owns the pty as its controlling tty so Ctrl-C / SIGWINCH / job control all work.

Callers that need to send bytes to the shell or pull bytes back use ‘write_input` / `read_available_output`. Don’t reach for the legacy ‘pty_read` / `pty_write` accessors — they’re nil in embedded mode.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(command:, rows:, cols:, cwd: nil, embedded: false, no_rc: false, editor_file: nil, env: nil) ⇒ Pane

Returns a new instance of Pane.



20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/echoes/pane.rb', line 20

def initialize(command:, rows:, cols:, cwd: nil, embedded: false, no_rc: false, editor_file: nil, env: nil)
  @screen = Screen.new(rows: rows, cols: cols)
  if editor_file
    require_relative 'editor'
    @editor = Editor.new(file: editor_file, rows: rows, cols: cols)
    @parser = Parser.new(@screen, writer: ->(_s) { })
    @title = File.basename(editor_file)
  elsif embedded
    require_relative 'embedded_shell'
    @embedded_shell = EmbeddedShell.new(no_rc: no_rc)
    # Writer routes OSC replies (display-info, OSC 52 paste-back,
    # color queries, terminfo replies, …) to the helper's pty
    # master so the foreground program reads them on its stdin.
    # While rubish is at the prompt, anything we write here lands
    # in Reline; in practice query OSCs only come from a running
    # foreground program (e.g. przn) so the routing is safe.
    @parser = Parser.new(@screen, writer: ->(s) { @embedded_shell.forward_input(s) })
    @title = 'rubish'
    @input_buffer = +''
    @input_cursor = 0     # offset within @input_buffer (0..length)
    @embedded_running = false
    @history_index = nil  # nil = not browsing; integer = browsing
    @history_saved = nil  # input held aside while browsing
    @continuation_lines = []  # collected lines while waiting for a complete command
    @kill_ring = +''      # last killed text (for Ctrl-Y yank)
    @autosuggestion = +''  # fish-style: tail of the most recent matching history entry
    @right_prompt_segments = nil  # cached at prompt time; redrawn after every input edit
    @input_mode = :prompt  # :prompt | :search (running uses @embedded_shell.running?)
    @search_query = +''    # Ctrl-R substring being typed
    @search_index = nil    # index into history of the current match (nil = no match)
    @search_saved_buffer = nil
    @search_saved_cursor = nil
    @search_saved_autosuggestion = nil
  else
    start_dir = (cwd && Dir.exist?(cwd)) ? cwd : Dir.home
    Dir.chdir(start_dir) do
      # When env: is nil (the default), we just normalize a few
      # vars on our own process — the child then inherits the
      # whole env from us, same as before. When env: is given,
      # it's an explicit env Hash that fully replaces what the
      # child sees, with our normalizations applied on top. The
      # explicit form is used by the OSC 7772 ;open-window path
      # so a child program (e.g. przn) reliably gets PATH /
      # HOME / USER / LANG even when Echoes.app was launched by
      # launchd with a minimal env.
      ENV['TERM'] = Echoes.config.term
      ENV['LANG'] ||= 'en_US.UTF-8'
      ENV['LC_CTYPE'] = 'UTF-8'
      # `command` may be a String (shell-parsed by /bin/sh) or an
      # Array of [argv0, *args] (execve directly, no shell). The
      # array form is what the OSC 7772 ;open-window handler
      # uses so user-supplied argv isn't subject to shell quoting.
      spawn_args = command.is_a?(Array) ? command : [command]
      @pty_read, @pty_write, @pty_pid = spawn_with_pty(spawn_args, env, rows, cols)
    end
    @parser = Parser.new(@screen, writer: ->(s) { @pty_write.write(s) rescue nil })
    @title = File.basename(command.is_a?(Array) ? command.first : command)
  end
  @scroll_offset = 0
  @scroll_accum = 0.0
  @copy_mode = nil
  render_initial_prompt if embedded
  render_editor if editor?
end

Instance Attribute Details

#copy_modeObject

Returns the value of attribute copy_mode.



16
17
18
# File 'lib/echoes/pane.rb', line 16

def copy_mode
  @copy_mode
end

#editorObject (readonly)

Returns the value of attribute editor.



93
94
95
# File 'lib/echoes/pane.rb', line 93

def editor
  @editor
end

#embedded_shellObject (readonly)

Returns the value of attribute embedded_shell.



18
19
20
# File 'lib/echoes/pane.rb', line 18

def embedded_shell
  @embedded_shell
end

#parserObject

Returns the value of attribute parser.



16
17
18
# File 'lib/echoes/pane.rb', line 16

def parser
  @parser
end

#pty_pidObject

Returns the value of attribute pty_pid.



16
17
18
# File 'lib/echoes/pane.rb', line 16

def pty_pid
  @pty_pid
end

#pty_readObject

Returns the value of attribute pty_read.



16
17
18
# File 'lib/echoes/pane.rb', line 16

def pty_read
  @pty_read
end

#pty_writeObject

Returns the value of attribute pty_write.



16
17
18
# File 'lib/echoes/pane.rb', line 16

def pty_write
  @pty_write
end

#screenObject

Returns the value of attribute screen.



16
17
18
# File 'lib/echoes/pane.rb', line 16

def screen
  @screen
end

#scroll_accumObject

Returns the value of attribute scroll_accum.



16
17
18
# File 'lib/echoes/pane.rb', line 16

def scroll_accum
  @scroll_accum
end

#scroll_offsetObject

Returns the value of attribute scroll_offset.



16
17
18
# File 'lib/echoes/pane.rb', line 16

def scroll_offset
  @scroll_offset
end

#titleObject

Returns the value of attribute title.



16
17
18
# File 'lib/echoes/pane.rb', line 16

def title
  @title
end

Instance Method Details

#alive?Boolean

Returns:

  • (Boolean)


153
154
155
156
157
158
159
160
161
162
# File 'lib/echoes/pane.rb', line 153

def alive?
  return !@editor.closed? if editor?
  if embedded?
    @embedded_shell.alive?
  else
    Process.waitpid(@pty_pid, Process::WNOHANG).nil?
  end
rescue Errno::ECHILD
  false
end

#apply_completion(word_start:, completion:) ⇒ Object

Splice ‘completion` into the input buffer in place of the partial word that starts at `word_start`. Adds a trailing space unless the completion already ends with `/` (a directory). Re-renders via `replace_input_line` so highlighting + autosuggestion stay consistent.



855
856
857
858
859
860
861
# File 'lib/echoes/pane.rb', line 855

def apply_completion(word_start:, completion:)
  completion = "#{completion} " unless completion.end_with?('/')
  tail = @input_buffer[@input_cursor..] || ''
  new_input = @input_buffer[0...word_start] + completion + tail
  new_cursor = word_start + completion.length
  replace_input_line(new_input, new_cursor)
end

#closeObject



198
199
200
201
202
203
204
205
206
207
# File 'lib/echoes/pane.rb', line 198

def close
  return if editor?
  if embedded?
    @embedded_shell.shutdown
    return
  end
  @pty_write.close rescue nil
  @pty_read.close rescue nil
  Process.kill(:HUP, @pty_pid) rescue nil
end

#completion_requestObject

Pure-data: ask the embedded shell what completions are available at the current cursor and locate the start of the word being completed. Returns nil when there are no candidates. Has no side effects on the screen.



841
842
843
844
845
846
847
848
# File 'lib/echoes/pane.rb', line 841

def completion_request
  point = @input_cursor
  candidates = @embedded_shell.complete_at(line: @input_buffer, point: point)
  return nil if candidates.empty?
  word_start = point
  word_start -= 1 while word_start > 0 && !WORD_BREAK_CHARS.include?(@input_buffer[word_start - 1])
  {candidates: candidates, word_start: word_start, point: point}
end

#copy_last_command_outputObject

Convenience: copy ‘last_command_output_text` to the system clipboard via the screen’s clipboard handler. Returns true on success, false if there’s nothing to copy or no clipboard handler is wired (e.g., in tests).



228
229
230
231
232
233
# File 'lib/echoes/pane.rb', line 228

def copy_last_command_output
  text = last_command_output_text
  return false unless text
  @screen.set_clipboard(text)
  true
end

#copy_last_command_textObject



246
247
248
249
250
251
# File 'lib/echoes/pane.rb', line 246

def copy_last_command_text
  text = last_command_text
  return false unless text && !text.empty?
  @screen.set_clipboard(text)
  true
end

#editor?Boolean

Returns:

  • (Boolean)


89
90
91
# File 'lib/echoes/pane.rb', line 89

def editor?
  !@editor.nil?
end

#embedded?Boolean

Returns:

  • (Boolean)


85
86
87
# File 'lib/echoes/pane.rb', line 85

def embedded?
  !@embedded_shell.nil?
end

#handle_key(chars:, flags: 0) ⇒ Object

Embedded-mode keyboard handling. Returns true if the pane consumed the event, false if the GUI should fall through to its own PTY-style handling (which is the only mode in non-embedded panes).

Two states:

- prompt mode (no command running): printable chars echo to the
  screen and append to @input_buffer; Backspace pops a char and
  erases the last cell; Enter submits the buffered line for
  async execution.
- running mode (a command is in flight): keystrokes get
  forwarded to the command's stdin via the pty master, so the
  user can type into vim, scroll less, etc. Ctrl-C interrupts.


295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
# File 'lib/echoes/pane.rb', line 295

def handle_key(chars:, flags: 0)
  if editor?
    return true if chars.nil? || chars.empty?
    # Map Ctrl+letter to the corresponding control byte that
    # rvim's keymap expects (e.g. Ctrl-D → 0x04). Other special
    # keys are translated by Editor#feed_key directly.
    ch = if (flags & NSEVENT_CONTROL_FLAG) != 0 && chars.length == 1 && chars.ord >= 0x20
           (chars.ord & 0x1F).chr
         else
           chars
         end
    @editor.feed_key(ch)
    render_editor
    return true
  end
  return false unless embedded?
  return true if chars.nil? || chars.empty?

  if @embedded_shell.running?
    # Translate macOS special-key code points to the ANSI escape
    # sequences a real terminal would have produced — that's what
    # programs reading from the pty (vim, less, etc.) expect.
    translated = translate_for_pty(chars, flags)
    @embedded_shell.forward_input(translated)
    return true
  end

  return handle_search_key(chars, flags) if @input_mode == :search

  # Emacs/readline-style bindings on Ctrl+letter at the prompt.
  # macOS gives us `chars` as the plain letter (Cocoa's
  # charactersIgnoringModifiers); flags carries the Control bit.
  if (flags & NSEVENT_CONTROL_FLAG) != 0 && chars.length == 1 && chars.ord >= 0x20
    return true if handle_ctrl_letter(chars.downcase)
  end

  option_held = (flags & NSEVENT_OPTION_FLAG) != 0
  cmd_held    = (flags & NSEVENT_COMMAND_FLAG) != 0
  shift_held  = (flags & NSEVENT_SHIFT_FLAG) != 0

  # Cmd+Shift+letter: pane-level shortcuts that operate on OSC 133
  # marks. Cmd+Shift+Up/Down (jump-to-prompt) is matched in the
  # arrow-key cases below.
  if cmd_held && shift_held && chars.length == 1
    case chars.downcase
    when 'o'
      copy_last_command_output
      return true
    when 'l'
      copy_last_command_text
      return true
    end
  end

  case chars
  when "\r", "\n"
    submit_or_continue
  when "\u{7F}", "\b"
    option_held ? kill_word_left : delete_before_cursor
  when "\u{F728}"  # NSDeleteFunctionKey (forward delete)
    option_held ? kill_word_right : delete_at_cursor
  when "\u{F702}"  # NSLeftArrowFunctionKey
    option_held ? word_left : cursor_left
  when "\u{F703}"  # NSRightArrowFunctionKey
    if option_held
      word_right
    elsif at_end_with_suggestion?
      accept_full_autosuggestion
    else
      cursor_right
    end
  when "\u{F729}"  # NSHomeFunctionKey
    cursor_home
  when "\u{F72B}"  # NSEndFunctionKey
    if at_end_with_suggestion?
      accept_full_autosuggestion
    else
      cursor_end
    end
  when "\u{F700}"  # NSUpArrowFunctionKey
    if cmd_held && shift_held
      jump_to_prompt(direction: :prev)
    else
      history_step(-1)
    end
  when "\u{F701}"  # NSDownArrowFunctionKey
    if cmd_held && shift_held
      jump_to_prompt(direction: :next)
    else
      history_step(1)
    end
  when "\t"
    complete_input
  else
    first = chars.bytes.first
    if first && first >= 0x20
      @history_index = nil  # editing ends history-walk mode
      @history_saved = nil
      insert_at_cursor(chars)
    end
  end
  true
end

#jump_to_prompt(direction:) ⇒ Object

Jump scroll position to the previous or next OSC 133 prompt boundary recorded on @screen. Returns true if a jump happened, false if there was no target in that direction. The Screen’s ‘command_marks` are populated by the parser whenever the running shell emits OSC 133 (the embedded shell does this automatically; PTY-mode shells like zsh/fish emit them too when configured).



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/echoes/pane.rb', line 259

def jump_to_prompt(direction:)
  marks = @screen.command_marks.select { |m| m[:prompt_start] }
  return false if marks.empty?

  scrollback_size = @screen.scrollback.size
  current_top = scrollback_size - @scroll_offset

  target =
    case direction
    when :prev then marks.reverse.find { |m| m[:prompt_start] < current_top }
    when :next then marks.find       { |m| m[:prompt_start] > current_top }
    end
  return false unless target

  row = target[:prompt_start]
  if row >= scrollback_size
    # Target is in the live grid — scroll to bottom.
    @scroll_offset = 0
  else
    @scroll_offset = (scrollback_size - row).clamp(0, scrollback_size)
  end
  true
end

#last_command_output_textObject

Text of the most recently completed command’s output, extracted from the OSC 133 ;C..;D region. Returns nil when no command has finished yet on this pane. Useful for “copy last command output” workflows and for piping output to external tools.



217
218
219
220
221
222
# File 'lib/echoes/pane.rb', line 217

def last_command_output_text
  mark = @screen.last_completed_command_mark
  return nil unless mark
  text = @screen.text_for_command_output(mark)
  text.empty? ? nil : text
end

#last_command_textObject

Most recently submitted command’s text (the literal line the user ran). Reads rubish’s Reline::HISTORY directly, which is more reliable than scraping the cell grid (no wrapping / column-offset ambiguity from the prompt). Returns nil if no command has been submitted yet.



240
241
242
243
244
# File 'lib/echoes/pane.rb', line 240

def last_command_text
  return nil unless embedded?
  hist = @embedded_shell.history
  hist.last
end

#process_output(data) ⇒ Object



209
210
211
# File 'lib/echoes/pane.rb', line 209

def process_output(data)
  @parser.feed(data)
end

#read_available_output(max = 16384) ⇒ Object

Drain whatever output bytes are available from the shell right now. Returns “” if nothing is ready; never blocks; never raises.

In embedded mode this is also where we detect that an async command has finished — we drain its trailing output, emit OSC 133 ;D (command end), then ;A + prompt + ;B for the next command, and re-enable the in-pane line editor.



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/echoes/pane.rb', line 127

def read_available_output(max = 16384)
  return '' if editor?
  if embedded?
    out = @embedded_shell.read_available_output
    if @embedded_running && @embedded_shell.reap_if_done
      out << @embedded_shell.read_available_output
      out << osc133_d(@embedded_shell.last_status)
      # Drain trailing output + ;D through the parser ourselves
      # so we can render the next prompt natively (skipping the
      # ANSI SGR roundtrip) before returning.
      process_output(out)
      out = +''
      process_output(osc133_a)
      render_prompt_natively
      process_output(osc133_b)
      render_input_area
      @embedded_running = false
    end
    out
  else
    @pty_read.read_nonblock(max)
  end
rescue IO::WaitReadable, EOFError, Errno::EIO, IOError
  ''
end

#recall_command(text) ⇒ Object

Replace the in-progress input with ‘text` (e.g., a command recovered from a Cmd-clicked prompt’s OSC 133 mark). Cursor lands at end. Cleared history-walk and autosuggestion state so the next ↑/↓ starts from the freshly-recalled line.



867
868
869
870
871
872
# File 'lib/echoes/pane.rb', line 867

def recall_command(text)
  return if text.nil? || text.empty?
  @history_index = nil
  @history_saved = nil
  replace_input_buffer(text)
end

#refresh_pty_pixel_sizeObject

Re-send winsize with the current cell pixel metrics. Called by the GUI after ‘wire_screen_handlers` updates the Screen’s cell_pixel_width / cell_pixel_height (font load, font size change, …), so TIOCGWINSZ on the slave side carries the right pixel dims — kitten icat and other image protocols read those instead of querying CSI 14 t.



185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/echoes/pane.rb', line 185

def refresh_pty_pixel_size
  rows = @screen.rows
  cols = @screen.cols
  if embedded?
    @embedded_shell.resize(rows: rows, cols: cols,
                            px_width:  pty_pixel_width(cols),
                            px_height: pty_pixel_height(rows))
  elsif @pty_read && !@pty_read.closed?
    @pty_read.winsize = pty_winsize_quad(rows, cols)
  end
rescue Errno::EIO, IOError
end

#resize(rows, cols) ⇒ Object



164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/echoes/pane.rb', line 164

def resize(rows, cols)
  @screen.resize(rows, cols)
  if editor?
    @editor.resize(rows: rows, cols: cols)
    render_editor
  elsif embedded?
    @embedded_shell.resize(rows: rows, cols: cols,
                            px_width:  pty_pixel_width(cols),
                            px_height: pty_pixel_height(rows))
  else
    @pty_read.winsize = pty_winsize_quad(rows, cols)
  end
rescue Errno::EIO, IOError
end

#submit_line(line) ⇒ Object

Submit a complete line of input. PTY mode writes the line plus CR; embedded mode hands the line directly to the in-process REPL.



109
110
111
112
113
114
115
116
117
118
# File 'lib/echoes/pane.rb', line 109

def submit_line(line)
  if embedded?
    @embedded_shell.submit_line(line,
                                 rows: @screen.rows, cols: @screen.cols,
                                 px_width:  pty_pixel_width(@screen.cols),
                                 px_height: pty_pixel_height(@screen.rows))
  else
    @pty_write.write("#{line}\r") rescue nil
  end
end

#write_input(bytes) ⇒ Object

Send raw bytes to the shell. In PTY mode these go through pty_write to the child process. In embedded mode there is no per-keystroke input channel (line editing happens in Echoes itself), so this is a no-op — the host should call ‘submit_line` for completed lines.



99
100
101
102
103
104
105
# File 'lib/echoes/pane.rb', line 99

def write_input(bytes)
  if embedded?
    # phase-1 stub: no per-keystroke routing yet
  else
    @pty_write.write(bytes) rescue nil
  end
end