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.
- checksums.yaml +7 -0
- data/CLAUDE.md +33 -0
- data/Echoes.app/Contents/Info.plist +16 -0
- data/Echoes.app/Contents/MacOS/Echoes +50 -0
- data/EchoesEmbed.app/Contents/Info.plist +16 -0
- data/EchoesEmbed.app/Contents/MacOS/EchoesEmbed +41 -0
- data/LICENSE.txt +21 -0
- data/README.md +133 -0
- data/Rakefile +45 -0
- data/exe/echoes +15 -0
- data/lib/echoes/cell.rb +54 -0
- data/lib/echoes/client.rb +96 -0
- data/lib/echoes/configuration.rb +135 -0
- data/lib/echoes/copy_mode.rb +545 -0
- data/lib/echoes/cursor.rb +18 -0
- data/lib/echoes/editor.rb +225 -0
- data/lib/echoes/embedded_shell.rb +360 -0
- data/lib/echoes/embedded_shell_helper.rb +265 -0
- data/lib/echoes/gui.rb +2861 -0
- data/lib/echoes/installer.rb +95 -0
- data/lib/echoes/objc.rb +188 -0
- data/lib/echoes/pane.rb +1122 -0
- data/lib/echoes/pane_tree.rb +194 -0
- data/lib/echoes/parser.rb +821 -0
- data/lib/echoes/preferences.rb +45 -0
- data/lib/echoes/screen.rb +1468 -0
- data/lib/echoes/sixel_decoder.rb +221 -0
- data/lib/echoes/tab.rb +152 -0
- data/lib/echoes/terminal.rb +124 -0
- data/lib/echoes/version.rb +5 -0
- data/lib/echoes.rb +37 -0
- data/sig/echoes.rbs +4 -0
- metadata +123 -0
|
@@ -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
|