echoes 0.2.0

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,1122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pty'
4
+
5
+ module Echoes
6
+ # A Pane is one shell session within a Tab. It owns a Screen (the cell
7
+ # grid the user sees) and a backing shell — either an external program
8
+ # spawned via PTY (the default), or a Rubish::REPL running in a per-pane
9
+ # helper subprocess via Echoes::EmbeddedShell. The helper owns the pty
10
+ # as its controlling tty so Ctrl-C / SIGWINCH / job control all work.
11
+ #
12
+ # Callers that need to send bytes to the shell or pull bytes back use
13
+ # `write_input` / `read_available_output`. Don't reach for the legacy
14
+ # `pty_read` / `pty_write` accessors — they're nil in embedded mode.
15
+ class Pane
16
+ attr_accessor :screen, :parser, :pty_read, :pty_write, :pty_pid,
17
+ :scroll_offset, :scroll_accum, :title, :copy_mode
18
+ attr_reader :embedded_shell
19
+
20
+ def initialize(command:, rows:, cols:, cwd: nil, embedded: false, no_rc: false, editor_file: nil)
21
+ @screen = Screen.new(rows: rows, cols: cols)
22
+ if editor_file
23
+ require_relative 'editor'
24
+ @editor = Editor.new(file: editor_file, rows: rows, cols: cols)
25
+ @parser = Parser.new(@screen, writer: ->(_s) { })
26
+ @title = File.basename(editor_file)
27
+ elsif embedded
28
+ require_relative 'embedded_shell'
29
+ @embedded_shell = EmbeddedShell.new(no_rc: no_rc)
30
+ @parser = Parser.new(@screen, writer: ->(_s) { })
31
+ @title = 'rubish'
32
+ @input_buffer = +''
33
+ @input_cursor = 0 # offset within @input_buffer (0..length)
34
+ @embedded_running = false
35
+ @history_index = nil # nil = not browsing; integer = browsing
36
+ @history_saved = nil # input held aside while browsing
37
+ @continuation_lines = [] # collected lines while waiting for a complete command
38
+ @kill_ring = +'' # last killed text (for Ctrl-Y yank)
39
+ @autosuggestion = +'' # fish-style: tail of the most recent matching history entry
40
+ @right_prompt_segments = nil # cached at prompt time; redrawn after every input edit
41
+ @input_mode = :prompt # :prompt | :search (running uses @embedded_shell.running?)
42
+ @search_query = +'' # Ctrl-R substring being typed
43
+ @search_index = nil # index into history of the current match (nil = no match)
44
+ @search_saved_buffer = nil
45
+ @search_saved_cursor = nil
46
+ @search_saved_autosuggestion = nil
47
+ else
48
+ start_dir = (cwd && Dir.exist?(cwd)) ? cwd : Dir.home
49
+ Dir.chdir(start_dir) do
50
+ ENV['TERM'] = Echoes.config.term
51
+ ENV['LANG'] ||= 'en_US.UTF-8'
52
+ ENV['LC_CTYPE'] = 'UTF-8'
53
+ @pty_read, @pty_write, @pty_pid = PTY.spawn(command)
54
+ @pty_read.winsize = [rows, cols]
55
+ end
56
+ @parser = Parser.new(@screen, writer: ->(s) { @pty_write.write(s) rescue nil })
57
+ @title = File.basename(command)
58
+ end
59
+ @scroll_offset = 0
60
+ @scroll_accum = 0.0
61
+ @copy_mode = nil
62
+ render_initial_prompt if embedded
63
+ render_editor if editor?
64
+ end
65
+
66
+ def embedded?
67
+ !@embedded_shell.nil?
68
+ end
69
+
70
+ def editor?
71
+ !@editor.nil?
72
+ end
73
+
74
+ attr_reader :editor
75
+
76
+ # Send raw bytes to the shell. In PTY mode these go through pty_write
77
+ # to the child process. In embedded mode there is no per-keystroke
78
+ # input channel (line editing happens in Echoes itself), so this is
79
+ # a no-op — the host should call `submit_line` for completed lines.
80
+ def write_input(bytes)
81
+ if embedded?
82
+ # phase-1 stub: no per-keystroke routing yet
83
+ else
84
+ @pty_write.write(bytes) rescue nil
85
+ end
86
+ end
87
+
88
+ # Submit a complete line of input. PTY mode writes the line plus CR;
89
+ # embedded mode hands the line directly to the in-process REPL.
90
+ def submit_line(line)
91
+ if embedded?
92
+ @embedded_shell.submit_line(line)
93
+ else
94
+ @pty_write.write("#{line}\r") rescue nil
95
+ end
96
+ end
97
+
98
+ # Drain whatever output bytes are available from the shell right now.
99
+ # Returns "" if nothing is ready; never blocks; never raises.
100
+ #
101
+ # In embedded mode this is also where we detect that an async
102
+ # command has finished — we drain its trailing output, emit OSC 133
103
+ # ;D (command end), then ;A + prompt + ;B for the next command, and
104
+ # re-enable the in-pane line editor.
105
+ def read_available_output(max = 16384)
106
+ return '' if editor?
107
+ if embedded?
108
+ out = @embedded_shell.read_available_output
109
+ if @embedded_running && @embedded_shell.reap_if_done
110
+ out << @embedded_shell.read_available_output
111
+ out << osc133_d(@embedded_shell.last_status)
112
+ # Drain trailing output + ;D through the parser ourselves
113
+ # so we can render the next prompt natively (skipping the
114
+ # ANSI SGR roundtrip) before returning.
115
+ process_output(out)
116
+ out = +''
117
+ process_output(osc133_a)
118
+ render_prompt_natively
119
+ process_output(osc133_b)
120
+ render_input_area
121
+ @embedded_running = false
122
+ end
123
+ out
124
+ else
125
+ @pty_read.read_nonblock(max)
126
+ end
127
+ rescue IO::WaitReadable, EOFError, Errno::EIO, IOError
128
+ ''
129
+ end
130
+
131
+ def alive?
132
+ return !@editor.closed? if editor?
133
+ if embedded?
134
+ @embedded_shell.alive?
135
+ else
136
+ Process.waitpid(@pty_pid, Process::WNOHANG).nil?
137
+ end
138
+ rescue Errno::ECHILD
139
+ false
140
+ end
141
+
142
+ def resize(rows, cols)
143
+ @screen.resize(rows, cols)
144
+ if editor?
145
+ @editor.resize(rows: rows, cols: cols)
146
+ render_editor
147
+ elsif embedded?
148
+ @embedded_shell.resize(rows: rows, cols: cols)
149
+ else
150
+ @pty_read.winsize = [rows, cols]
151
+ end
152
+ rescue Errno::EIO, IOError
153
+ end
154
+
155
+ def close
156
+ return if editor?
157
+ if embedded?
158
+ @embedded_shell.shutdown
159
+ return
160
+ end
161
+ @pty_write.close rescue nil
162
+ @pty_read.close rescue nil
163
+ Process.kill(:HUP, @pty_pid) rescue nil
164
+ end
165
+
166
+ def process_output(data)
167
+ @parser.feed(data)
168
+ end
169
+
170
+ # Text of the most recently completed command's output, extracted
171
+ # from the OSC 133 ;C..;D region. Returns nil when no command has
172
+ # finished yet on this pane. Useful for "copy last command output"
173
+ # workflows and for piping output to external tools.
174
+ def last_command_output_text
175
+ mark = @screen.last_completed_command_mark
176
+ return nil unless mark
177
+ text = @screen.text_for_command_output(mark)
178
+ text.empty? ? nil : text
179
+ end
180
+
181
+ # Convenience: copy `last_command_output_text` to the system
182
+ # clipboard via the screen's clipboard handler. Returns true on
183
+ # success, false if there's nothing to copy or no clipboard
184
+ # handler is wired (e.g., in tests).
185
+ def copy_last_command_output
186
+ text = last_command_output_text
187
+ return false unless text
188
+ @screen.set_clipboard(text)
189
+ true
190
+ end
191
+
192
+ # Most recently submitted command's text (the literal line the user
193
+ # ran). Reads rubish's Reline::HISTORY directly, which is more
194
+ # reliable than scraping the cell grid (no wrapping / column-offset
195
+ # ambiguity from the prompt). Returns nil if no command has been
196
+ # submitted yet.
197
+ def last_command_text
198
+ return nil unless embedded?
199
+ hist = @embedded_shell.history
200
+ hist.last
201
+ end
202
+
203
+ def copy_last_command_text
204
+ text = last_command_text
205
+ return false unless text && !text.empty?
206
+ @screen.set_clipboard(text)
207
+ true
208
+ end
209
+
210
+ # Jump scroll position to the previous or next OSC 133 prompt
211
+ # boundary recorded on @screen. Returns true if a jump happened,
212
+ # false if there was no target in that direction. The Screen's
213
+ # `command_marks` are populated by the parser whenever the running
214
+ # shell emits OSC 133 (the embedded shell does this automatically;
215
+ # PTY-mode shells like zsh/fish emit them too when configured).
216
+ def jump_to_prompt(direction:)
217
+ marks = @screen.command_marks.select { |m| m[:prompt_start] }
218
+ return false if marks.empty?
219
+
220
+ scrollback_size = @screen.scrollback.size
221
+ current_top = scrollback_size - @scroll_offset
222
+
223
+ target =
224
+ case direction
225
+ when :prev then marks.reverse.find { |m| m[:prompt_start] < current_top }
226
+ when :next then marks.find { |m| m[:prompt_start] > current_top }
227
+ end
228
+ return false unless target
229
+
230
+ row = target[:prompt_start]
231
+ if row >= scrollback_size
232
+ # Target is in the live grid — scroll to bottom.
233
+ @scroll_offset = 0
234
+ else
235
+ @scroll_offset = (scrollback_size - row).clamp(0, scrollback_size)
236
+ end
237
+ true
238
+ end
239
+
240
+ # Embedded-mode keyboard handling. Returns true if the pane consumed
241
+ # the event, false if the GUI should fall through to its own
242
+ # PTY-style handling (which is the only mode in non-embedded panes).
243
+ #
244
+ # Two states:
245
+ # - prompt mode (no command running): printable chars echo to the
246
+ # screen and append to @input_buffer; Backspace pops a char and
247
+ # erases the last cell; Enter submits the buffered line for
248
+ # async execution.
249
+ # - running mode (a command is in flight): keystrokes get
250
+ # forwarded to the command's stdin via the pty master, so the
251
+ # user can type into vim, scroll less, etc. Ctrl-C interrupts.
252
+ def handle_key(chars:, flags: 0)
253
+ if editor?
254
+ return true if chars.nil? || chars.empty?
255
+ # Map Ctrl+letter to the corresponding control byte that
256
+ # rvim's keymap expects (e.g. Ctrl-D → 0x04). Other special
257
+ # keys are translated by Editor#feed_key directly.
258
+ ch = if (flags & NSEVENT_CONTROL_FLAG) != 0 && chars.length == 1 && chars.ord >= 0x20
259
+ (chars.ord & 0x1F).chr
260
+ else
261
+ chars
262
+ end
263
+ @editor.feed_key(ch)
264
+ render_editor
265
+ return true
266
+ end
267
+ return false unless embedded?
268
+ return true if chars.nil? || chars.empty?
269
+
270
+ if @embedded_shell.running?
271
+ # Translate macOS special-key code points to the ANSI escape
272
+ # sequences a real terminal would have produced — that's what
273
+ # programs reading from the pty (vim, less, etc.) expect.
274
+ translated = translate_for_pty(chars, flags)
275
+ @embedded_shell.forward_input(translated)
276
+ return true
277
+ end
278
+
279
+ return handle_search_key(chars, flags) if @input_mode == :search
280
+
281
+ # Emacs/readline-style bindings on Ctrl+letter at the prompt.
282
+ # macOS gives us `chars` as the plain letter (Cocoa's
283
+ # charactersIgnoringModifiers); flags carries the Control bit.
284
+ if (flags & NSEVENT_CONTROL_FLAG) != 0 && chars.length == 1 && chars.ord >= 0x20
285
+ return true if handle_ctrl_letter(chars.downcase)
286
+ end
287
+
288
+ option_held = (flags & NSEVENT_OPTION_FLAG) != 0
289
+ cmd_held = (flags & NSEVENT_COMMAND_FLAG) != 0
290
+ shift_held = (flags & NSEVENT_SHIFT_FLAG) != 0
291
+
292
+ # Cmd+Shift+letter: pane-level shortcuts that operate on OSC 133
293
+ # marks. Cmd+Shift+Up/Down (jump-to-prompt) is matched in the
294
+ # arrow-key cases below.
295
+ if cmd_held && shift_held && chars.length == 1
296
+ case chars.downcase
297
+ when 'o'
298
+ copy_last_command_output
299
+ return true
300
+ when 'l'
301
+ copy_last_command_text
302
+ return true
303
+ end
304
+ end
305
+
306
+ case chars
307
+ when "\r", "\n"
308
+ submit_or_continue
309
+ when "\u{7F}", "\b"
310
+ option_held ? kill_word_left : delete_before_cursor
311
+ when "\u{F728}" # NSDeleteFunctionKey (forward delete)
312
+ option_held ? kill_word_right : delete_at_cursor
313
+ when "\u{F702}" # NSLeftArrowFunctionKey
314
+ option_held ? word_left : cursor_left
315
+ when "\u{F703}" # NSRightArrowFunctionKey
316
+ if option_held
317
+ word_right
318
+ elsif at_end_with_suggestion?
319
+ accept_full_autosuggestion
320
+ else
321
+ cursor_right
322
+ end
323
+ when "\u{F729}" # NSHomeFunctionKey
324
+ cursor_home
325
+ when "\u{F72B}" # NSEndFunctionKey
326
+ if at_end_with_suggestion?
327
+ accept_full_autosuggestion
328
+ else
329
+ cursor_end
330
+ end
331
+ when "\u{F700}" # NSUpArrowFunctionKey
332
+ if cmd_held && shift_held
333
+ jump_to_prompt(direction: :prev)
334
+ else
335
+ history_step(-1)
336
+ end
337
+ when "\u{F701}" # NSDownArrowFunctionKey
338
+ if cmd_held && shift_held
339
+ jump_to_prompt(direction: :next)
340
+ else
341
+ history_step(1)
342
+ end
343
+ when "\t"
344
+ complete_input
345
+ else
346
+ first = chars.bytes.first
347
+ if first && first >= 0x20
348
+ @history_index = nil # editing ends history-walk mode
349
+ @history_saved = nil
350
+ insert_at_cursor(chars)
351
+ end
352
+ end
353
+ true
354
+ end
355
+
356
+ private
357
+
358
+ # Re-render the editor's visible window into the screen
359
+ # cells. Called on construction, after every key event, and
360
+ # after resize.
361
+ def render_editor
362
+ return unless @editor
363
+ process_output("\e[2J\e[H")
364
+ segs_per_row = @editor.visible_segments
365
+ segs_per_row.each_with_index do |segs, idx|
366
+ break if idx >= @screen.rows
367
+ @screen.cursor.row = idx
368
+ @screen.cursor.col = 0
369
+ @screen.put_styled_segments(segs)
370
+ end
371
+ row, col = @editor.cursor_position
372
+ @screen.cursor.row = row.clamp(0, @screen.rows - 1)
373
+ @screen.cursor.col = col.clamp(0, @screen.cols - 1)
374
+ @screen.pending_wrap = false
375
+ @screen.mark_all_dirty
376
+ end
377
+
378
+ def render_initial_prompt
379
+ process_output(osc133_a)
380
+ render_prompt_natively
381
+ process_output(osc133_b)
382
+ render_input_area # draws the rprompt for the (empty) initial input
383
+ end
384
+
385
+ # Render the current prompt by pulling rubish's structured
386
+ # `prompt_segments` and writing them directly to cells via
387
+ # `Screen#put_styled_segments` — no ANSI SGR roundtrip. Falls
388
+ # back to the legacy ANSI string path if segments aren't
389
+ # available.
390
+ #
391
+ # Also refreshes the rprompt cache. Drawing the rprompt itself is
392
+ # done by render_input_area so it follows the input on every
393
+ # edit; that way the rprompt isn't lost when the user's input
394
+ # grows long enough to overwrite its cells and is then shortened.
395
+ def render_prompt_natively
396
+ segments = @embedded_shell.prompt_segments
397
+ if segments && !segments.empty?
398
+ @screen.put_styled_segments(segments)
399
+ else
400
+ process_output(@embedded_shell.prompt.to_s)
401
+ end
402
+ @right_prompt_segments = @embedded_shell.right_prompt_segments
403
+ end
404
+
405
+ # Render the input area: colored input, dim autosuggestion, then
406
+ # the cached right-prompt at the right edge, and finally restores
407
+ # the cursor to the user's logical input position. Assumed cursor
408
+ # entry: at the start of the input area (just past the main prompt).
409
+ def render_input_area
410
+ process_output(colorize_input(@input_buffer))
411
+ process_output("\e[2m" + @autosuggestion + "\e[0m") unless @autosuggestion.empty?
412
+ input_visible = @input_buffer.length + @autosuggestion.length
413
+ draw_right_prompt_inline(input_visible)
414
+ back = input_visible - @input_cursor
415
+ process_output("\e[#{back}D") if back > 0
416
+ end
417
+
418
+ # Draw the cached rprompt at the right edge of the current row.
419
+ # On entry the cursor sits right after the input + suggestion (at
420
+ # column `input_start_col + input_visible`); on exit it's back at
421
+ # that same column. Skipped when it would overlap the input.
422
+ def draw_right_prompt_inline(input_visible)
423
+ rsegs = @right_prompt_segments
424
+ return if rsegs.nil? || rsegs.empty?
425
+ rwidth = rsegs.sum { |s| (s[:text] || '').length }
426
+ return if rwidth == 0
427
+ cols = @screen.cols
428
+ saved_row = @screen.cursor.row
429
+ saved_col = @screen.cursor.col
430
+ target_col = cols - rwidth
431
+ return if saved_col >= target_col # not enough room
432
+
433
+ delta = target_col - saved_col
434
+ process_output("\e[#{delta}C")
435
+ @screen.put_styled_segments(rsegs)
436
+ # When the rprompt's last cell lands at cols-1, put_char defers
437
+ # the wrap and leaves cursor.col=cols-1 with pending_wrap=true.
438
+ # ANSI `\e[D` would then back up from cols-1 not cols and we'd
439
+ # lose the trailing prompt cell. Restore cursor state directly
440
+ # instead.
441
+ @screen.cursor.row = saved_row
442
+ @screen.cursor.col = saved_col
443
+ @screen.pending_wrap = false
444
+ end
445
+
446
+ # OSC 133 escape strings. Hosts surrounding shells in their own
447
+ # render layer (us) emit these to mark prompt/input/output regions.
448
+ # The terminal parser routes them to Screen#osc133_mark.
449
+ def osc133_a; "\e]133;A\e\\"; end
450
+ def osc133_b; "\e]133;B\e\\"; end
451
+ def osc133_c; "\e]133;C\e\\"; end
452
+ def osc133_d(code = nil)
453
+ code.nil? ? "\e]133;D\e\\" : "\e]133;D;#{code}\e\\"
454
+ end
455
+
456
+ # Enter pressed at the prompt. Decide whether the accumulated
457
+ # input forms a complete command — if so, submit it; if not,
458
+ # keep collecting continuation lines under PS2.
459
+ def submit_or_continue
460
+ this_line = @input_buffer
461
+ candidate = (@continuation_lines + [this_line]).join("\n")
462
+
463
+ case @embedded_shell.try_parse(candidate)
464
+ when :incomplete
465
+ # Accumulate and prompt for more.
466
+ @continuation_lines << this_line
467
+ @input_buffer = +''
468
+ @input_cursor = 0
469
+ @autosuggestion = +''
470
+ process_output("\r\n")
471
+ process_output(@embedded_shell.continuation_prompt)
472
+ else
473
+ # :ok or :error — let rubish run it; rubish reports syntax
474
+ # errors itself. Either way, this line completes the input.
475
+ line = candidate
476
+ @input_buffer = +''
477
+ @input_cursor = 0
478
+ @continuation_lines = []
479
+ @history_index = nil
480
+ @history_saved = nil
481
+ @autosuggestion = +''
482
+ process_output("\r\n")
483
+ process_output(osc133_c)
484
+ # Stash the command text on the OSC 133 mark so click-to-rerun
485
+ # can recover it later.
486
+ @screen.set_current_command_text(line)
487
+ @embedded_shell.submit_line(line, rows: @screen.rows, cols: @screen.cols)
488
+ @embedded_running = true
489
+ end
490
+ end
491
+
492
+ # ↑/↓ history navigation. step is -1 (older) or +1 (newer).
493
+ def history_step(step)
494
+ hist = @embedded_shell.history
495
+ return if hist.empty?
496
+
497
+ if @history_index.nil?
498
+ return if step > 0 # already at "current input", down-arrow no-op
499
+ @history_saved = @input_buffer.dup
500
+ @history_index = hist.size # one past last; about to decrement
501
+ end
502
+
503
+ new_index = @history_index + step
504
+ if new_index < 0
505
+ return # already at oldest
506
+ elsif new_index >= hist.size
507
+ # past the newest entry → restore the user's saved in-progress input
508
+ replace_input_buffer(@history_saved || '')
509
+ @history_index = nil
510
+ @history_saved = nil
511
+ else
512
+ @history_index = new_index
513
+ replace_input_buffer(hist[@history_index] || '')
514
+ end
515
+ end
516
+
517
+ # Erase the currently-displayed input line and replace it with
518
+ # `new_line`. After the call the screen cursor is at the end of
519
+ # new_line. Used by history navigation, which always wants the
520
+ # cursor at the end after a swap.
521
+ def replace_input_buffer(new_line)
522
+ replace_input_line(new_line, new_line.length)
523
+ end
524
+
525
+ # Lower-level variant: replace the input line with `new_line` and
526
+ # position the cursor at `new_cursor` within it. Erases the old
527
+ # input + autosuggestion, then delegates to `render_input_area` so
528
+ # the input, autosuggestion, and rprompt are all redrawn from the
529
+ # cached state in lockstep.
530
+ def replace_input_line(new_line, new_cursor)
531
+ prev_visible = @input_buffer.length + @autosuggestion.length
532
+ tail_len = prev_visible - @input_cursor
533
+ process_output("\e[#{tail_len}C") if tail_len > 0
534
+ process_output("\b \b" * prev_visible)
535
+ @input_buffer = +new_line
536
+ @input_cursor = new_cursor
537
+ @autosuggestion = compute_autosuggestion
538
+ render_input_area
539
+ end
540
+
541
+ # ---- Mid-line editing primitives. All operate on @input_buffer
542
+ # and @input_cursor and emit just enough on the screen to keep the
543
+ # cell-grid view in sync.
544
+
545
+ def insert_at_cursor(chars)
546
+ new_line = @input_buffer.dup.insert(@input_cursor, chars)
547
+ replace_input_line(new_line, @input_cursor + chars.length)
548
+ end
549
+
550
+ def delete_before_cursor
551
+ return if @input_cursor == 0
552
+ new_line = @input_buffer.dup.tap { |s| s.slice!(@input_cursor - 1) }
553
+ replace_input_line(new_line, @input_cursor - 1)
554
+ end
555
+
556
+ def delete_at_cursor
557
+ return if @input_cursor >= @input_buffer.length
558
+ new_line = @input_buffer.dup.tap { |s| s.slice!(@input_cursor) }
559
+ replace_input_line(new_line, @input_cursor)
560
+ end
561
+
562
+ def cursor_left
563
+ return if @input_cursor == 0
564
+ @input_cursor -= 1
565
+ process_output("\e[D")
566
+ end
567
+
568
+ def cursor_right
569
+ return if @input_cursor >= @input_buffer.length
570
+ @input_cursor += 1
571
+ process_output("\e[C")
572
+ end
573
+
574
+ def cursor_home
575
+ return if @input_cursor == 0
576
+ process_output("\e[#{@input_cursor}D")
577
+ @input_cursor = 0
578
+ end
579
+
580
+ def cursor_end
581
+ n = @input_buffer.length - @input_cursor
582
+ return if n == 0
583
+ process_output("\e[#{n}C")
584
+ @input_cursor = @input_buffer.length
585
+ end
586
+
587
+ # Emacs/readline keybindings handled at the prompt. Returns true
588
+ # if we consumed the keystroke. The PTY-mode pane still uses the
589
+ # GUI's existing Ctrl-letter -> control byte path; this only fires
590
+ # in embedded prompt mode.
591
+ def handle_ctrl_letter(letter)
592
+ case letter
593
+ when 'a' then cursor_home; true
594
+ when 'e'
595
+ # Ctrl-E: jump to end. If already at end and a suggestion is
596
+ # showing, accept it (fish-style).
597
+ if at_end_with_suggestion?
598
+ accept_full_autosuggestion
599
+ else
600
+ cursor_end
601
+ end
602
+ true
603
+ when 'b' then cursor_left; true
604
+ when 'f'
605
+ # Ctrl-F: forward one char. At end-of-input with a suggestion,
606
+ # accept just one word of it (fish's accept-autosuggestion-word).
607
+ if at_end_with_suggestion?
608
+ accept_word_of_autosuggestion
609
+ else
610
+ cursor_right
611
+ end
612
+ true
613
+ when 'h' then delete_before_cursor; true # ASCII 0x08 (BS)
614
+ when 'i' then complete_input; true # ASCII 0x09 (Tab)
615
+ when 'j' then submit_or_continue; true # ASCII 0x0A (LF / Enter)
616
+ when 'm' then submit_or_continue; true # ASCII 0x0D (CR / Enter)
617
+ when 'p' then history_step(-1); true # readline alias for ↑
618
+ when 'n' then history_step(1); true # readline alias for ↓
619
+ when 't' then transpose_chars; true
620
+ when 'y' then yank_kill_ring; true
621
+ when 'd'
622
+ # Bash convention: Ctrl-D on an empty line is "EOF / exit"; on
623
+ # a non-empty line it's forward-delete. Synthesize a typed
624
+ # `exit` so it goes through the same submit path as the user
625
+ # typing it manually — the helper catches rubish's `throw
626
+ # :exit` and shuts down.
627
+ if @input_buffer.empty?
628
+ @input_buffer = +'exit'
629
+ @input_cursor = @input_buffer.length
630
+ submit_or_continue
631
+ else
632
+ delete_at_cursor
633
+ end
634
+ true
635
+ when 'k' then kill_to_end; true
636
+ when 'u' then kill_to_start; true
637
+ when 'w' then kill_word_left; true
638
+ when 'l' then redraw_screen; true
639
+ when 'r' then enter_search; true
640
+ when 'c'
641
+ # Ctrl-C at the prompt: discard the in-progress line, drop
642
+ # the user on a fresh prompt below. Like bash.
643
+ process_output("^C\r\n")
644
+ @input_buffer = +''
645
+ @input_cursor = 0
646
+ @history_index = nil
647
+ @history_saved = nil
648
+ @autosuggestion = +''
649
+ process_output(osc133_a)
650
+ render_prompt_natively
651
+ process_output(osc133_b)
652
+ render_input_area
653
+ true
654
+ else
655
+ false
656
+ end
657
+ end
658
+
659
+ def kill_to_end
660
+ return if @input_cursor >= @input_buffer.length
661
+ @kill_ring = @input_buffer[@input_cursor..]
662
+ new_line = @input_buffer[0, @input_cursor]
663
+ replace_input_line(new_line, @input_cursor)
664
+ end
665
+
666
+ def kill_to_start
667
+ return if @input_cursor == 0
668
+ @kill_ring = @input_buffer[0, @input_cursor]
669
+ new_line = @input_buffer[@input_cursor..] || ''
670
+ replace_input_line(new_line, 0)
671
+ end
672
+
673
+ def kill_word_left
674
+ return if @input_cursor == 0
675
+ i = @input_cursor
676
+ i -= 1 while i > 0 && @input_buffer[i - 1] == ' '
677
+ i -= 1 while i > 0 && @input_buffer[i - 1] != ' '
678
+ removed = @input_cursor - i
679
+ return if removed == 0
680
+ @kill_ring = @input_buffer[i, removed]
681
+ new_line = @input_buffer.dup.tap { |s| s.slice!(i, removed) }
682
+ replace_input_line(new_line, i)
683
+ end
684
+
685
+ # Mirror of kill_word_left: skip whitespace forward, then a word, kill
686
+ # that span. Bound to Option+forward-Delete; matches macOS muscle memory.
687
+ def kill_word_right
688
+ return if @input_cursor >= @input_buffer.length
689
+ j = @input_cursor
690
+ j += 1 while j < @input_buffer.length && @input_buffer[j] == ' '
691
+ j += 1 while j < @input_buffer.length && @input_buffer[j] != ' '
692
+ removed = j - @input_cursor
693
+ return if removed == 0
694
+ @kill_ring = @input_buffer[@input_cursor, removed]
695
+ new_line = @input_buffer.dup.tap { |s| s.slice!(@input_cursor, removed) }
696
+ replace_input_line(new_line, @input_cursor)
697
+ end
698
+
699
+ # Re-insert the most recently killed text at the cursor.
700
+ def yank_kill_ring
701
+ return if @kill_ring.empty?
702
+ insert_at_cursor(@kill_ring)
703
+ end
704
+
705
+ # Swap the char before the cursor with the char at the cursor and
706
+ # advance one position. At end-of-line, swaps the last two chars
707
+ # (readline behavior). At start-of-line, no-op.
708
+ def transpose_chars
709
+ return if @input_buffer.length < 2 || @input_cursor == 0
710
+ new_line = @input_buffer.dup
711
+ if @input_cursor == @input_buffer.length
712
+ new_line[-2], new_line[-1] = new_line[-1], new_line[-2]
713
+ replace_input_line(new_line, @input_cursor)
714
+ else
715
+ new_line[@input_cursor - 1], new_line[@input_cursor] =
716
+ new_line[@input_cursor], new_line[@input_cursor - 1]
717
+ replace_input_line(new_line, @input_cursor + 1)
718
+ end
719
+ end
720
+
721
+ def redraw_screen
722
+ process_output("\e[2J\e[H")
723
+ render_prompt_natively
724
+ render_input_area
725
+ end
726
+
727
+ # Translate a macOS NSEvent character (which uses U+F70x for
728
+ # special keys) to the ANSI escape sequence a unix program reading
729
+ # from a pty would expect. Plain printable input passes through;
730
+ # Ctrl+letter gets masked to its control byte (so Ctrl-C → ETX).
731
+ SPECIAL_KEY_TO_ANSI = {
732
+ "\u{F700}" => "\e[A", # Up
733
+ "\u{F701}" => "\e[B", # Down
734
+ "\u{F703}" => "\e[C", # Right
735
+ "\u{F702}" => "\e[D", # Left
736
+ "\u{F728}" => "\e[3~", # Delete (forward)
737
+ "\u{F729}" => "\e[H", # Home
738
+ "\u{F72B}" => "\e[F", # End
739
+ "\u{F72C}" => "\e[5~", # PageUp
740
+ "\u{F72D}" => "\e[6~", # PageDown
741
+ }.freeze
742
+
743
+ NSEVENT_CONTROL_FLAG = 0x40000
744
+ NSEVENT_OPTION_FLAG = 0x80000
745
+ NSEVENT_SHIFT_FLAG = 0x20000
746
+ NSEVENT_COMMAND_FLAG = 0x100000
747
+
748
+ def word_left
749
+ return if @input_cursor == 0
750
+ i = @input_cursor
751
+ i -= 1 while i > 0 && @input_buffer[i - 1] == ' '
752
+ i -= 1 while i > 0 && @input_buffer[i - 1] != ' '
753
+ steps = @input_cursor - i
754
+ return if steps == 0
755
+ process_output("\e[#{steps}D")
756
+ @input_cursor = i
757
+ end
758
+
759
+ def word_right
760
+ return if @input_cursor >= @input_buffer.length
761
+ i = @input_cursor
762
+ i += 1 while i < @input_buffer.length && @input_buffer[i] != ' '
763
+ i += 1 while i < @input_buffer.length && @input_buffer[i] == ' '
764
+ steps = i - @input_cursor
765
+ return if steps == 0
766
+ process_output("\e[#{steps}C")
767
+ @input_cursor = i
768
+ end
769
+
770
+ def translate_for_pty(chars, flags)
771
+ mapped = SPECIAL_KEY_TO_ANSI[chars]
772
+ return mapped if mapped
773
+ if (flags & NSEVENT_CONTROL_FLAG) != 0 && chars.length == 1 && chars.ord >= 0x20
774
+ return (chars.ord & 0x1F).chr
775
+ end
776
+ chars
777
+ end
778
+
779
+ # Tab completion. If exactly one candidate matches the word at
780
+ # cursor, splice it in and add a trailing space (or `/` for dirs).
781
+ # Multiple candidates → print them inline below the prompt and
782
+ # redraw the input. Zero → no-op (silent).
783
+ #
784
+ # The data-only `completion_request` and the splice helper
785
+ # `apply_completion` are public so the GUI can intercept multi-
786
+ # candidate completions and show a native NSMenu popup instead of
787
+ # the inline list.
788
+ WORD_BREAK_CHARS = " \t\n\"'><=;|&{("
789
+
790
+ public # the completion API is reached from gui.rb (the popup)
791
+
792
+ # Pure-data: ask the embedded shell what completions are available
793
+ # at the current cursor and locate the start of the word being
794
+ # completed. Returns nil when there are no candidates. Has no
795
+ # side effects on the screen.
796
+ def completion_request
797
+ point = @input_cursor
798
+ candidates = @embedded_shell.complete_at(line: @input_buffer, point: point)
799
+ return nil if candidates.empty?
800
+ word_start = point
801
+ word_start -= 1 while word_start > 0 && !WORD_BREAK_CHARS.include?(@input_buffer[word_start - 1])
802
+ {candidates: candidates, word_start: word_start, point: point}
803
+ end
804
+
805
+ # Splice `completion` into the input buffer in place of the partial
806
+ # word that starts at `word_start`. Adds a trailing space unless
807
+ # the completion already ends with `/` (a directory). Re-renders
808
+ # via `replace_input_line` so highlighting + autosuggestion stay
809
+ # consistent.
810
+ def apply_completion(word_start:, completion:)
811
+ completion = "#{completion} " unless completion.end_with?('/')
812
+ tail = @input_buffer[@input_cursor..] || ''
813
+ new_input = @input_buffer[0...word_start] + completion + tail
814
+ new_cursor = word_start + completion.length
815
+ replace_input_line(new_input, new_cursor)
816
+ end
817
+
818
+ # Replace the in-progress input with `text` (e.g., a command
819
+ # recovered from a Cmd-clicked prompt's OSC 133 mark). Cursor lands
820
+ # at end. Cleared history-walk and autosuggestion state so the next
821
+ # ↑/↓ starts from the freshly-recalled line.
822
+ def recall_command(text)
823
+ return if text.nil? || text.empty?
824
+ @history_index = nil
825
+ @history_saved = nil
826
+ replace_input_buffer(text)
827
+ end
828
+
829
+ private
830
+
831
+ def complete_input
832
+ req = completion_request
833
+ return unless req
834
+ candidates = req[:candidates]
835
+
836
+ if candidates.size == 1
837
+ apply_completion(word_start: req[:word_start], completion: candidates.first)
838
+ else
839
+ # GUI-less fallback (and what tests exercise): print the
840
+ # candidates inline below the prompt and redraw the input.
841
+ # In the windowed app the GUI intercepts Tab before this
842
+ # branch and shows an NSMenu popup instead.
843
+ process_output("\r\n")
844
+ per_row = 4
845
+ candidates.each_with_index do |c, i|
846
+ process_output(c.ljust(20))
847
+ process_output("\r\n") if i % per_row == per_row - 1
848
+ end
849
+ process_output("\r\n") unless candidates.size % per_row == 0
850
+ render_prompt_natively
851
+ @input_cursor = @input_buffer.length
852
+ @autosuggestion = compute_autosuggestion
853
+ render_input_area
854
+ end
855
+ end
856
+
857
+ # Map of token type → SGR escape. Keywords get bold yellow; the
858
+ # control-flow operators get bright cyan; redirections get bright
859
+ # magenta. Word tokens are handled separately below — quoted ones
860
+ # render green, the first word at command position renders bold.
861
+ TOKEN_COLOR_MAP = {
862
+ IF: "\e[1;33m", THEN: "\e[1;33m", ELSE: "\e[1;33m", ELIF: "\e[1;33m",
863
+ ELSIF: "\e[1;33m", FI: "\e[1;33m", UNLESS: "\e[1;33m", WHILE: "\e[1;33m",
864
+ UNTIL: "\e[1;33m", FOR: "\e[1;33m", SELECT: "\e[1;33m", CASE: "\e[1;33m",
865
+ WHEN: "\e[1;33m", ESAC: "\e[1;33m", FUNCTION: "\e[1;33m", DEF: "\e[1;33m",
866
+ COPROC: "\e[1;33m", TIME: "\e[1;33m", LAZY_LOAD: "\e[1;33m",
867
+ PIPE: "\e[96m", PIPE_BOTH: "\e[96m", SEMICOLON: "\e[96m",
868
+ DOUBLE_SEMI: "\e[96m", AND: "\e[96m", OR: "\e[96m", AMPERSAND: "\e[96m",
869
+ REDIRECT_OUT: "\e[95m", REDIRECT_APPEND: "\e[95m", REDIRECT_IN: "\e[95m",
870
+ REDIRECT_ERR: "\e[95m", REDIRECT_CLOBBER: "\e[95m", DUP_OUT: "\e[95m",
871
+ DUP_IN: "\e[95m", HEREDOC: "\e[95m", HEREDOC_INDENT: "\e[95m",
872
+ HERESTRING: "\e[95m",
873
+ }.freeze
874
+
875
+ # Token types that put the *next* WORD into "command position" — i.e.
876
+ # this is where the user types a program name, which we render bold.
877
+ COMMAND_BOUNDARY_TYPES = %i[
878
+ SEMICOLON PIPE PIPE_BOTH AND OR AMPERSAND
879
+ IF THEN ELSE ELIF ELSIF WHILE UNTIL FOR SELECT CASE WHEN
880
+ DOUBLE_SEMI CASE_FALL CASE_CONT LBRACE LPAREN
881
+ ].freeze
882
+
883
+ # Take the user's in-progress input line and return a copy with
884
+ # ANSI SGR escapes wrapped around each token. SGRs don't advance
885
+ # the cell-grid cursor, so the surrounding cell-count math in
886
+ # `replace_input_line` is unchanged. Falls back to the plain line
887
+ # on any failure — highlighting must never lose user input.
888
+ def colorize_input(line)
889
+ return line if line.empty?
890
+ tokens = @embedded_shell.tokenize(line)
891
+ return line if tokens.empty?
892
+
893
+ out = +''
894
+ pos = 0
895
+ command_position = true
896
+ tokens.each do |tok|
897
+ val = tok.value.to_s
898
+ next if val.empty?
899
+ idx = line.index(val, pos)
900
+ break unless idx
901
+ # Whitespace skipped by the lexer copies through verbatim.
902
+ out << line[pos...idx] if idx > pos
903
+
904
+ sgr = sgr_for_token(tok, command_position)
905
+ if sgr
906
+ out << sgr << val << "\e[0m"
907
+ else
908
+ out << val
909
+ end
910
+ pos = idx + val.length
911
+
912
+ if tok.type == :WORD
913
+ command_position = false
914
+ elsif COMMAND_BOUNDARY_TYPES.include?(tok.type)
915
+ command_position = true
916
+ end
917
+ end
918
+ out << line[pos..] if pos < line.length
919
+ out
920
+ rescue
921
+ line
922
+ end
923
+
924
+ # Find the most recent history entry that has @input_buffer as a
925
+ # strict prefix; the suffix is what we render as a dim ghost-text
926
+ # autosuggestion. Empty input → no suggestion. Empty during
927
+ # multi-line continuation: history stores commands as one entry
928
+ # each, so a partial second-line buffer wouldn't match meaningfully.
929
+ def compute_autosuggestion
930
+ return '' if @input_buffer.empty?
931
+ return '' unless @continuation_lines.empty?
932
+ hist = @embedded_shell.history
933
+ return '' if hist.empty?
934
+ hist.reverse_each do |entry|
935
+ # Multi-line history entries (saved from continuation submissions
936
+ # as one big string with embedded "\n") can't render as inline
937
+ # ghost text — skip them.
938
+ next if entry.include?("\n")
939
+ if entry.start_with?(@input_buffer) && entry.length > @input_buffer.length
940
+ return entry[@input_buffer.length..]
941
+ end
942
+ end
943
+ ''
944
+ rescue
945
+ ''
946
+ end
947
+
948
+ def at_end_with_suggestion?
949
+ @input_cursor >= @input_buffer.length && !@autosuggestion.empty?
950
+ end
951
+
952
+ # Accept the entire pending suggestion: splice it in at the cursor.
953
+ # `insert_at_cursor` routes through `replace_input_line`, which
954
+ # recomputes the now-empty suggestion against the new buffer.
955
+ def accept_full_autosuggestion
956
+ return if @autosuggestion.empty?
957
+ insert_at_cursor(@autosuggestion)
958
+ end
959
+
960
+ # Accept just the next word (run of whitespace + run of non-whitespace)
961
+ # from the pending suggestion. Mirrors fish's accept-autosuggestion-word.
962
+ def accept_word_of_autosuggestion
963
+ return if @autosuggestion.empty?
964
+ s = @autosuggestion
965
+ i = 0
966
+ i += 1 while i < s.length && s[i] == ' '
967
+ i += 1 while i < s.length && s[i] != ' '
968
+ insert_at_cursor(s[0, i])
969
+ end
970
+
971
+ # ---- Ctrl-R reverse-incremental history search.
972
+ #
973
+ # While in :search mode, the current line on screen is replaced with
974
+ # (reverse-i-search)`query': matched-history-line
975
+ # Typed chars narrow the query; Ctrl-R jumps to the next older match;
976
+ # Enter accepts the match and submits it; Esc / Ctrl-G cancels and
977
+ # restores the original input. Other keys (arrows, etc.) cancel the
978
+ # search and re-process the keystroke in :prompt mode — so e.g. ↑
979
+ # exits search and walks the regular history.
980
+
981
+ def enter_search
982
+ @input_mode = :search
983
+ @search_query = +''
984
+ @search_index = nil
985
+ @search_saved_buffer = @input_buffer.dup
986
+ @search_saved_cursor = @input_cursor
987
+ @search_saved_autosuggestion = @autosuggestion.dup
988
+ render_search
989
+ end
990
+
991
+ def handle_search_key(chars, flags)
992
+ ctrl = (flags & NSEVENT_CONTROL_FLAG) != 0
993
+ if ctrl && chars.length == 1 && chars.ord >= 0x20
994
+ case chars.downcase
995
+ when 'r' then search_step_back; return true
996
+ when 'g' then exit_search(:cancel); return true
997
+ when 'h' then search_backspace; return true
998
+ end
999
+ end
1000
+
1001
+ case chars
1002
+ when "\e"
1003
+ exit_search(:cancel)
1004
+ when "\r", "\n"
1005
+ exit_search(:accept_and_submit)
1006
+ when "\u{7F}", "\b"
1007
+ search_backspace
1008
+ else
1009
+ ord = chars.length == 1 ? chars.ord : nil
1010
+ # Accept printable input: ASCII printable plus normal Unicode.
1011
+ # Exclude the macOS NSEvent special-key range (U+F700–F7FF) —
1012
+ # those are arrows / Home / End / Delete and should fall through
1013
+ # to the cancel-and-reprocess path so the user lands back in
1014
+ # prompt mode and the keystroke runs there.
1015
+ if !ctrl && ord && ord >= 0x20 && (ord < 0xF700 || ord > 0xF7FF)
1016
+ @search_query << chars
1017
+ restart_search_from_end
1018
+ render_search
1019
+ else
1020
+ exit_search(:cancel)
1021
+ return handle_key(chars: chars, flags: flags)
1022
+ end
1023
+ end
1024
+ true
1025
+ end
1026
+
1027
+ def search_backspace
1028
+ return if @search_query.empty?
1029
+ @search_query.chop!
1030
+ restart_search_from_end
1031
+ render_search
1032
+ end
1033
+
1034
+ # After the query changes, find the newest history entry that
1035
+ # contains the new query. nil index means no match.
1036
+ def restart_search_from_end
1037
+ hist = @embedded_shell.history
1038
+ @search_index = nil
1039
+ return if @search_query.empty?
1040
+ (hist.size - 1).downto(0) do |i|
1041
+ entry = hist[i]
1042
+ next if entry.nil? || entry.include?("\n")
1043
+ if entry.include?(@search_query)
1044
+ @search_index = i
1045
+ return
1046
+ end
1047
+ end
1048
+ end
1049
+
1050
+ # Ctrl-R while already in search: jump to the next older match for
1051
+ # the current query. If none, leave the index alone.
1052
+ def search_step_back
1053
+ hist = @embedded_shell.history
1054
+ return if @search_query.empty?
1055
+ start_idx = (@search_index || hist.size) - 1
1056
+ start_idx.downto(0) do |i|
1057
+ entry = hist[i]
1058
+ next if entry.nil? || entry.include?("\n")
1059
+ if entry.include?(@search_query)
1060
+ @search_index = i
1061
+ render_search
1062
+ return
1063
+ end
1064
+ end
1065
+ end
1066
+
1067
+ def render_search
1068
+ hist = @embedded_shell.history
1069
+ matched = (@search_index ? hist[@search_index] : nil) || ''
1070
+ display = "(reverse-i-search)`#{@search_query}': #{matched}"
1071
+ process_output("\r\e[K")
1072
+ process_output(display)
1073
+ end
1074
+
1075
+ def exit_search(action)
1076
+ hist = @embedded_shell.history
1077
+ matched = @search_index ? hist[@search_index] : nil
1078
+
1079
+ case action
1080
+ when :accept_and_submit
1081
+ accepted = matched || @search_saved_buffer || ''
1082
+ @input_buffer = +accepted
1083
+ @input_cursor = @input_buffer.length
1084
+ @autosuggestion = +''
1085
+ @input_mode = :prompt
1086
+ @search_query = +''
1087
+ @search_index = nil
1088
+ @search_saved_buffer = nil
1089
+ @search_saved_cursor = nil
1090
+ @search_saved_autosuggestion = nil
1091
+ process_output("\r\e[K")
1092
+ render_prompt_natively
1093
+ render_input_area
1094
+ submit_or_continue
1095
+ else # :cancel
1096
+ @input_buffer = @search_saved_buffer || +''
1097
+ @input_cursor = @search_saved_cursor || 0
1098
+ @autosuggestion = @search_saved_autosuggestion || +''
1099
+ @input_mode = :prompt
1100
+ @search_query = +''
1101
+ @search_index = nil
1102
+ @search_saved_buffer = nil
1103
+ @search_saved_cursor = nil
1104
+ @search_saved_autosuggestion = nil
1105
+ process_output("\r\e[K")
1106
+ render_prompt_natively
1107
+ render_input_area
1108
+ end
1109
+ end
1110
+
1111
+ def sgr_for_token(tok, command_position)
1112
+ if tok.type == :WORD
1113
+ first = tok.value.to_s[0]
1114
+ return "\e[32m" if first == '"' || first == "'"
1115
+ return "\e[1m" if command_position
1116
+ nil
1117
+ else
1118
+ TOKEN_COLOR_MAP[tok.type]
1119
+ end
1120
+ end
1121
+ end
1122
+ end