echoes 0.2.0 → 0.3.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.
data/lib/echoes/pane.rb CHANGED
@@ -17,7 +17,7 @@ module Echoes
17
17
  :scroll_offset, :scroll_accum, :title, :copy_mode
18
18
  attr_reader :embedded_shell
19
19
 
20
- def initialize(command:, rows:, cols:, cwd: nil, embedded: false, no_rc: false, editor_file: nil)
20
+ def initialize(command:, rows:, cols:, cwd: nil, embedded: false, no_rc: false, editor_file: nil, env: nil)
21
21
  @screen = Screen.new(rows: rows, cols: cols)
22
22
  if editor_file
23
23
  require_relative 'editor'
@@ -27,7 +27,13 @@ module Echoes
27
27
  elsif embedded
28
28
  require_relative 'embedded_shell'
29
29
  @embedded_shell = EmbeddedShell.new(no_rc: no_rc)
30
- @parser = Parser.new(@screen, writer: ->(_s) { })
30
+ # Writer routes OSC replies (display-info, OSC 52 paste-back,
31
+ # color queries, terminfo replies, …) to the helper's pty
32
+ # master so the foreground program reads them on its stdin.
33
+ # While rubish is at the prompt, anything we write here lands
34
+ # in Reline; in practice query OSCs only come from a running
35
+ # foreground program (e.g. przn) so the routing is safe.
36
+ @parser = Parser.new(@screen, writer: ->(s) { @embedded_shell.forward_input(s) })
31
37
  @title = 'rubish'
32
38
  @input_buffer = +''
33
39
  @input_cursor = 0 # offset within @input_buffer (0..length)
@@ -47,14 +53,27 @@ module Echoes
47
53
  else
48
54
  start_dir = (cwd && Dir.exist?(cwd)) ? cwd : Dir.home
49
55
  Dir.chdir(start_dir) do
56
+ # When env: is nil (the default), we just normalize a few
57
+ # vars on our own process — the child then inherits the
58
+ # whole env from us, same as before. When env: is given,
59
+ # it's an explicit env Hash that fully replaces what the
60
+ # child sees, with our normalizations applied on top. The
61
+ # explicit form is used by the OSC 7772 ;open-window path
62
+ # so a child program (e.g. przn) reliably gets PATH /
63
+ # HOME / USER / LANG even when Echoes.app was launched by
64
+ # launchd with a minimal env.
50
65
  ENV['TERM'] = Echoes.config.term
51
66
  ENV['LANG'] ||= 'en_US.UTF-8'
52
67
  ENV['LC_CTYPE'] = 'UTF-8'
53
- @pty_read, @pty_write, @pty_pid = PTY.spawn(command)
54
- @pty_read.winsize = [rows, cols]
68
+ # `command` may be a String (shell-parsed by /bin/sh) or an
69
+ # Array of [argv0, *args] (execve directly, no shell). The
70
+ # array form is what the OSC 7772 ;open-window handler
71
+ # uses so user-supplied argv isn't subject to shell quoting.
72
+ spawn_args = command.is_a?(Array) ? command : [command]
73
+ @pty_read, @pty_write, @pty_pid = spawn_with_pty(spawn_args, env, rows, cols)
55
74
  end
56
75
  @parser = Parser.new(@screen, writer: ->(s) { @pty_write.write(s) rescue nil })
57
- @title = File.basename(command)
76
+ @title = File.basename(command.is_a?(Array) ? command.first : command)
58
77
  end
59
78
  @scroll_offset = 0
60
79
  @scroll_accum = 0.0
@@ -89,7 +108,10 @@ module Echoes
89
108
  # embedded mode hands the line directly to the in-process REPL.
90
109
  def submit_line(line)
91
110
  if embedded?
92
- @embedded_shell.submit_line(line)
111
+ @embedded_shell.submit_line(line,
112
+ rows: @screen.rows, cols: @screen.cols,
113
+ px_width: pty_pixel_width(@screen.cols),
114
+ px_height: pty_pixel_height(@screen.rows))
93
115
  else
94
116
  @pty_write.write("#{line}\r") rescue nil
95
117
  end
@@ -145,9 +167,30 @@ module Echoes
145
167
  @editor.resize(rows: rows, cols: cols)
146
168
  render_editor
147
169
  elsif embedded?
148
- @embedded_shell.resize(rows: rows, cols: cols)
170
+ @embedded_shell.resize(rows: rows, cols: cols,
171
+ px_width: pty_pixel_width(cols),
172
+ px_height: pty_pixel_height(rows))
149
173
  else
150
- @pty_read.winsize = [rows, cols]
174
+ @pty_read.winsize = pty_winsize_quad(rows, cols)
175
+ end
176
+ rescue Errno::EIO, IOError
177
+ end
178
+
179
+ # Re-send winsize with the current cell pixel metrics. Called
180
+ # by the GUI after `wire_screen_handlers` updates the Screen's
181
+ # cell_pixel_width / cell_pixel_height (font load, font size
182
+ # change, …), so TIOCGWINSZ on the slave side carries the
183
+ # right pixel dims — kitten icat and other image protocols
184
+ # read those instead of querying CSI 14 t.
185
+ def refresh_pty_pixel_size
186
+ rows = @screen.rows
187
+ cols = @screen.cols
188
+ if embedded?
189
+ @embedded_shell.resize(rows: rows, cols: cols,
190
+ px_width: pty_pixel_width(cols),
191
+ px_height: pty_pixel_height(rows))
192
+ elsif @pty_read && !@pty_read.closed?
193
+ @pty_read.winsize = pty_winsize_quad(rows, cols)
151
194
  end
152
195
  rescue Errno::EIO, IOError
153
196
  end
@@ -484,7 +527,9 @@ module Echoes
484
527
  # Stash the command text on the OSC 133 mark so click-to-rerun
485
528
  # can recover it later.
486
529
  @screen.set_current_command_text(line)
487
- @embedded_shell.submit_line(line, rows: @screen.rows, cols: @screen.cols)
530
+ @embedded_shell.submit_line(line, rows: @screen.rows, cols: @screen.cols,
531
+ px_width: pty_pixel_width(@screen.cols),
532
+ px_height: pty_pixel_height(@screen.rows))
488
533
  @embedded_running = true
489
534
  end
490
535
  end
@@ -828,6 +873,60 @@ module Echoes
828
873
 
829
874
  private
830
875
 
876
+ # macOS ioctl numbers used by the manual pty setup below.
877
+ # PTY.spawn does setsid + TIOCSCTTY, but skips tcsetpgrp —
878
+ # leaving the slave's foreground process group unset (the
879
+ # macOS kernel doesn't fill it in automatically the way Linux
880
+ # does on TIOCSCTTY). The user's shell rc files then run
881
+ # things like `stty -ixon`, zsh fork+setpgid's stty into its
882
+ # own group for job control, that group has no parent in
883
+ # the slave's session, and tcsetattr returns
884
+ # stty: tcsetattr: Input/output error
885
+ # because the calling group is "orphaned and not foreground".
886
+ # Pre-seeding the foreground pgrp to the shell's pid in the
887
+ # child — same way embedded_shell_helper does — makes the
888
+ # whole startup path tcsetattr-safe.
889
+ DARWIN_TIOCSCTTY = 0x20007461
890
+ DARWIN_TIOCSPGRP = 0x80047476
891
+
892
+ def spawn_with_pty(spawn_args, env, rows, cols)
893
+ master, slave = PTY.open
894
+ slave.winsize = pty_winsize_quad(rows, cols)
895
+ pid = fork do
896
+ master.close
897
+ Process.setsid rescue nil
898
+ slave.ioctl(DARWIN_TIOCSCTTY, 0) rescue nil
899
+ slave.ioctl(DARWIN_TIOCSPGRP, [Process.getpgrp].pack('i!')) rescue nil
900
+ STDIN.reopen(slave)
901
+ STDOUT.reopen(slave)
902
+ STDERR.reopen(slave)
903
+ slave.close rescue nil
904
+ if env
905
+ exec(env, *spawn_args)
906
+ else
907
+ exec(*spawn_args)
908
+ end
909
+ end
910
+ slave.close
911
+ [master, master, pid]
912
+ end
913
+
914
+ # 4-element winsize tuple [rows, cols, xpixel, ypixel] for
915
+ # TIOCSWINSZ. The pixel fields seed the slave's TIOCGWINSZ so
916
+ # processes that prefer ioctl over CSI 14 t (kitten icat,
917
+ # tput, …) see real screen pixel dims.
918
+ def pty_winsize_quad(rows, cols)
919
+ [rows, cols, pty_pixel_width(cols), pty_pixel_height(rows)]
920
+ end
921
+
922
+ def pty_pixel_width(cols)
923
+ (cols * @screen.cell_pixel_width).to_i
924
+ end
925
+
926
+ def pty_pixel_height(rows)
927
+ (rows * @screen.cell_pixel_height).to_i
928
+ end
929
+
831
930
  def complete_input
832
931
  req = completion_request
833
932
  return unless req
data/lib/echoes/parser.rb CHANGED
@@ -30,8 +30,12 @@ module Echoes
30
30
 
31
31
  REPLACEMENT_CHAR = "\u{FFFD}"
32
32
  CSI_PARAM_LIMIT = 32
33
- OSC_BUFFER_LIMIT = 4096
33
+ # 16 MB to accommodate OSC 1337 ;File= base64-encoded image
34
+ # payloads (iTerm2 inline images). Non-image OSCs are short
35
+ # and don't notice the higher cap.
36
+ OSC_BUFFER_LIMIT = 16 * 1024 * 1024
34
37
  DCS_BUFFER_LIMIT = 1024 * 1024 # 1 MB (sixel images can be large)
38
+ APC_BUFFER_LIMIT = 16 * 1024 * 1024 # 16 MB (kitty graphics can be big)
35
39
 
36
40
  def process_byte(byte)
37
41
  # UTF-8 continuation bytes
@@ -72,6 +76,8 @@ module Echoes
72
76
  csi_param(byte)
73
77
  when :osc_string
74
78
  osc_string(byte)
79
+ when :apc_string
80
+ apc_string(byte)
75
81
  when :dcs_entry
76
82
  dcs_entry(byte)
77
83
  when :dcs_param
@@ -137,6 +143,9 @@ module Echoes
137
143
  when 0x5D # ]
138
144
  @state = :osc_string
139
145
  @osc_string = "".b
146
+ when 0x5F # _ (APC — Application Program Command)
147
+ @state = :apc_string
148
+ @apc_string = "".b
140
149
  when 0x37 # 7
141
150
  @screen.save_cursor
142
151
  @state = :ground
@@ -289,6 +298,27 @@ module Echoes
289
298
  end
290
299
  end
291
300
 
301
+ # APC (Application Program Command) accumulator. Mirrors the
302
+ # OSC state shape: bytes accumulate until BEL or ST (`ESC \`),
303
+ # then `dispatch_apc` routes by namespace prefix. Currently
304
+ # only the kitty graphics protocol (`G…`) lives in here.
305
+ def apc_string(byte)
306
+ case byte
307
+ when 0x07 # BEL terminates APC
308
+ dispatch_apc
309
+ @state = :ground
310
+ when 0x1B # ESC — dispatch, enter escape state for ST (\)
311
+ dispatch_apc
312
+ @state = :escape
313
+ else
314
+ if @apc_string.bytesize < APC_BUFFER_LIMIT
315
+ @apc_string << byte
316
+ else
317
+ @state = :ground
318
+ end
319
+ end
320
+ end
321
+
292
322
  def dcs_entry(byte)
293
323
  case byte
294
324
  when 0x30..0x39
@@ -380,6 +410,23 @@ module Echoes
380
410
  end
381
411
  end
382
412
 
413
+ # APC body shape: G<comma-separated-options>;<base64-payload>
414
+ # Anything not starting with `G` is some other private APC
415
+ # subprotocol we don't handle; ignore silently.
416
+ def dispatch_apc
417
+ body = @apc_string
418
+ return if body.empty?
419
+ return unless body.getbyte(0) == 0x47 # 'G'
420
+ meta_and_payload = body.byteslice(1..) || ''.b
421
+ meta, payload = meta_and_payload.split(';'.b, 2)
422
+ meta = (meta || '').force_encoding('UTF-8')
423
+ payload = (payload || ''.b)
424
+ require_relative 'kitty_graphics'
425
+ @kitty_state ||= {chunks: {}, cache: {}}
426
+ KittyGraphics.handle_chunk(@kitty_state, meta, payload,
427
+ screen: @screen, writer: @writer)
428
+ end
429
+
383
430
  def dispatch_osc
384
431
  code, rest = @osc_string.split(';'.b, 2)
385
432
  return unless rest
@@ -413,12 +460,25 @@ module Echoes
413
460
  when '52'
414
461
  dispatch_osc52(rest)
415
462
  return
463
+ when '9'
464
+ # OSC 9 (iTerm2): everything after `9;` is the message body.
465
+ # Use the pane title as the notification title; the host
466
+ # decides how to render it.
467
+ deliver_notification(nil, rest)
468
+ return
469
+ when '777'
470
+ dispatch_osc777(rest)
471
+ return
416
472
  when '133'
417
473
  dispatch_osc133(rest)
418
474
  return
419
475
  when '7772'
420
476
  dispatch_osc7772(rest)
421
477
  return
478
+ when '1337'
479
+ require_relative 'iterm2_images'
480
+ Iterm2Images.handle(rest, screen: @screen, writer: @writer)
481
+ return
422
482
  when '66'
423
483
  # fall through to multicell handling below
424
484
  else
@@ -428,12 +488,20 @@ module Echoes
428
488
  return unless text
429
489
 
430
490
  text.force_encoding('UTF-8')
431
- # `f=` is an Echoes extension that other terminals ignore.
432
- # Family names containing `:` aren't representable here because
433
- # `:` is the meta-field separator — use `,` or omit the colon
434
- # in the family name (e.g. "Helvetica Neue", not "Foo:Italic").
435
- params = {scale: 1, width: 0, frac_n: 0, frac_d: 0, valign: 0, halign: 0, family: nil}
436
- meta_str.split(':').each do |pair|
491
+ @screen.put_multicell(text, **parse_multicell_meta(meta_str, allow_extensions: false))
492
+ end
493
+
494
+ # Parse the `key=value:key=value:...` meta block that lives in
495
+ # the second field of OSC 66 (and of OSC 7772 ;multicell).
496
+ # `allow_extensions: false` accepts only the kitty spec keys
497
+ # (s/w/n/d/v/h) so OSC 66 stays strictly compatible with other
498
+ # terminals — Echoes-private knobs (f=family, flip=h|v|hv) are
499
+ # routed through OSC 7772 ;multicell, where collisions with a
500
+ # future kitty spec extension can't surprise emitters.
501
+ def parse_multicell_meta(meta_str, allow_extensions:)
502
+ params = {scale: 1, width: 0, frac_n: 0, frac_d: 0, valign: 0, halign: 0,
503
+ family: nil, flip_h: false, flip_v: false}
504
+ meta_str.to_s.split(':').each do |pair|
437
505
  k, v = pair.split('=', 2)
438
506
  next unless v
439
507
  case k
@@ -443,11 +511,24 @@ module Echoes
443
511
  when 'd' then params[:frac_d] = v.to_i.clamp(0, 15)
444
512
  when 'v' then params[:valign] = v.to_i.clamp(0, 2)
445
513
  when 'h' then params[:halign] = v.to_i.clamp(0, 2)
446
- when 'f' then params[:family] = v unless v.empty?
514
+ when 'f'
515
+ # Family name. Names with ':' aren't representable here
516
+ # because ':' is the meta-field separator — use ',' or
517
+ # omit the colon (e.g. "Helvetica Neue", not "Foo:Italic").
518
+ params[:family] = v if allow_extensions && !v.empty?
519
+ when 'flip'
520
+ # Mirror the rendered glyph(s). `flip=h` horizontal,
521
+ # `flip=v` vertical, `flip=hv` / `flip=vh` both. Handy
522
+ # for direction-having emojis (e.g. flipping 🐇 to face
523
+ # the other way).
524
+ if allow_extensions
525
+ flip = v.downcase
526
+ params[:flip_h] = flip.include?('h')
527
+ params[:flip_v] = flip.include?('v')
528
+ end
447
529
  end
448
530
  end
449
-
450
- @screen.put_multicell(text, **params)
531
+ params
451
532
  end
452
533
 
453
534
  def dispatch_osc_default_color(key, osc_code, spec)
@@ -521,9 +602,26 @@ module Echoes
521
602
  # bg-gradient ; type=linear:angle=N:colors=#rrggbb,#rrggbb[,...]
522
603
  # bg-fill ; color=#rrggbb:rect=row1,col1,row2,col2
523
604
  # bg-clear (revert to default_bg)
605
+ # capture ; <absolute-path-to.png>
606
+ # display-info (sync query — host writes
607
+ # \e]7772;display-info;<json>\a
608
+ # back to the pty)
609
+ # open-window ; display=N:program=<base64-argv>:fullscreen=yes|no
610
+ # multicell ; <params> ; <text> (OSC-66-shaped multicell + the
611
+ # Echoes-only knobs `f=family`
612
+ # and `flip=h|v|hv`. Use this
613
+ # instead of putting extensions
614
+ # on OSC 66 so portable emitters
615
+ # can keep OSC 66 strictly
616
+ # kitty-compatible.)
524
617
  def dispatch_osc7772(rest)
525
618
  command, args = rest.split(';', 2)
526
619
  case command
620
+ when 'multicell'
621
+ meta_str, text = (args || '').split(';', 2)
622
+ return unless text
623
+ text.force_encoding('UTF-8')
624
+ @screen.put_multicell(text, **parse_multicell_meta(meta_str, allow_extensions: true))
527
625
  when 'bg-color'
528
626
  rgba = parse_hex_color((args || '').strip)
529
627
  if rgba
@@ -546,6 +644,20 @@ module Echoes
546
644
  @screen.background = nil
547
645
  @screen.bg_fills.clear if @screen.respond_to?(:bg_fills) && @screen.bg_fills
548
646
  @screen.mark_all_dirty if @screen.respond_to?(:mark_all_dirty)
647
+ when 'capture'
648
+ path = (args || '').strip
649
+ if !path.empty? && @screen.respond_to?(:capture_handler) && @screen.capture_handler
650
+ @screen.capture_handler.call(path)
651
+ end
652
+ when 'display-info'
653
+ if @screen.respond_to?(:display_info_handler) && @screen.display_info_handler
654
+ json = @screen.display_info_handler.call.to_s
655
+ @writer.call("\e]7772;display-info;#{json}\a") if @writer
656
+ end
657
+ when 'open-window'
658
+ if @screen.respond_to?(:open_window_handler) && @screen.open_window_handler
659
+ @screen.open_window_handler.call(args || '')
660
+ end
549
661
  end
550
662
  end
551
663
 
@@ -611,6 +723,27 @@ module Echoes
611
723
  end
612
724
  end
613
725
 
726
+ # OSC 777 — VTE-style notifications:
727
+ # \e]777;notify;<title>;<message>\a
728
+ # Some emitters omit the body, sending only a title.
729
+ def dispatch_osc777(rest)
730
+ parts = rest.split(';', 3)
731
+ return unless parts[0] == 'notify'
732
+ title = parts[1] || ''
733
+ message = parts[2] || ''
734
+ deliver_notification(title.empty? ? nil : title, message)
735
+ end
736
+
737
+ # Hand off to whatever the host wired into `screen.notification_handler`.
738
+ # Title is nil when the emitter (OSC 9) didn't supply one — the
739
+ # host typically falls back to the pane title or "Echoes". A
740
+ # missing handler is a no-op so the parser never raises on
741
+ # non-GUI tests.
742
+ def deliver_notification(title, message)
743
+ return unless @screen.respond_to?(:notification_handler) && @screen.notification_handler
744
+ @screen.notification_handler.call(title, message)
745
+ end
746
+
614
747
  def dispatch_osc133(rest)
615
748
  return unless @screen.respond_to?(:osc133_mark)
616
749
  parts = rest.split(';')
@@ -712,6 +845,7 @@ module Echoes
712
845
  when 1006 then @screen.mouse_encoding = :sgr
713
846
  when 1004 then @screen.focus_reporting = true
714
847
  when 2004 then @screen.bracketed_paste_mode = true
848
+ when 2026 then @screen.sync_active = true
715
849
  when 1049
716
850
  @screen.save_cursor
717
851
  @screen.switch_to_alt_screen
@@ -741,6 +875,7 @@ module Echoes
741
875
  when 1006 then @screen.mouse_encoding = :default
742
876
  when 1004 then @screen.focus_reporting = false
743
877
  when 2004 then @screen.bracketed_paste_mode = false
878
+ when 2026 then @screen.sync_active = false
744
879
  when 1049
745
880
  @screen.switch_to_main_screen
746
881
  @screen.restore_cursor
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Echoes
4
+ # A named color theme — foreground / background / cursor / selection
5
+ # plus the 16-entry ANSI palette that drives the 256-color table.
6
+ # Defined in `~/.config/echoes/echoes.conf` via the `profile` DSL:
7
+ #
8
+ # profile "Solarized Dark" do
9
+ # foreground "#93a1a1"
10
+ # background "#002b36"
11
+ # cursor_color "#93a1a1", 0.7
12
+ # selection_color "#586e75"
13
+ # color_palette %w[#073642 #dc322f #859900 #b58900 #268bd2
14
+ # #d33682 #2aa198 #eee8d5 #002b36 #cb4b16
15
+ # #586e75 #657b83 #839496 #6c71c4 #93a1a1 #fdf6e3]
16
+ # end
17
+ #
18
+ # Attributes left unset inherit from `Echoes.config` so a profile
19
+ # can be a partial override.
20
+ class Profile
21
+ attr_reader :name
22
+
23
+ def initialize(name)
24
+ @name = name.to_s
25
+ @foreground = nil
26
+ @background = nil
27
+ @cursor_color = nil
28
+ @selection_color = nil
29
+ @color_palette = nil
30
+ end
31
+
32
+ def foreground(*args)
33
+ args.empty? ? (@foreground || Echoes.config.foreground) : @foreground = parse_color(args)
34
+ end
35
+
36
+ def background(*args)
37
+ args.empty? ? (@background || Echoes.config.background) : @background = parse_color(args)
38
+ end
39
+
40
+ def cursor_color(*args)
41
+ args.empty? ? (@cursor_color || Echoes.config.cursor_color) : @cursor_color = parse_color(args)
42
+ end
43
+
44
+ def selection_color(*args)
45
+ args.empty? ? (@selection_color || Echoes.config.selection_color) : @selection_color = parse_color(args)
46
+ end
47
+
48
+ def color_palette(val = nil)
49
+ if val
50
+ @color_palette = val.map { |c| c.is_a?(String) ? parse_color([c]) : c.map(&:to_f) }
51
+ else
52
+ @color_palette || Echoes.config.color_palette
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def parse_color(args)
59
+ if args.size == 1 && args[0].is_a?(String)
60
+ hex = args[0].delete_prefix('#')
61
+ r = hex[0, 2].to_i(16) / 255.0
62
+ g = hex[2, 2].to_i(16) / 255.0
63
+ b = hex[4, 2].to_i(16) / 255.0
64
+ if hex.size == 8
65
+ a = hex[6, 2].to_i(16) / 255.0
66
+ [r, g, b, a]
67
+ else
68
+ [r, g, b]
69
+ end
70
+ else
71
+ args.map(&:to_f)
72
+ end
73
+ end
74
+ end
75
+ end