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) ⇒ 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
# File 'lib/echoes/pane.rb', line 20

def initialize(command:, rows:, cols:, cwd: nil, embedded: false, no_rc: false, editor_file: 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)
    @parser = Parser.new(@screen, writer: ->(_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
      ENV['TERM'] = Echoes.config.term
      ENV['LANG'] ||= 'en_US.UTF-8'
      ENV['LC_CTYPE'] = 'UTF-8'
      @pty_read, @pty_write, @pty_pid = PTY.spawn(command)
      @pty_read.winsize = [rows, cols]
    end
    @parser = Parser.new(@screen, writer: ->(s) { @pty_write.write(s) rescue nil })
    @title = File.basename(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.



74
75
76
# File 'lib/echoes/pane.rb', line 74

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)


131
132
133
134
135
136
137
138
139
140
# File 'lib/echoes/pane.rb', line 131

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.



810
811
812
813
814
815
816
# File 'lib/echoes/pane.rb', line 810

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



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

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.



796
797
798
799
800
801
802
803
# File 'lib/echoes/pane.rb', line 796

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).



185
186
187
188
189
190
# File 'lib/echoes/pane.rb', line 185

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

#copy_last_command_textObject



203
204
205
206
207
208
# File 'lib/echoes/pane.rb', line 203

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)


70
71
72
# File 'lib/echoes/pane.rb', line 70

def editor?
  !@editor.nil?
end

#embedded?Boolean

Returns:

  • (Boolean)


66
67
68
# File 'lib/echoes/pane.rb', line 66

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.


252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
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
# File 'lib/echoes/pane.rb', line 252

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).



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/echoes/pane.rb', line 216

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.



174
175
176
177
178
179
# File 'lib/echoes/pane.rb', line 174

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.



197
198
199
200
201
# File 'lib/echoes/pane.rb', line 197

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

#process_output(data) ⇒ Object



166
167
168
# File 'lib/echoes/pane.rb', line 166

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.



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/echoes/pane.rb', line 105

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.



822
823
824
825
826
827
# File 'lib/echoes/pane.rb', line 822

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

#resize(rows, cols) ⇒ Object



142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/echoes/pane.rb', line 142

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)
  else
    @pty_read.winsize = [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.



90
91
92
93
94
95
96
# File 'lib/echoes/pane.rb', line 90

def submit_line(line)
  if embedded?
    @embedded_shell.submit_line(line)
  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.



80
81
82
83
84
85
86
# File 'lib/echoes/pane.rb', line 80

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