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,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'reline'
4
+
5
+ module Echoes
6
+ # Vim-equivalent editor pane backed by `Rvim::Editor`. Editing,
7
+ # `:w` (write), `:q` (quit), search, visual mode, undo/redo and
8
+ # the rest of rvim's surface all work — this class is the
9
+ # rendering shim that turns rvim's editor state into the styled
10
+ # segments echoes' Screen wants. The bottom row is reserved for
11
+ # the statusline (mode / filename / modified marker / line:col)
12
+ # or, when in command/search mode, for the cmdline (`:`, `/`,
13
+ # or `?` prompt with the typed text and cursor).
14
+ class Editor
15
+ # rvim's syntax highlighter labels each token with a vim-style
16
+ # color symbol (`:Comment`, `:String`, …). Map to ANSI palette
17
+ # indices that this Screen's Cell.fg uses.
18
+ COLOR_MAP = {
19
+ Comment: 8, # bright black / grey
20
+ String: 2, # green
21
+ Keyword: 5, # magenta
22
+ Symbol: 3, # yellow
23
+ Number: 3, # yellow
24
+ Constant: 6, # cyan
25
+ Function: 4, # blue
26
+ Type: 6, # cyan
27
+ Special: 3, # yellow
28
+ PreProc: 5, # magenta
29
+ Operator: 14, # bright cyan
30
+ Identifier: 7, # white
31
+ }.freeze
32
+
33
+ DEFAULT_SEGMENT = {
34
+ fg: nil, bg: nil,
35
+ bold: false, italic: false, underline: false, inverse: false,
36
+ }.freeze
37
+
38
+ attr_reader :file
39
+
40
+ def initialize(file:, rows:, cols:)
41
+ require 'rvim'
42
+ @editor = Rvim::Editor.new(Reline.core.config)
43
+ @rows = rows
44
+ @cols = cols
45
+ @file = file
46
+ resize(rows: rows, cols: cols)
47
+ @editor.open(file) if file && File.exist?(file)
48
+ @lang = @file ? Rvim::Syntax.detect_language(@file) : nil
49
+ end
50
+
51
+ # Match rvim's window dimensions to the host pane's. Called on
52
+ # construction and on later `Pane#resize`.
53
+ def resize(rows:, cols:)
54
+ @rows = rows
55
+ @cols = cols
56
+ win = @editor.current_window
57
+ return unless win
58
+ win.height = rows
59
+ win.width = cols
60
+ end
61
+
62
+ # Forward a single character (or escape sequence) to the editor.
63
+ # Accepts Strings like 'j', 'G', "\x04" (Ctrl-D), or "\e[A". Maps
64
+ # macOS NSEvent special-key codepoints to vim equivalents so the
65
+ # arrow keys "just work" in viewer mode.
66
+ SPECIAL_KEY_MAP = {
67
+ "\u{F700}" => 'k', # Up
68
+ "\u{F701}" => 'j', # Down
69
+ "\u{F702}" => 'h', # Left
70
+ "\u{F703}" => 'l', # Right
71
+ "\u{F72C}" => "\x02", # PageUp → Ctrl-B
72
+ "\u{F72D}" => "\x06", # PageDown → Ctrl-F
73
+ "\u{F729}" => 'g', # Home (caller may follow with another 'g')
74
+ "\u{F72B}" => 'G', # End
75
+ }.freeze
76
+
77
+ def feed_key(ch)
78
+ mapped = SPECIAL_KEY_MAP[ch] || ch
79
+ mapped.each_char { |c| @editor.send(:dispatch_synthesized_key, c) }
80
+ true
81
+ rescue
82
+ false
83
+ end
84
+
85
+ # Visible window's lines as Arrays of styled-segment Hashes
86
+ # (`{text:, fg:, bg:, bold:, italic:, underline:, inverse:}`),
87
+ # one Array per visible row. The last row is the statusline (or
88
+ # cmdline, in `:`/`/`/`?` modes); the rows above are buffer text
89
+ # padded with vim-style `~` markers when shorter than the pane.
90
+ def visible_segments
91
+ win = @editor.current_window
92
+ lines = @editor.current_buffer&.lines || []
93
+ top = win&.scroll_top || 0
94
+ body_rows = [@rows - 1, 1].max # reserve last row for status/cmdline
95
+ slice = lines[top, body_rows] || []
96
+ out = slice.map { |line| line_to_segments(line) }
97
+ while out.size < body_rows
98
+ out << [DEFAULT_SEGMENT.merge(text: '~', fg: 4)]
99
+ end
100
+ out << bottom_row_segments
101
+ out
102
+ end
103
+
104
+ # (row, col) of the cursor within the viewport. In cmdline modes
105
+ # (:ex / :search_*) the cursor sits on the bottom row at the end
106
+ # of the cmdline text; otherwise it tracks the editor cursor in
107
+ # the buffer body.
108
+ def cursor_position
109
+ if cmdline_mode?
110
+ text = cmdline_text
111
+ [@rows - 1, text.length.clamp(0, @cols - 1)]
112
+ else
113
+ win = @editor.current_window
114
+ top = win&.scroll_top || 0
115
+ row = (@editor.line_index || 0) - top
116
+ col = @editor.byte_pointer || 0
117
+ body_max = [@rows - 2, 0].max
118
+ [row.clamp(0, body_max), col.clamp(0, @cols - 1)]
119
+ end
120
+ end
121
+
122
+ # rvim sets `quit` after `:q` / `:q!` / `:wq`. The host polls this
123
+ # via `Pane#alive?` and reaps the pane like it would a dead shell.
124
+ def closed?
125
+ @editor.quit?
126
+ end
127
+
128
+ # Short label for the current vim mode (used in the statusline
129
+ # and exposed for window-title / future status-bar plumbing).
130
+ def mode_label
131
+ return :cmdline if cmdline_mode?
132
+ return :visual if @editor.visual_mode
133
+ return :insert if @editor.send(:editing_mode_label) == :vi_insert
134
+ :normal
135
+ end
136
+
137
+ # Filename for the window title; appends `[+]` while the buffer
138
+ # has unsaved changes (vim convention). Returns `'[No Name]'` for
139
+ # buffers opened with no path (e.g. `:enew`).
140
+ def display_filename
141
+ base = @file && !@file.empty? ? File.basename(@file) : '[No Name]'
142
+ @editor.modified ? "#{base} [+]" : base
143
+ end
144
+
145
+ private
146
+
147
+ def cmdline_mode?
148
+ [:ex, :search_forward, :search_backward].include?(@editor.prompt_mode)
149
+ end
150
+
151
+ # The text the user sees on the bottom row when in cmdline mode:
152
+ # the leader char (`:`, `/`, `?`) followed by what they've typed.
153
+ def cmdline_text
154
+ leader =
155
+ case @editor.prompt_mode
156
+ when :search_forward then '/'
157
+ when :search_backward then '?'
158
+ else ':'
159
+ end
160
+ "#{leader}#{@editor.prompt_buffer}"
161
+ end
162
+
163
+ # One row of segments for the bottom of the pane. Either the
164
+ # cmdline (in :ex/search mode) or the inverse-video statusline.
165
+ def bottom_row_segments
166
+ if cmdline_mode?
167
+ text = cmdline_text
168
+ [DEFAULT_SEGMENT.merge(text: text.byteslice(0, @cols).to_s)]
169
+ else
170
+ statusline_segments
171
+ end
172
+ end
173
+
174
+ # Inverse-video statusline: "MODE filename [+] <padding> line:col".
175
+ # `status_message` (if rvim has one — e.g. ':"…" written') wins
176
+ # over the mode label so saves and errors are visible.
177
+ def statusline_segments
178
+ status = @editor.status_message
179
+ left =
180
+ if status && !status.empty?
181
+ status.to_s
182
+ else
183
+ mode = mode_label_text
184
+ fname = display_filename
185
+ mode.empty? ? fname : "#{mode} #{fname}"
186
+ end
187
+ lineno = (@editor.line_index || 0) + 1
188
+ col = (@editor.byte_pointer || 0) + 1
189
+ right = "#{lineno}:#{col}"
190
+
191
+ max = @cols
192
+ pad = max - left.length - right.length
193
+ pad = 1 if pad < 1
194
+ text = "#{left}#{' ' * pad}#{right}"
195
+ text = text.byteslice(0, max).to_s
196
+
197
+ [DEFAULT_SEGMENT.merge(text: text, inverse: true)]
198
+ end
199
+
200
+ def mode_label_text
201
+ case mode_label
202
+ when :insert then '-- INSERT --'
203
+ when :visual then '-- VISUAL --'
204
+ else ''
205
+ end
206
+ end
207
+
208
+ def line_to_segments(line)
209
+ return [DEFAULT_SEGMENT.merge(text: '')] if line.nil? || line.empty?
210
+ spans = (@lang ? Rvim::Syntax.highlight(line, @lang) : []).sort_by { |s, _e, _c| s }
211
+ result = []
212
+ pos = 0
213
+ spans.each do |start, last, color_sym|
214
+ if start > pos
215
+ result << DEFAULT_SEGMENT.merge(text: line.byteslice(pos, start - pos).to_s)
216
+ end
217
+ text = line.byteslice(start, last - start + 1).to_s
218
+ result << DEFAULT_SEGMENT.merge(text: text, fg: COLOR_MAP[color_sym])
219
+ pos = last + 1
220
+ end
221
+ result << DEFAULT_SEGMENT.merge(text: line.byteslice(pos..).to_s) if pos < line.bytesize
222
+ result.empty? ? [DEFAULT_SEGMENT.merge(text: line)] : result
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pty'
4
+ require 'io/console'
5
+ require 'json'
6
+ require 'securerandom'
7
+
8
+ module Echoes
9
+ # Drives a per-pane subprocess (`embedded_shell_helper.rb`) that owns
10
+ # the pty as its controlling tty and runs a `Rubish::REPL` in its own
11
+ # session. Every external command rubish forks inherits the helper's
12
+ # session/ctty, so Ctrl-C (ETX → SIGINT) works for any execution
13
+ # shape — single command, pipeline, loop, sequence, conditional.
14
+ #
15
+ # The helper communicates with this class over a JSON-line control
16
+ # pipe. Standard rubish output (prompts, command stdout/stderr) flows
17
+ # through the pty in the usual way. The control pipe carries:
18
+ #
19
+ # - synchronous queries: complete_at, prompt, prompt_segments,
20
+ # try_parse, tokenize, history, last_status, cwd, …
21
+ # - async events from helper to host: command_done, …
22
+ #
23
+ # API surface is identical to the previous in-process version so
24
+ # callers (Pane, etc.) don't need to change.
25
+ class EmbeddedShell
26
+ HELPER_SCRIPT = File.expand_path('embedded_shell_helper.rb', __dir__)
27
+ DEFAULT_SYNC_TIMEOUT = 5.0
28
+ # Mirror of EmbeddedShellHelper::DONE_SENTINEL — the helper writes
29
+ # this OSC sequence to the pty after a command's last output bytes
30
+ # are flushed but before it sends the command_done event on the
31
+ # control pipe. The pty reader scans for it and strips it; the
32
+ # presence of the sentinel + the event together is what
33
+ # `reap_if_done` waits for.
34
+ DONE_SENTINEL = "\e]7771\a"
35
+
36
+ # `no_rc:` skips rubish's startup-file loading (~/.rubishrc, /etc/bashrc,
37
+ # …) and history file restore in the helper. Tests pass true so the
38
+ # subprocess starts from a known-clean environment; production
39
+ # callers leave it false so the user's config takes effect.
40
+ def initialize(no_rc: false)
41
+ ENV['GIT_PAGER'] ||= 'cat'
42
+ ENV['PAGER'] ||= 'cat'
43
+
44
+ helper_env = {'ECHOES_HELPER_NO_RC' => no_rc ? '1' : nil}
45
+ @master, slave = PTY.open
46
+ # Bidirectional JSON-line control pipes. Helper reads requests on
47
+ # fd 3 and writes responses/events on fd 4.
48
+ ctl_in_r, ctl_in_w = IO.pipe # parent → helper
49
+ ctl_out_r, ctl_out_w = IO.pipe # helper → parent
50
+ @control_write = ctl_in_w
51
+ @control_read = ctl_out_r
52
+ @control_write.sync = true
53
+
54
+ @output_buffer = +''
55
+ @output_lock = Mutex.new
56
+ @pending_lock = Mutex.new
57
+ @pending = {} # id → Queue
58
+ @running = false
59
+ @last_status = 0
60
+ @last_cwd = Dir.pwd
61
+
62
+ load_paths = $LOAD_PATH.flat_map { |p| ['-I', p] }
63
+ @helper_pid = Process.spawn(
64
+ helper_env, RbConfig.ruby, *load_paths, HELPER_SCRIPT,
65
+ in: slave, out: slave, err: slave,
66
+ 3 => ctl_in_r, 4 => ctl_out_w,
67
+ close_others: true,
68
+ )
69
+ slave.close
70
+ ctl_in_r.close
71
+ ctl_out_w.close
72
+
73
+ @sentinel_seen = false
74
+ @reader = Thread.new(@master) do |m|
75
+ # Accumulator handles a sentinel that straddles two chunks.
76
+ # After splitting on any complete sentinels, hold back ONLY the
77
+ # bytes at the tail of `acc` that match a prefix of the sentinel
78
+ # — anything else is safe to flush right away. Holding back
79
+ # blindly (e.g. always the last N-1 bytes) corrupts the host's
80
+ # parser when a long OSC sequence happens to end inside that
81
+ # window: the trailing bytes get released much later, by which
82
+ # time the parser has moved on and renders them as text.
83
+ acc = +''
84
+ begin
85
+ loop do
86
+ chunk = m.readpartial(4096)
87
+ acc << chunk
88
+ while (idx = acc.index(DONE_SENTINEL))
89
+ before = acc[0...idx]
90
+ @output_lock.synchronize { @output_buffer << before } unless before.empty?
91
+ acc = acc[(idx + DONE_SENTINEL.bytesize)..] || +''
92
+ @sentinel_seen = true
93
+ end
94
+ keep = pending_sentinel_prefix_len(acc)
95
+ if acc.bytesize > keep
96
+ flush_n = acc.bytesize - keep
97
+ @output_lock.synchronize { @output_buffer << acc.byteslice(0, flush_n) }
98
+ acc = acc.byteslice(flush_n, acc.bytesize - flush_n) || +''
99
+ end
100
+ end
101
+ rescue EOFError, IOError, Errno::EIO
102
+ # helper exited or was killed
103
+ end
104
+ @output_lock.synchronize { @output_buffer << acc } unless acc.empty?
105
+ end
106
+
107
+ @control_reader = Thread.new(@control_read) do |io|
108
+ begin
109
+ while (line = io.gets)
110
+ handle_control_line(line)
111
+ end
112
+ rescue IOError, Errno::EIO
113
+ # helper exited
114
+ end
115
+ end
116
+ end
117
+
118
+ # Submit a complete line of input. Returns immediately; output flows
119
+ # through the pty asynchronously and `command_done` will set
120
+ # @running back to false. The line is added to history (rubish-side)
121
+ # by the helper before execution.
122
+ def submit_line(line, rows: 24, cols: 80)
123
+ return if running?
124
+ @master.winsize = [rows, cols] rescue nil
125
+ @running = true
126
+ rpc_async('execute', line: line)
127
+ end
128
+
129
+ # Synchronous wrapper for scripts and tests that don't have a
130
+ # dispatch loop. Submits and blocks until the command completes
131
+ # (or `timeout` seconds pass — in which case ETX is sent).
132
+ def submit_and_wait(line, rows: 24, cols: 80, timeout: 30)
133
+ submit_line(line, rows: rows, cols: cols)
134
+ deadline = Time.now + timeout
135
+ while running?
136
+ if Time.now > deadline
137
+ interrupt
138
+ break
139
+ end
140
+ sleep 0.01
141
+ end
142
+ reap_if_done
143
+ nil
144
+ end
145
+
146
+ def running?
147
+ @running
148
+ end
149
+
150
+ # True until the helper subprocess has terminated (e.g. user typed
151
+ # `exit`). Reaps with WNOHANG so the call is non-blocking; once we
152
+ # observe the death we cache it so subsequent calls don't ECHILD on
153
+ # an already-reaped pid.
154
+ def alive?
155
+ return false if @helper_dead
156
+ return true unless @helper_pid
157
+ pid, _ = Process.waitpid2(@helper_pid, Process::WNOHANG)
158
+ if pid
159
+ @helper_dead = true
160
+ return false
161
+ end
162
+ true
163
+ rescue Errno::ECHILD
164
+ @helper_dead = true
165
+ false
166
+ end
167
+
168
+ # Drain any output bytes the pty reader has buffered. Empty String
169
+ # if none. Never blocks.
170
+ def read_available_output
171
+ @output_lock.synchronize do
172
+ data = @output_buffer.dup
173
+ @output_buffer.clear
174
+ data
175
+ end
176
+ end
177
+
178
+ # Write keystrokes to the pty master. The kernel's line discipline
179
+ # routes them to whatever is reading on the slave (the running
180
+ # command's stdin).
181
+ def forward_input(bytes)
182
+ @master.write(bytes)
183
+ rescue IOError, Errno::EIO, Errno::EPIPE
184
+ end
185
+
186
+ # ETX. The line discipline converts to SIGINT delivered to the
187
+ # foreground process group of the slave's controlling session —
188
+ # which is the helper's session, so any rubish-forked child
189
+ # receives it.
190
+ def interrupt
191
+ @master.write("\x03") rescue nil
192
+ end
193
+
194
+ # Updates the pty master's winsize. The kernel propagates to the
195
+ # slave (which is the helper's ctty) and emits SIGWINCH to the
196
+ # foreground process group, so vim/less/etc. repaint.
197
+ def resize(rows:, cols:)
198
+ @master.winsize = [rows, cols]
199
+ rescue IOError, Errno::EIO
200
+ end
201
+
202
+ # Compatibility method the host calls each tick. With the helper
203
+ # architecture the pty/ctty live for the pane's lifetime, but the
204
+ # host still needs a one-shot "command finished" signal so it can
205
+ # emit the next prompt. Returns true on the tick where BOTH the
206
+ # control_out command_done event has arrived AND the pty sentinel
207
+ # has been observed by the reader thread (so the caller's next
208
+ # `read_available_output` is guaranteed to have drained the
209
+ # command's last bytes).
210
+ def reap_if_done
211
+ return false unless @done_pending && @sentinel_seen
212
+ @done_pending = false
213
+ @sentinel_seen = false
214
+ true
215
+ end
216
+
217
+ # ---- queries to the helper (synchronous) ----
218
+
219
+ def complete_at(line:, point:)
220
+ rpc_sync('complete', line: line, point: point) || []
221
+ end
222
+
223
+ def prompt
224
+ rpc_sync('prompt') || ''
225
+ end
226
+
227
+ def prompt_segments
228
+ (rpc_sync('prompt_segments') || []).map { |s| symbolize_segment(s) }
229
+ end
230
+
231
+ # Right-aligned prompt (rubish's RPROMPT). Returns an Array of
232
+ # segments — possibly empty — same shape as `prompt_segments`.
233
+ def right_prompt_segments
234
+ (rpc_sync('right_prompt_segments') || []).map { |s| symbolize_segment(s) }
235
+ end
236
+
237
+ def continuation_prompt
238
+ rpc_sync('continuation_prompt') || '> '
239
+ end
240
+
241
+ def try_parse(line)
242
+ (rpc_sync('try_parse', line: line) || 'error').to_sym
243
+ end
244
+
245
+ def tokenize(line)
246
+ (rpc_sync('tokenize', line: line) || []).map { |t| TokenView.new(t['type'].to_sym, t['value']) }
247
+ end
248
+
249
+ def history
250
+ rpc_sync('history') || []
251
+ end
252
+
253
+ def last_status
254
+ @last_status
255
+ end
256
+
257
+ def cwd
258
+ # Cached from command_done events; only refresh on demand if
259
+ # nothing has been run yet.
260
+ @last_cwd ||= rpc_sync('cwd')
261
+ @last_cwd
262
+ end
263
+
264
+ # Returns when the helper has exited and pipes are closed. For
265
+ # tests / shutdown.
266
+ def shutdown
267
+ rpc_async('shutdown') rescue nil
268
+ Process.waitpid(@helper_pid) rescue nil
269
+ @master.close rescue nil
270
+ @control_write.close rescue nil
271
+ @control_read.close rescue nil
272
+ @reader.join(1) rescue nil
273
+ @control_reader.join(1) rescue nil
274
+ end
275
+
276
+ private
277
+
278
+ # Mirror of Rubish::Lexer::Token's small interface (type/value)
279
+ # over a value object so colorize_input doesn't need to know it
280
+ # came across a pipe.
281
+ TokenView = Struct.new(:type, :value)
282
+
283
+ # Longest tail of `acc` that's also a prefix of DONE_SENTINEL —
284
+ # i.e. how many bytes might be the start of a sentinel that
285
+ # crosses into the next pty chunk. 0 if the tail can't possibly
286
+ # be a sentinel prefix, so the whole acc is safe to flush.
287
+ def pending_sentinel_prefix_len(acc)
288
+ max = [DONE_SENTINEL.bytesize - 1, acc.bytesize].min
289
+ max.downto(1) do |n|
290
+ return n if DONE_SENTINEL.byteslice(0, n) == acc.byteslice(-n, n)
291
+ end
292
+ 0
293
+ end
294
+
295
+ def handle_control_line(line)
296
+ msg = JSON.parse(line) rescue nil
297
+ return unless msg
298
+ if msg.key?('event')
299
+ handle_event(msg)
300
+ elsif (id = msg['id'])
301
+ queue = @pending_lock.synchronize { @pending.delete(id) }
302
+ queue&.push(msg['result'])
303
+ end
304
+ end
305
+
306
+ def handle_event(msg)
307
+ case msg['event']
308
+ when 'command_done'
309
+ @last_status = msg['exit_status'].to_i
310
+ @last_cwd = msg['cwd'] if msg['cwd']
311
+ @done_pending = true
312
+ @running = false
313
+ end
314
+ end
315
+
316
+ def rpc_async(op, **args)
317
+ send_request(op, **args)
318
+ end
319
+
320
+ def rpc_sync(op, **args)
321
+ id = SecureRandom.uuid
322
+ queue = Queue.new
323
+ @pending_lock.synchronize { @pending[id] = queue }
324
+ send_request(op, id: id, **args)
325
+ result = queue.pop_with_timeout(DEFAULT_SYNC_TIMEOUT) if queue.respond_to?(:pop_with_timeout)
326
+ result = wait_with_timeout(queue, DEFAULT_SYNC_TIMEOUT) if result.nil?
327
+ result
328
+ rescue Errno::EPIPE, IOError
329
+ nil
330
+ end
331
+
332
+ def wait_with_timeout(queue, timeout)
333
+ deadline = Time.now + timeout
334
+ until queue.closed? || queue.size > 0
335
+ return nil if Time.now > deadline
336
+ sleep 0.005
337
+ end
338
+ queue.pop
339
+ end
340
+
341
+ def send_request(op, **args)
342
+ payload = {'op' => op}
343
+ args.each { |k, v| payload[k.to_s] = v }
344
+ @control_write.puts JSON.generate(payload)
345
+ rescue Errno::EPIPE, IOError
346
+ end
347
+
348
+ def symbolize_segment(s)
349
+ {
350
+ text: s['text'],
351
+ fg: s['fg'],
352
+ bg: s['bg'],
353
+ bold: s['bold'],
354
+ italic: s['italic'],
355
+ underline: s['underline'],
356
+ inverse: s['inverse'],
357
+ }
358
+ end
359
+ end
360
+ end