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, env: 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).
-
#refresh_pty_pixel_size ⇒ Object
Re-send winsize with the current cell pixel metrics.
- #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, 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 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 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.
93 94 95 |
# File 'lib/echoes/pane.rb', line 93 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
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_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 |
#close ⇒ Object
198 199 200 201 202 203 204 205 206 207 |
# File 'lib/echoes/pane.rb', line 198 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.
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_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).
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_text ⇒ Object
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
89 90 91 |
# File 'lib/echoes/pane.rb', line 89 def editor? !@editor.nil? end |
#embedded? ⇒ Boolean
85 86 87 |
# File 'lib/echoes/pane.rb', line 85 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.
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 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_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.
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_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.
240 241 242 243 244 |
# File 'lib/echoes/pane.rb', line 240 def last_command_text return nil unless 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 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_size ⇒ Object
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_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_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_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 # phase-1 stub: no per-keystroke routing yet else @pty_write.write(bytes) rescue nil end end |