Class: Echoes::Pane
- Inherits:
-
Object
- Object
- Echoes::Pane
- 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
-
#copy_mode ⇒ Object
Returns the value of attribute copy_mode.
-
#editor ⇒ Object
readonly
Returns the value of attribute editor.
-
#embedded_shell ⇒ Object
readonly
Returns the value of attribute embedded_shell.
-
#parser ⇒ Object
Returns the value of attribute parser.
-
#pty_pid ⇒ Object
Returns the value of attribute pty_pid.
-
#pty_read ⇒ Object
Returns the value of attribute pty_read.
-
#pty_write ⇒ Object
Returns the value of attribute pty_write.
-
#screen ⇒ Object
Returns the value of attribute screen.
-
#scroll_accum ⇒ Object
Returns the value of attribute scroll_accum.
-
#scroll_offset ⇒ Object
Returns the value of attribute scroll_offset.
-
#title ⇒ Object
Returns the value of attribute title.
Instance Method Summary collapse
- #alive? ⇒ Boolean
-
#apply_completion(word_start:, completion:) ⇒ Object
Splice ‘completion` into the input buffer in place of the partial word that starts at `word_start`.
- #close ⇒ Object
-
#completion_request ⇒ Object
Pure-data: ask the embedded shell what completions are available at the current cursor and locate the start of the word being completed.
-
#copy_last_command_output ⇒ Object
Convenience: copy ‘last_command_output_text` to the system clipboard via the screen’s clipboard handler.
- #copy_last_command_text ⇒ Object
- #editor? ⇒ Boolean
- #embedded? ⇒ Boolean
-
#handle_key(chars:, flags: 0) ⇒ Object
Embedded-mode keyboard handling.
-
#initialize(command:, rows:, cols:, cwd: nil, embedded: false, no_rc: false, editor_file: nil) ⇒ Pane
constructor
A new instance of Pane.
-
#jump_to_prompt(direction:) ⇒ Object
Jump scroll position to the previous or next OSC 133 prompt boundary recorded on @screen.
-
#last_command_output_text ⇒ Object
Text of the most recently completed command’s output, extracted from the OSC 133 ;C..;D region.
-
#last_command_text ⇒ Object
Most recently submitted command’s text (the literal line the user ran).
- #process_output(data) ⇒ Object
-
#read_available_output(max = 16384) ⇒ Object
Drain whatever output bytes are available from the shell right now.
-
#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).
- #resize(rows, cols) ⇒ Object
-
#submit_line(line) ⇒ Object
Submit a complete line of input.
-
#write_input(bytes) ⇒ Object
Send raw bytes to the shell.
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 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 render_editor if editor? end |
Instance Attribute Details
#copy_mode ⇒ Object
Returns the value of attribute copy_mode.
16 17 18 |
# File 'lib/echoes/pane.rb', line 16 def copy_mode @copy_mode end |
#editor ⇒ Object (readonly)
Returns the value of attribute editor.
74 75 76 |
# File 'lib/echoes/pane.rb', line 74 def editor @editor end |
#embedded_shell ⇒ Object (readonly)
Returns the value of attribute embedded_shell.
18 19 20 |
# File 'lib/echoes/pane.rb', line 18 def @embedded_shell end |
#parser ⇒ Object
Returns the value of attribute parser.
16 17 18 |
# File 'lib/echoes/pane.rb', line 16 def parser @parser end |
#pty_pid ⇒ Object
Returns the value of attribute pty_pid.
16 17 18 |
# File 'lib/echoes/pane.rb', line 16 def pty_pid @pty_pid end |
#pty_read ⇒ Object
Returns the value of attribute pty_read.
16 17 18 |
# File 'lib/echoes/pane.rb', line 16 def pty_read @pty_read end |
#pty_write ⇒ Object
Returns the value of attribute pty_write.
16 17 18 |
# File 'lib/echoes/pane.rb', line 16 def pty_write @pty_write end |
#screen ⇒ Object
Returns the value of attribute screen.
16 17 18 |
# File 'lib/echoes/pane.rb', line 16 def screen @screen end |
#scroll_accum ⇒ Object
Returns the value of attribute scroll_accum.
16 17 18 |
# File 'lib/echoes/pane.rb', line 16 def scroll_accum @scroll_accum end |
#scroll_offset ⇒ Object
Returns the value of attribute scroll_offset.
16 17 18 |
# File 'lib/echoes/pane.rb', line 16 def scroll_offset @scroll_offset end |
#title ⇒ Object
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
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_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 |
#close ⇒ Object
155 156 157 158 159 160 161 162 163 164 |
# File 'lib/echoes/pane.rb', line 155 def close return if editor? if @embedded_shell.shutdown return end @pty_write.close rescue nil @pty_read.close rescue nil Process.kill(:HUP, @pty_pid) rescue nil end |
#completion_request ⇒ Object
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_output ⇒ Object
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_text ⇒ Object
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
70 71 72 |
# File 'lib/echoes/pane.rb', line 70 def editor? !@editor.nil? end |
#embedded? ⇒ Boolean
66 67 68 |
# File 'lib/echoes/pane.rb', line 66 def !@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 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_text ⇒ Object
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_text ⇒ Object
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 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 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_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_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 # phase-1 stub: no per-keystroke routing yet else @pty_write.write(bytes) rescue nil end end |