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,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
|