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,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Per-pane subprocess that owns the pty as its controlling tty and
4
+ # runs Rubish::REPL on behalf of the parent Echoes process. By living
5
+ # in its own session and claiming the slave as ctty (TIOCSCTTY),
6
+ # every child rubish forks for an external command inherits the
7
+ # session/ctty — so ETX (Ctrl-C) the parent writes to the master
8
+ # becomes a SIGINT delivered by the line discipline to the foreground
9
+ # process group. Loops/pipelines now work because the session leader
10
+ # (this helper) stays alive across multiple forks.
11
+ #
12
+ # Communication with the parent:
13
+ # - stdin / stdout / stderr (fds 0/1/2) = pty slave (= controlling tty).
14
+ # Rubish writes its prompts and command output to fd 1; children
15
+ # read from fd 0.
16
+ # - fd 3 = control_in: JSON-line messages from the parent.
17
+ # - fd 4 = control_out: JSON-line responses + async events to parent.
18
+ #
19
+ # Wire format: one JSON object per line. Requests carry a string id;
20
+ # responses echo it. Async events have no id and use a "event" key.
21
+
22
+ require 'json'
23
+ require 'fiddle/import'
24
+ require 'rubish'
25
+ require 'rubish/runtime/command'
26
+ require 'reline'
27
+
28
+ module Echoes
29
+ module HelperLibc
30
+ extend Fiddle::Importer
31
+ dlload Fiddle::Handle::DEFAULT
32
+ extern 'int tcsetpgrp(int, int)'
33
+ end
34
+
35
+ class EmbeddedShellHelper
36
+ DARWIN_TIOCSCTTY = 0x20007461
37
+
38
+ def initialize
39
+ Process.setsid rescue nil
40
+ STDIN.ioctl(DARWIN_TIOCSCTTY, 0) rescue nil
41
+ # After claiming ctty, explicitly set the slave's foreground
42
+ # process group to ours. Without this, the line discipline has
43
+ # nowhere to deliver SIGINT (the kernel doesn't do it automatically
44
+ # on macOS via TIOCSCTTY) and Ctrl-C is silently lost.
45
+ HelperLibc.tcsetpgrp(0, Process.getpgrp) rescue nil
46
+ # The helper and any rubish-forked child share the helper's
47
+ # process group (= slave's foreground pgrp once we claim ctty).
48
+ # ETX → SIGINT is delivered to that whole group. We want:
49
+ # - the forked external command to terminate (its default
50
+ # handler does this after exec)
51
+ # - the helper *process* to survive (so the pane keeps running)
52
+ # - the rubish *command thread* to abort (so a for-loop's body
53
+ # stops iterating instead of just dropping the current sleep
54
+ # and starting the next one).
55
+ # A Ruby trap-with-block survives across exec as SIG_DFL (kernel
56
+ # only preserves SIG_IGN), so the exec'd binary still gets the
57
+ # default-terminate behavior. The block runs on the helper main
58
+ # thread; from there we raise Interrupt on the command thread.
59
+ Signal.trap('INT') { @command_thread&.raise(Interrupt) rescue nil }
60
+ Signal.trap('QUIT') { @command_thread&.raise(Interrupt) rescue nil }
61
+ no_rc = ENV['ECHOES_HELPER_NO_RC'] == '1'
62
+ # login_shell: true so rubish sources /etc/profile (which runs
63
+ # path_helper, populating PATH from /etc/paths and /etc/paths.d
64
+ # — including /usr/local/bin and the macOS cryptex paths). The
65
+ # embedded rubish IS the only shell in the pane, so treating it
66
+ # as a login shell is correct, and matches how Ghostty (and any
67
+ # other terminal) launches the user's $SHELL.
68
+ @repl = Rubish::REPL.new(no_rc: no_rc, login_shell: true)
69
+ # Rubish normally calls these from its `run` loop, which we
70
+ # bypass — the line editor and prompt rendering live in echoes.
71
+ # Drive them explicitly so ~/.rubishrc et al take effect,
72
+ # default aliases land in the env, and history is restored.
73
+ # Errors land on stderr (= the pty, visible in the pane) so
74
+ # silent failures during startup don't disappear into the void.
75
+ run_init_step(:setup_default_aliases)
76
+ run_init_step(:load_config)
77
+ run_init_step(:load_history) unless no_rc
78
+ @control_in = IO.for_fd(3, 'r')
79
+ @control_out = IO.for_fd(4, 'w')
80
+ @control_out.sync = true
81
+ @write_lock = Mutex.new
82
+ @command_thread = nil
83
+ # OSC 7 announces the working directory to the host so things
84
+ # like new-tab-inherits-cwd and the window title pick it up via
85
+ # the standard `screen.current_directory` path. Emit once at
86
+ # startup; thereafter handle_execute re-emits after each command
87
+ # in case it changed cwd (cd, pushd/popd, …).
88
+ emit_osc7(Dir.pwd)
89
+ end
90
+
91
+ def run
92
+ while (line = @control_in.gets)
93
+ msg = (JSON.parse(line) rescue nil)
94
+ next unless msg
95
+ dispatch(msg)
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def dispatch(msg)
102
+ op = msg['op']
103
+ case op
104
+ when 'execute' then handle_execute(msg)
105
+ when 'complete' then reply(msg, @repl.complete_at(line: msg['line'], point: msg['point']))
106
+ when 'prompt' then reply(msg, @repl.prompt)
107
+ when 'prompt_segments' then reply(msg, segments_to_array(@repl.prompt_segments))
108
+ when 'right_prompt_segments' then reply(msg, segments_to_array(@repl.right_prompt_segments))
109
+ when 'continuation_prompt' then reply(msg, @repl.send(:continuation_prompt))
110
+ when 'try_parse' then reply(msg, @repl.try_parse(msg['line']).to_s)
111
+ when 'tokenize' then reply(msg, tokens_for(msg['line']))
112
+ when 'history' then reply(msg, Reline::HISTORY.to_a)
113
+ when 'last_status' then reply(msg, @repl.instance_variable_get(:@last_status) || 0)
114
+ when 'cwd' then reply(msg, Dir.pwd)
115
+ when 'resize' then handle_resize(msg)
116
+ when 'shutdown' then exit 0
117
+ end
118
+ end
119
+
120
+ # OSC 7771 used as an end-of-command sentinel so the host can be
121
+ # certain it has drained all of this command's pty output before
122
+ # acting on the command_done event. (control_out and the pty are
123
+ # separate channels with no inherent ordering.) The host's pty
124
+ # reader scans for this sequence and strips it.
125
+ DONE_SENTINEL = "\e]7771\a"
126
+
127
+ # Sentinel for the catch(:exit) wrapper around `@repl.execute`.
128
+ # Distinguishes "rubish's exit builtin asked us to terminate" from
129
+ # "command finished normally". A unique frozen symbol is enough —
130
+ # the exit builtin only throws integer status codes.
131
+ NO_EXIT_THROWN = :__rubish_helper_no_exit__
132
+
133
+ def handle_execute(msg)
134
+ line = msg['line'].to_s
135
+ Reline::HISTORY << line unless line.empty? || line.strip.empty?
136
+
137
+ @command_thread = Thread.new do
138
+ interrupted = false
139
+ exit_code = nil
140
+ begin
141
+ # Rubish's `exit` builtin signals shutdown via `throw :exit,
142
+ # code`. The normal `Rubish::REPL#run` loop catches that, but
143
+ # we bypass `run` here, so we have to catch it ourselves —
144
+ # otherwise the throw escapes as UncaughtThrowError, gets
145
+ # logged as a rubish error, and the helper keeps prompting.
146
+ result = catch(:exit) do
147
+ @repl.send(:execute, line)
148
+ STDOUT.flush rescue nil
149
+ STDERR.flush rescue nil
150
+ NO_EXIT_THROWN
151
+ end
152
+ exit_code = result.to_i unless result == NO_EXIT_THROWN
153
+ rescue Interrupt
154
+ interrupted = true
155
+ # SIGINT propagated by the trap; let the rest of the
156
+ # rubish runtime unwind, then fall through to command_done.
157
+ STDOUT.write "\r\n" rescue nil
158
+ STDOUT.flush rescue nil
159
+ rescue => e
160
+ STDERR.puts "rubish: #{e.class}: #{e.message}"
161
+ end
162
+ status = exit_code || @repl.instance_variable_get(:@last_status) || (interrupted ? 130 : 0)
163
+ # Re-announce cwd so the host's pane.screen.current_directory
164
+ # reflects any cd/pushd/popd this command performed. Emit
165
+ # *before* the sentinel so the host has it processed by the
166
+ # time it reacts to command_done.
167
+ emit_osc7(Dir.pwd)
168
+ # Sentinel must be in the pty kernel buffer before host
169
+ # observes command_done; the structured event comes last.
170
+ STDOUT.write DONE_SENTINEL rescue nil
171
+ STDOUT.flush rescue nil
172
+ emit_event('command_done', exit_status: status, cwd: Dir.pwd)
173
+ if exit_code
174
+ # Give the host a beat to drain the control pipe + pty
175
+ # before we tear them down. After exit, the host's
176
+ # waitpid(WNOHANG) flips the pane's alive? to false and
177
+ # the dead-pane reaper closes the pane (or window).
178
+ sleep 0.05
179
+ exit(exit_code)
180
+ end
181
+ end
182
+ reply(msg, 'started')
183
+ end
184
+
185
+ def handle_resize(msg)
186
+ rows = msg['rows'].to_i
187
+ cols = msg['cols'].to_i
188
+ STDIN.winsize = [rows, cols]
189
+ rescue Errno::EINVAL, Errno::ENOTTY
190
+ # ignore
191
+ ensure
192
+ reply(msg, 'ok')
193
+ end
194
+
195
+ def reply(msg, result)
196
+ id = msg['id']
197
+ return unless id
198
+ send_json('id' => id, 'result' => result)
199
+ end
200
+
201
+ def emit_event(event, **payload)
202
+ send_json('event' => event, **payload)
203
+ end
204
+
205
+ # Drive a private startup step on the REPL with visible error
206
+ # reporting. The previous `rescue nil` swallowed silent failures
207
+ # — most notably ensure_system_path failing to populate PATH from
208
+ # /usr/libexec/path_helper, which left embedded shells without
209
+ # /usr/local/bin and friends. STDERR is wired to the pty so any
210
+ # exception lands in the pane where the user can act on it.
211
+ def run_init_step(method)
212
+ @repl.send(method)
213
+ rescue Exception => e
214
+ STDERR.puts "echoes helper: #{method} failed: #{e.class}: #{e.message}"
215
+ STDERR.puts e.backtrace.first(8).join("\n") if e.backtrace
216
+ STDERR.flush
217
+ end
218
+
219
+ # Write an OSC 7 sequence (cwd announcement) to the pty. Format:
220
+ # `\e]7;file://localhost/<percent-encoded-path>\e\\`. The host's
221
+ # parser sets `screen.current_directory` from this; gui.rb's
222
+ # `pane_local_cwd` then converts it back to a real path for
223
+ # things like new-tab-inherits-cwd.
224
+ def emit_osc7(path)
225
+ # Percent-encode every byte that isn't an RFC 3986 unreserved
226
+ # char or path separator, so spaces and unicode survive the
227
+ # round-trip.
228
+ encoded = path.b.gsub(/[^A-Za-z0-9\-._~\/]/n) { |c| '%' + c.unpack1('H*').upcase }
229
+ STDOUT.write "\e]7;file://localhost#{encoded}\e\\"
230
+ STDOUT.flush
231
+ rescue IOError, Errno::EPIPE
232
+ end
233
+
234
+ def send_json(obj)
235
+ @write_lock.synchronize do
236
+ @control_out.puts JSON.generate(obj)
237
+ end
238
+ rescue Errno::EPIPE
239
+ # parent went away
240
+ exit 0
241
+ end
242
+
243
+ def tokens_for(line)
244
+ @repl.tokenize(line).map { |t| {'type' => t.type.to_s, 'value' => t.value.to_s} }
245
+ end
246
+
247
+ def segments_to_array(segs)
248
+ (segs || []).map do |s|
249
+ {
250
+ 'text' => s[:text].to_s,
251
+ 'fg' => s[:fg],
252
+ 'bg' => s[:bg],
253
+ 'bold' => !!s[:bold],
254
+ 'italic' => !!s[:italic],
255
+ 'underline' => !!s[:underline],
256
+ 'inverse' => !!s[:inverse],
257
+ }
258
+ end
259
+ end
260
+ end
261
+ end
262
+
263
+ if $PROGRAM_NAME == __FILE__
264
+ Echoes::EmbeddedShellHelper.new.run
265
+ end