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
data/lib/echoes/pane.rb
ADDED
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pty'
|
|
4
|
+
|
|
5
|
+
module Echoes
|
|
6
|
+
# A Pane is one shell session within a Tab. It owns a Screen (the cell
|
|
7
|
+
# grid the user sees) and a backing shell — either an external program
|
|
8
|
+
# spawned via PTY (the default), or a Rubish::REPL running in a per-pane
|
|
9
|
+
# helper subprocess via Echoes::EmbeddedShell. The helper owns the pty
|
|
10
|
+
# as its controlling tty so Ctrl-C / SIGWINCH / job control all work.
|
|
11
|
+
#
|
|
12
|
+
# Callers that need to send bytes to the shell or pull bytes back use
|
|
13
|
+
# `write_input` / `read_available_output`. Don't reach for the legacy
|
|
14
|
+
# `pty_read` / `pty_write` accessors — they're nil in embedded mode.
|
|
15
|
+
class Pane
|
|
16
|
+
attr_accessor :screen, :parser, :pty_read, :pty_write, :pty_pid,
|
|
17
|
+
:scroll_offset, :scroll_accum, :title, :copy_mode
|
|
18
|
+
attr_reader :embedded_shell
|
|
19
|
+
|
|
20
|
+
def initialize(command:, rows:, cols:, cwd: nil, embedded: false, no_rc: false, editor_file: nil)
|
|
21
|
+
@screen = Screen.new(rows: rows, cols: cols)
|
|
22
|
+
if editor_file
|
|
23
|
+
require_relative 'editor'
|
|
24
|
+
@editor = Editor.new(file: editor_file, rows: rows, cols: cols)
|
|
25
|
+
@parser = Parser.new(@screen, writer: ->(_s) { })
|
|
26
|
+
@title = File.basename(editor_file)
|
|
27
|
+
elsif embedded
|
|
28
|
+
require_relative 'embedded_shell'
|
|
29
|
+
@embedded_shell = EmbeddedShell.new(no_rc: no_rc)
|
|
30
|
+
@parser = Parser.new(@screen, writer: ->(_s) { })
|
|
31
|
+
@title = 'rubish'
|
|
32
|
+
@input_buffer = +''
|
|
33
|
+
@input_cursor = 0 # offset within @input_buffer (0..length)
|
|
34
|
+
@embedded_running = false
|
|
35
|
+
@history_index = nil # nil = not browsing; integer = browsing
|
|
36
|
+
@history_saved = nil # input held aside while browsing
|
|
37
|
+
@continuation_lines = [] # collected lines while waiting for a complete command
|
|
38
|
+
@kill_ring = +'' # last killed text (for Ctrl-Y yank)
|
|
39
|
+
@autosuggestion = +'' # fish-style: tail of the most recent matching history entry
|
|
40
|
+
@right_prompt_segments = nil # cached at prompt time; redrawn after every input edit
|
|
41
|
+
@input_mode = :prompt # :prompt | :search (running uses @embedded_shell.running?)
|
|
42
|
+
@search_query = +'' # Ctrl-R substring being typed
|
|
43
|
+
@search_index = nil # index into history of the current match (nil = no match)
|
|
44
|
+
@search_saved_buffer = nil
|
|
45
|
+
@search_saved_cursor = nil
|
|
46
|
+
@search_saved_autosuggestion = nil
|
|
47
|
+
else
|
|
48
|
+
start_dir = (cwd && Dir.exist?(cwd)) ? cwd : Dir.home
|
|
49
|
+
Dir.chdir(start_dir) do
|
|
50
|
+
ENV['TERM'] = Echoes.config.term
|
|
51
|
+
ENV['LANG'] ||= 'en_US.UTF-8'
|
|
52
|
+
ENV['LC_CTYPE'] = 'UTF-8'
|
|
53
|
+
@pty_read, @pty_write, @pty_pid = PTY.spawn(command)
|
|
54
|
+
@pty_read.winsize = [rows, cols]
|
|
55
|
+
end
|
|
56
|
+
@parser = Parser.new(@screen, writer: ->(s) { @pty_write.write(s) rescue nil })
|
|
57
|
+
@title = File.basename(command)
|
|
58
|
+
end
|
|
59
|
+
@scroll_offset = 0
|
|
60
|
+
@scroll_accum = 0.0
|
|
61
|
+
@copy_mode = nil
|
|
62
|
+
render_initial_prompt if embedded
|
|
63
|
+
render_editor if editor?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def embedded?
|
|
67
|
+
!@embedded_shell.nil?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def editor?
|
|
71
|
+
!@editor.nil?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
attr_reader :editor
|
|
75
|
+
|
|
76
|
+
# Send raw bytes to the shell. In PTY mode these go through pty_write
|
|
77
|
+
# to the child process. In embedded mode there is no per-keystroke
|
|
78
|
+
# input channel (line editing happens in Echoes itself), so this is
|
|
79
|
+
# a no-op — the host should call `submit_line` for completed lines.
|
|
80
|
+
def write_input(bytes)
|
|
81
|
+
if embedded?
|
|
82
|
+
# phase-1 stub: no per-keystroke routing yet
|
|
83
|
+
else
|
|
84
|
+
@pty_write.write(bytes) rescue nil
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Submit a complete line of input. PTY mode writes the line plus CR;
|
|
89
|
+
# embedded mode hands the line directly to the in-process REPL.
|
|
90
|
+
def submit_line(line)
|
|
91
|
+
if embedded?
|
|
92
|
+
@embedded_shell.submit_line(line)
|
|
93
|
+
else
|
|
94
|
+
@pty_write.write("#{line}\r") rescue nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Drain whatever output bytes are available from the shell right now.
|
|
99
|
+
# Returns "" if nothing is ready; never blocks; never raises.
|
|
100
|
+
#
|
|
101
|
+
# In embedded mode this is also where we detect that an async
|
|
102
|
+
# command has finished — we drain its trailing output, emit OSC 133
|
|
103
|
+
# ;D (command end), then ;A + prompt + ;B for the next command, and
|
|
104
|
+
# re-enable the in-pane line editor.
|
|
105
|
+
def read_available_output(max = 16384)
|
|
106
|
+
return '' if editor?
|
|
107
|
+
if embedded?
|
|
108
|
+
out = @embedded_shell.read_available_output
|
|
109
|
+
if @embedded_running && @embedded_shell.reap_if_done
|
|
110
|
+
out << @embedded_shell.read_available_output
|
|
111
|
+
out << osc133_d(@embedded_shell.last_status)
|
|
112
|
+
# Drain trailing output + ;D through the parser ourselves
|
|
113
|
+
# so we can render the next prompt natively (skipping the
|
|
114
|
+
# ANSI SGR roundtrip) before returning.
|
|
115
|
+
process_output(out)
|
|
116
|
+
out = +''
|
|
117
|
+
process_output(osc133_a)
|
|
118
|
+
render_prompt_natively
|
|
119
|
+
process_output(osc133_b)
|
|
120
|
+
render_input_area
|
|
121
|
+
@embedded_running = false
|
|
122
|
+
end
|
|
123
|
+
out
|
|
124
|
+
else
|
|
125
|
+
@pty_read.read_nonblock(max)
|
|
126
|
+
end
|
|
127
|
+
rescue IO::WaitReadable, EOFError, Errno::EIO, IOError
|
|
128
|
+
''
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def alive?
|
|
132
|
+
return !@editor.closed? if editor?
|
|
133
|
+
if embedded?
|
|
134
|
+
@embedded_shell.alive?
|
|
135
|
+
else
|
|
136
|
+
Process.waitpid(@pty_pid, Process::WNOHANG).nil?
|
|
137
|
+
end
|
|
138
|
+
rescue Errno::ECHILD
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def resize(rows, cols)
|
|
143
|
+
@screen.resize(rows, cols)
|
|
144
|
+
if editor?
|
|
145
|
+
@editor.resize(rows: rows, cols: cols)
|
|
146
|
+
render_editor
|
|
147
|
+
elsif embedded?
|
|
148
|
+
@embedded_shell.resize(rows: rows, cols: cols)
|
|
149
|
+
else
|
|
150
|
+
@pty_read.winsize = [rows, cols]
|
|
151
|
+
end
|
|
152
|
+
rescue Errno::EIO, IOError
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def close
|
|
156
|
+
return if editor?
|
|
157
|
+
if embedded?
|
|
158
|
+
@embedded_shell.shutdown
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
@pty_write.close rescue nil
|
|
162
|
+
@pty_read.close rescue nil
|
|
163
|
+
Process.kill(:HUP, @pty_pid) rescue nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def process_output(data)
|
|
167
|
+
@parser.feed(data)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Text of the most recently completed command's output, extracted
|
|
171
|
+
# from the OSC 133 ;C..;D region. Returns nil when no command has
|
|
172
|
+
# finished yet on this pane. Useful for "copy last command output"
|
|
173
|
+
# workflows and for piping output to external tools.
|
|
174
|
+
def last_command_output_text
|
|
175
|
+
mark = @screen.last_completed_command_mark
|
|
176
|
+
return nil unless mark
|
|
177
|
+
text = @screen.text_for_command_output(mark)
|
|
178
|
+
text.empty? ? nil : text
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Convenience: copy `last_command_output_text` to the system
|
|
182
|
+
# clipboard via the screen's clipboard handler. Returns true on
|
|
183
|
+
# success, false if there's nothing to copy or no clipboard
|
|
184
|
+
# handler is wired (e.g., in tests).
|
|
185
|
+
def copy_last_command_output
|
|
186
|
+
text = last_command_output_text
|
|
187
|
+
return false unless text
|
|
188
|
+
@screen.set_clipboard(text)
|
|
189
|
+
true
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Most recently submitted command's text (the literal line the user
|
|
193
|
+
# ran). Reads rubish's Reline::HISTORY directly, which is more
|
|
194
|
+
# reliable than scraping the cell grid (no wrapping / column-offset
|
|
195
|
+
# ambiguity from the prompt). Returns nil if no command has been
|
|
196
|
+
# submitted yet.
|
|
197
|
+
def last_command_text
|
|
198
|
+
return nil unless embedded?
|
|
199
|
+
hist = @embedded_shell.history
|
|
200
|
+
hist.last
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def copy_last_command_text
|
|
204
|
+
text = last_command_text
|
|
205
|
+
return false unless text && !text.empty?
|
|
206
|
+
@screen.set_clipboard(text)
|
|
207
|
+
true
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Jump scroll position to the previous or next OSC 133 prompt
|
|
211
|
+
# boundary recorded on @screen. Returns true if a jump happened,
|
|
212
|
+
# false if there was no target in that direction. The Screen's
|
|
213
|
+
# `command_marks` are populated by the parser whenever the running
|
|
214
|
+
# shell emits OSC 133 (the embedded shell does this automatically;
|
|
215
|
+
# PTY-mode shells like zsh/fish emit them too when configured).
|
|
216
|
+
def jump_to_prompt(direction:)
|
|
217
|
+
marks = @screen.command_marks.select { |m| m[:prompt_start] }
|
|
218
|
+
return false if marks.empty?
|
|
219
|
+
|
|
220
|
+
scrollback_size = @screen.scrollback.size
|
|
221
|
+
current_top = scrollback_size - @scroll_offset
|
|
222
|
+
|
|
223
|
+
target =
|
|
224
|
+
case direction
|
|
225
|
+
when :prev then marks.reverse.find { |m| m[:prompt_start] < current_top }
|
|
226
|
+
when :next then marks.find { |m| m[:prompt_start] > current_top }
|
|
227
|
+
end
|
|
228
|
+
return false unless target
|
|
229
|
+
|
|
230
|
+
row = target[:prompt_start]
|
|
231
|
+
if row >= scrollback_size
|
|
232
|
+
# Target is in the live grid — scroll to bottom.
|
|
233
|
+
@scroll_offset = 0
|
|
234
|
+
else
|
|
235
|
+
@scroll_offset = (scrollback_size - row).clamp(0, scrollback_size)
|
|
236
|
+
end
|
|
237
|
+
true
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Embedded-mode keyboard handling. Returns true if the pane consumed
|
|
241
|
+
# the event, false if the GUI should fall through to its own
|
|
242
|
+
# PTY-style handling (which is the only mode in non-embedded panes).
|
|
243
|
+
#
|
|
244
|
+
# Two states:
|
|
245
|
+
# - prompt mode (no command running): printable chars echo to the
|
|
246
|
+
# screen and append to @input_buffer; Backspace pops a char and
|
|
247
|
+
# erases the last cell; Enter submits the buffered line for
|
|
248
|
+
# async execution.
|
|
249
|
+
# - running mode (a command is in flight): keystrokes get
|
|
250
|
+
# forwarded to the command's stdin via the pty master, so the
|
|
251
|
+
# user can type into vim, scroll less, etc. Ctrl-C interrupts.
|
|
252
|
+
def handle_key(chars:, flags: 0)
|
|
253
|
+
if editor?
|
|
254
|
+
return true if chars.nil? || chars.empty?
|
|
255
|
+
# Map Ctrl+letter to the corresponding control byte that
|
|
256
|
+
# rvim's keymap expects (e.g. Ctrl-D → 0x04). Other special
|
|
257
|
+
# keys are translated by Editor#feed_key directly.
|
|
258
|
+
ch = if (flags & NSEVENT_CONTROL_FLAG) != 0 && chars.length == 1 && chars.ord >= 0x20
|
|
259
|
+
(chars.ord & 0x1F).chr
|
|
260
|
+
else
|
|
261
|
+
chars
|
|
262
|
+
end
|
|
263
|
+
@editor.feed_key(ch)
|
|
264
|
+
render_editor
|
|
265
|
+
return true
|
|
266
|
+
end
|
|
267
|
+
return false unless embedded?
|
|
268
|
+
return true if chars.nil? || chars.empty?
|
|
269
|
+
|
|
270
|
+
if @embedded_shell.running?
|
|
271
|
+
# Translate macOS special-key code points to the ANSI escape
|
|
272
|
+
# sequences a real terminal would have produced — that's what
|
|
273
|
+
# programs reading from the pty (vim, less, etc.) expect.
|
|
274
|
+
translated = translate_for_pty(chars, flags)
|
|
275
|
+
@embedded_shell.forward_input(translated)
|
|
276
|
+
return true
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
return handle_search_key(chars, flags) if @input_mode == :search
|
|
280
|
+
|
|
281
|
+
# Emacs/readline-style bindings on Ctrl+letter at the prompt.
|
|
282
|
+
# macOS gives us `chars` as the plain letter (Cocoa's
|
|
283
|
+
# charactersIgnoringModifiers); flags carries the Control bit.
|
|
284
|
+
if (flags & NSEVENT_CONTROL_FLAG) != 0 && chars.length == 1 && chars.ord >= 0x20
|
|
285
|
+
return true if handle_ctrl_letter(chars.downcase)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
option_held = (flags & NSEVENT_OPTION_FLAG) != 0
|
|
289
|
+
cmd_held = (flags & NSEVENT_COMMAND_FLAG) != 0
|
|
290
|
+
shift_held = (flags & NSEVENT_SHIFT_FLAG) != 0
|
|
291
|
+
|
|
292
|
+
# Cmd+Shift+letter: pane-level shortcuts that operate on OSC 133
|
|
293
|
+
# marks. Cmd+Shift+Up/Down (jump-to-prompt) is matched in the
|
|
294
|
+
# arrow-key cases below.
|
|
295
|
+
if cmd_held && shift_held && chars.length == 1
|
|
296
|
+
case chars.downcase
|
|
297
|
+
when 'o'
|
|
298
|
+
copy_last_command_output
|
|
299
|
+
return true
|
|
300
|
+
when 'l'
|
|
301
|
+
copy_last_command_text
|
|
302
|
+
return true
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
case chars
|
|
307
|
+
when "\r", "\n"
|
|
308
|
+
submit_or_continue
|
|
309
|
+
when "\u{7F}", "\b"
|
|
310
|
+
option_held ? kill_word_left : delete_before_cursor
|
|
311
|
+
when "\u{F728}" # NSDeleteFunctionKey (forward delete)
|
|
312
|
+
option_held ? kill_word_right : delete_at_cursor
|
|
313
|
+
when "\u{F702}" # NSLeftArrowFunctionKey
|
|
314
|
+
option_held ? word_left : cursor_left
|
|
315
|
+
when "\u{F703}" # NSRightArrowFunctionKey
|
|
316
|
+
if option_held
|
|
317
|
+
word_right
|
|
318
|
+
elsif at_end_with_suggestion?
|
|
319
|
+
accept_full_autosuggestion
|
|
320
|
+
else
|
|
321
|
+
cursor_right
|
|
322
|
+
end
|
|
323
|
+
when "\u{F729}" # NSHomeFunctionKey
|
|
324
|
+
cursor_home
|
|
325
|
+
when "\u{F72B}" # NSEndFunctionKey
|
|
326
|
+
if at_end_with_suggestion?
|
|
327
|
+
accept_full_autosuggestion
|
|
328
|
+
else
|
|
329
|
+
cursor_end
|
|
330
|
+
end
|
|
331
|
+
when "\u{F700}" # NSUpArrowFunctionKey
|
|
332
|
+
if cmd_held && shift_held
|
|
333
|
+
jump_to_prompt(direction: :prev)
|
|
334
|
+
else
|
|
335
|
+
history_step(-1)
|
|
336
|
+
end
|
|
337
|
+
when "\u{F701}" # NSDownArrowFunctionKey
|
|
338
|
+
if cmd_held && shift_held
|
|
339
|
+
jump_to_prompt(direction: :next)
|
|
340
|
+
else
|
|
341
|
+
history_step(1)
|
|
342
|
+
end
|
|
343
|
+
when "\t"
|
|
344
|
+
complete_input
|
|
345
|
+
else
|
|
346
|
+
first = chars.bytes.first
|
|
347
|
+
if first && first >= 0x20
|
|
348
|
+
@history_index = nil # editing ends history-walk mode
|
|
349
|
+
@history_saved = nil
|
|
350
|
+
insert_at_cursor(chars)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
true
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
private
|
|
357
|
+
|
|
358
|
+
# Re-render the editor's visible window into the screen
|
|
359
|
+
# cells. Called on construction, after every key event, and
|
|
360
|
+
# after resize.
|
|
361
|
+
def render_editor
|
|
362
|
+
return unless @editor
|
|
363
|
+
process_output("\e[2J\e[H")
|
|
364
|
+
segs_per_row = @editor.visible_segments
|
|
365
|
+
segs_per_row.each_with_index do |segs, idx|
|
|
366
|
+
break if idx >= @screen.rows
|
|
367
|
+
@screen.cursor.row = idx
|
|
368
|
+
@screen.cursor.col = 0
|
|
369
|
+
@screen.put_styled_segments(segs)
|
|
370
|
+
end
|
|
371
|
+
row, col = @editor.cursor_position
|
|
372
|
+
@screen.cursor.row = row.clamp(0, @screen.rows - 1)
|
|
373
|
+
@screen.cursor.col = col.clamp(0, @screen.cols - 1)
|
|
374
|
+
@screen.pending_wrap = false
|
|
375
|
+
@screen.mark_all_dirty
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def render_initial_prompt
|
|
379
|
+
process_output(osc133_a)
|
|
380
|
+
render_prompt_natively
|
|
381
|
+
process_output(osc133_b)
|
|
382
|
+
render_input_area # draws the rprompt for the (empty) initial input
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Render the current prompt by pulling rubish's structured
|
|
386
|
+
# `prompt_segments` and writing them directly to cells via
|
|
387
|
+
# `Screen#put_styled_segments` — no ANSI SGR roundtrip. Falls
|
|
388
|
+
# back to the legacy ANSI string path if segments aren't
|
|
389
|
+
# available.
|
|
390
|
+
#
|
|
391
|
+
# Also refreshes the rprompt cache. Drawing the rprompt itself is
|
|
392
|
+
# done by render_input_area so it follows the input on every
|
|
393
|
+
# edit; that way the rprompt isn't lost when the user's input
|
|
394
|
+
# grows long enough to overwrite its cells and is then shortened.
|
|
395
|
+
def render_prompt_natively
|
|
396
|
+
segments = @embedded_shell.prompt_segments
|
|
397
|
+
if segments && !segments.empty?
|
|
398
|
+
@screen.put_styled_segments(segments)
|
|
399
|
+
else
|
|
400
|
+
process_output(@embedded_shell.prompt.to_s)
|
|
401
|
+
end
|
|
402
|
+
@right_prompt_segments = @embedded_shell.right_prompt_segments
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Render the input area: colored input, dim autosuggestion, then
|
|
406
|
+
# the cached right-prompt at the right edge, and finally restores
|
|
407
|
+
# the cursor to the user's logical input position. Assumed cursor
|
|
408
|
+
# entry: at the start of the input area (just past the main prompt).
|
|
409
|
+
def render_input_area
|
|
410
|
+
process_output(colorize_input(@input_buffer))
|
|
411
|
+
process_output("\e[2m" + @autosuggestion + "\e[0m") unless @autosuggestion.empty?
|
|
412
|
+
input_visible = @input_buffer.length + @autosuggestion.length
|
|
413
|
+
draw_right_prompt_inline(input_visible)
|
|
414
|
+
back = input_visible - @input_cursor
|
|
415
|
+
process_output("\e[#{back}D") if back > 0
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Draw the cached rprompt at the right edge of the current row.
|
|
419
|
+
# On entry the cursor sits right after the input + suggestion (at
|
|
420
|
+
# column `input_start_col + input_visible`); on exit it's back at
|
|
421
|
+
# that same column. Skipped when it would overlap the input.
|
|
422
|
+
def draw_right_prompt_inline(input_visible)
|
|
423
|
+
rsegs = @right_prompt_segments
|
|
424
|
+
return if rsegs.nil? || rsegs.empty?
|
|
425
|
+
rwidth = rsegs.sum { |s| (s[:text] || '').length }
|
|
426
|
+
return if rwidth == 0
|
|
427
|
+
cols = @screen.cols
|
|
428
|
+
saved_row = @screen.cursor.row
|
|
429
|
+
saved_col = @screen.cursor.col
|
|
430
|
+
target_col = cols - rwidth
|
|
431
|
+
return if saved_col >= target_col # not enough room
|
|
432
|
+
|
|
433
|
+
delta = target_col - saved_col
|
|
434
|
+
process_output("\e[#{delta}C")
|
|
435
|
+
@screen.put_styled_segments(rsegs)
|
|
436
|
+
# When the rprompt's last cell lands at cols-1, put_char defers
|
|
437
|
+
# the wrap and leaves cursor.col=cols-1 with pending_wrap=true.
|
|
438
|
+
# ANSI `\e[D` would then back up from cols-1 not cols and we'd
|
|
439
|
+
# lose the trailing prompt cell. Restore cursor state directly
|
|
440
|
+
# instead.
|
|
441
|
+
@screen.cursor.row = saved_row
|
|
442
|
+
@screen.cursor.col = saved_col
|
|
443
|
+
@screen.pending_wrap = false
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# OSC 133 escape strings. Hosts surrounding shells in their own
|
|
447
|
+
# render layer (us) emit these to mark prompt/input/output regions.
|
|
448
|
+
# The terminal parser routes them to Screen#osc133_mark.
|
|
449
|
+
def osc133_a; "\e]133;A\e\\"; end
|
|
450
|
+
def osc133_b; "\e]133;B\e\\"; end
|
|
451
|
+
def osc133_c; "\e]133;C\e\\"; end
|
|
452
|
+
def osc133_d(code = nil)
|
|
453
|
+
code.nil? ? "\e]133;D\e\\" : "\e]133;D;#{code}\e\\"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Enter pressed at the prompt. Decide whether the accumulated
|
|
457
|
+
# input forms a complete command — if so, submit it; if not,
|
|
458
|
+
# keep collecting continuation lines under PS2.
|
|
459
|
+
def submit_or_continue
|
|
460
|
+
this_line = @input_buffer
|
|
461
|
+
candidate = (@continuation_lines + [this_line]).join("\n")
|
|
462
|
+
|
|
463
|
+
case @embedded_shell.try_parse(candidate)
|
|
464
|
+
when :incomplete
|
|
465
|
+
# Accumulate and prompt for more.
|
|
466
|
+
@continuation_lines << this_line
|
|
467
|
+
@input_buffer = +''
|
|
468
|
+
@input_cursor = 0
|
|
469
|
+
@autosuggestion = +''
|
|
470
|
+
process_output("\r\n")
|
|
471
|
+
process_output(@embedded_shell.continuation_prompt)
|
|
472
|
+
else
|
|
473
|
+
# :ok or :error — let rubish run it; rubish reports syntax
|
|
474
|
+
# errors itself. Either way, this line completes the input.
|
|
475
|
+
line = candidate
|
|
476
|
+
@input_buffer = +''
|
|
477
|
+
@input_cursor = 0
|
|
478
|
+
@continuation_lines = []
|
|
479
|
+
@history_index = nil
|
|
480
|
+
@history_saved = nil
|
|
481
|
+
@autosuggestion = +''
|
|
482
|
+
process_output("\r\n")
|
|
483
|
+
process_output(osc133_c)
|
|
484
|
+
# Stash the command text on the OSC 133 mark so click-to-rerun
|
|
485
|
+
# can recover it later.
|
|
486
|
+
@screen.set_current_command_text(line)
|
|
487
|
+
@embedded_shell.submit_line(line, rows: @screen.rows, cols: @screen.cols)
|
|
488
|
+
@embedded_running = true
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# ↑/↓ history navigation. step is -1 (older) or +1 (newer).
|
|
493
|
+
def history_step(step)
|
|
494
|
+
hist = @embedded_shell.history
|
|
495
|
+
return if hist.empty?
|
|
496
|
+
|
|
497
|
+
if @history_index.nil?
|
|
498
|
+
return if step > 0 # already at "current input", down-arrow no-op
|
|
499
|
+
@history_saved = @input_buffer.dup
|
|
500
|
+
@history_index = hist.size # one past last; about to decrement
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
new_index = @history_index + step
|
|
504
|
+
if new_index < 0
|
|
505
|
+
return # already at oldest
|
|
506
|
+
elsif new_index >= hist.size
|
|
507
|
+
# past the newest entry → restore the user's saved in-progress input
|
|
508
|
+
replace_input_buffer(@history_saved || '')
|
|
509
|
+
@history_index = nil
|
|
510
|
+
@history_saved = nil
|
|
511
|
+
else
|
|
512
|
+
@history_index = new_index
|
|
513
|
+
replace_input_buffer(hist[@history_index] || '')
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Erase the currently-displayed input line and replace it with
|
|
518
|
+
# `new_line`. After the call the screen cursor is at the end of
|
|
519
|
+
# new_line. Used by history navigation, which always wants the
|
|
520
|
+
# cursor at the end after a swap.
|
|
521
|
+
def replace_input_buffer(new_line)
|
|
522
|
+
replace_input_line(new_line, new_line.length)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Lower-level variant: replace the input line with `new_line` and
|
|
526
|
+
# position the cursor at `new_cursor` within it. Erases the old
|
|
527
|
+
# input + autosuggestion, then delegates to `render_input_area` so
|
|
528
|
+
# the input, autosuggestion, and rprompt are all redrawn from the
|
|
529
|
+
# cached state in lockstep.
|
|
530
|
+
def replace_input_line(new_line, new_cursor)
|
|
531
|
+
prev_visible = @input_buffer.length + @autosuggestion.length
|
|
532
|
+
tail_len = prev_visible - @input_cursor
|
|
533
|
+
process_output("\e[#{tail_len}C") if tail_len > 0
|
|
534
|
+
process_output("\b \b" * prev_visible)
|
|
535
|
+
@input_buffer = +new_line
|
|
536
|
+
@input_cursor = new_cursor
|
|
537
|
+
@autosuggestion = compute_autosuggestion
|
|
538
|
+
render_input_area
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# ---- Mid-line editing primitives. All operate on @input_buffer
|
|
542
|
+
# and @input_cursor and emit just enough on the screen to keep the
|
|
543
|
+
# cell-grid view in sync.
|
|
544
|
+
|
|
545
|
+
def insert_at_cursor(chars)
|
|
546
|
+
new_line = @input_buffer.dup.insert(@input_cursor, chars)
|
|
547
|
+
replace_input_line(new_line, @input_cursor + chars.length)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def delete_before_cursor
|
|
551
|
+
return if @input_cursor == 0
|
|
552
|
+
new_line = @input_buffer.dup.tap { |s| s.slice!(@input_cursor - 1) }
|
|
553
|
+
replace_input_line(new_line, @input_cursor - 1)
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def delete_at_cursor
|
|
557
|
+
return if @input_cursor >= @input_buffer.length
|
|
558
|
+
new_line = @input_buffer.dup.tap { |s| s.slice!(@input_cursor) }
|
|
559
|
+
replace_input_line(new_line, @input_cursor)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def cursor_left
|
|
563
|
+
return if @input_cursor == 0
|
|
564
|
+
@input_cursor -= 1
|
|
565
|
+
process_output("\e[D")
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def cursor_right
|
|
569
|
+
return if @input_cursor >= @input_buffer.length
|
|
570
|
+
@input_cursor += 1
|
|
571
|
+
process_output("\e[C")
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def cursor_home
|
|
575
|
+
return if @input_cursor == 0
|
|
576
|
+
process_output("\e[#{@input_cursor}D")
|
|
577
|
+
@input_cursor = 0
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def cursor_end
|
|
581
|
+
n = @input_buffer.length - @input_cursor
|
|
582
|
+
return if n == 0
|
|
583
|
+
process_output("\e[#{n}C")
|
|
584
|
+
@input_cursor = @input_buffer.length
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Emacs/readline keybindings handled at the prompt. Returns true
|
|
588
|
+
# if we consumed the keystroke. The PTY-mode pane still uses the
|
|
589
|
+
# GUI's existing Ctrl-letter -> control byte path; this only fires
|
|
590
|
+
# in embedded prompt mode.
|
|
591
|
+
def handle_ctrl_letter(letter)
|
|
592
|
+
case letter
|
|
593
|
+
when 'a' then cursor_home; true
|
|
594
|
+
when 'e'
|
|
595
|
+
# Ctrl-E: jump to end. If already at end and a suggestion is
|
|
596
|
+
# showing, accept it (fish-style).
|
|
597
|
+
if at_end_with_suggestion?
|
|
598
|
+
accept_full_autosuggestion
|
|
599
|
+
else
|
|
600
|
+
cursor_end
|
|
601
|
+
end
|
|
602
|
+
true
|
|
603
|
+
when 'b' then cursor_left; true
|
|
604
|
+
when 'f'
|
|
605
|
+
# Ctrl-F: forward one char. At end-of-input with a suggestion,
|
|
606
|
+
# accept just one word of it (fish's accept-autosuggestion-word).
|
|
607
|
+
if at_end_with_suggestion?
|
|
608
|
+
accept_word_of_autosuggestion
|
|
609
|
+
else
|
|
610
|
+
cursor_right
|
|
611
|
+
end
|
|
612
|
+
true
|
|
613
|
+
when 'h' then delete_before_cursor; true # ASCII 0x08 (BS)
|
|
614
|
+
when 'i' then complete_input; true # ASCII 0x09 (Tab)
|
|
615
|
+
when 'j' then submit_or_continue; true # ASCII 0x0A (LF / Enter)
|
|
616
|
+
when 'm' then submit_or_continue; true # ASCII 0x0D (CR / Enter)
|
|
617
|
+
when 'p' then history_step(-1); true # readline alias for ↑
|
|
618
|
+
when 'n' then history_step(1); true # readline alias for ↓
|
|
619
|
+
when 't' then transpose_chars; true
|
|
620
|
+
when 'y' then yank_kill_ring; true
|
|
621
|
+
when 'd'
|
|
622
|
+
# Bash convention: Ctrl-D on an empty line is "EOF / exit"; on
|
|
623
|
+
# a non-empty line it's forward-delete. Synthesize a typed
|
|
624
|
+
# `exit` so it goes through the same submit path as the user
|
|
625
|
+
# typing it manually — the helper catches rubish's `throw
|
|
626
|
+
# :exit` and shuts down.
|
|
627
|
+
if @input_buffer.empty?
|
|
628
|
+
@input_buffer = +'exit'
|
|
629
|
+
@input_cursor = @input_buffer.length
|
|
630
|
+
submit_or_continue
|
|
631
|
+
else
|
|
632
|
+
delete_at_cursor
|
|
633
|
+
end
|
|
634
|
+
true
|
|
635
|
+
when 'k' then kill_to_end; true
|
|
636
|
+
when 'u' then kill_to_start; true
|
|
637
|
+
when 'w' then kill_word_left; true
|
|
638
|
+
when 'l' then redraw_screen; true
|
|
639
|
+
when 'r' then enter_search; true
|
|
640
|
+
when 'c'
|
|
641
|
+
# Ctrl-C at the prompt: discard the in-progress line, drop
|
|
642
|
+
# the user on a fresh prompt below. Like bash.
|
|
643
|
+
process_output("^C\r\n")
|
|
644
|
+
@input_buffer = +''
|
|
645
|
+
@input_cursor = 0
|
|
646
|
+
@history_index = nil
|
|
647
|
+
@history_saved = nil
|
|
648
|
+
@autosuggestion = +''
|
|
649
|
+
process_output(osc133_a)
|
|
650
|
+
render_prompt_natively
|
|
651
|
+
process_output(osc133_b)
|
|
652
|
+
render_input_area
|
|
653
|
+
true
|
|
654
|
+
else
|
|
655
|
+
false
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def kill_to_end
|
|
660
|
+
return if @input_cursor >= @input_buffer.length
|
|
661
|
+
@kill_ring = @input_buffer[@input_cursor..]
|
|
662
|
+
new_line = @input_buffer[0, @input_cursor]
|
|
663
|
+
replace_input_line(new_line, @input_cursor)
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def kill_to_start
|
|
667
|
+
return if @input_cursor == 0
|
|
668
|
+
@kill_ring = @input_buffer[0, @input_cursor]
|
|
669
|
+
new_line = @input_buffer[@input_cursor..] || ''
|
|
670
|
+
replace_input_line(new_line, 0)
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def kill_word_left
|
|
674
|
+
return if @input_cursor == 0
|
|
675
|
+
i = @input_cursor
|
|
676
|
+
i -= 1 while i > 0 && @input_buffer[i - 1] == ' '
|
|
677
|
+
i -= 1 while i > 0 && @input_buffer[i - 1] != ' '
|
|
678
|
+
removed = @input_cursor - i
|
|
679
|
+
return if removed == 0
|
|
680
|
+
@kill_ring = @input_buffer[i, removed]
|
|
681
|
+
new_line = @input_buffer.dup.tap { |s| s.slice!(i, removed) }
|
|
682
|
+
replace_input_line(new_line, i)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# Mirror of kill_word_left: skip whitespace forward, then a word, kill
|
|
686
|
+
# that span. Bound to Option+forward-Delete; matches macOS muscle memory.
|
|
687
|
+
def kill_word_right
|
|
688
|
+
return if @input_cursor >= @input_buffer.length
|
|
689
|
+
j = @input_cursor
|
|
690
|
+
j += 1 while j < @input_buffer.length && @input_buffer[j] == ' '
|
|
691
|
+
j += 1 while j < @input_buffer.length && @input_buffer[j] != ' '
|
|
692
|
+
removed = j - @input_cursor
|
|
693
|
+
return if removed == 0
|
|
694
|
+
@kill_ring = @input_buffer[@input_cursor, removed]
|
|
695
|
+
new_line = @input_buffer.dup.tap { |s| s.slice!(@input_cursor, removed) }
|
|
696
|
+
replace_input_line(new_line, @input_cursor)
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Re-insert the most recently killed text at the cursor.
|
|
700
|
+
def yank_kill_ring
|
|
701
|
+
return if @kill_ring.empty?
|
|
702
|
+
insert_at_cursor(@kill_ring)
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# Swap the char before the cursor with the char at the cursor and
|
|
706
|
+
# advance one position. At end-of-line, swaps the last two chars
|
|
707
|
+
# (readline behavior). At start-of-line, no-op.
|
|
708
|
+
def transpose_chars
|
|
709
|
+
return if @input_buffer.length < 2 || @input_cursor == 0
|
|
710
|
+
new_line = @input_buffer.dup
|
|
711
|
+
if @input_cursor == @input_buffer.length
|
|
712
|
+
new_line[-2], new_line[-1] = new_line[-1], new_line[-2]
|
|
713
|
+
replace_input_line(new_line, @input_cursor)
|
|
714
|
+
else
|
|
715
|
+
new_line[@input_cursor - 1], new_line[@input_cursor] =
|
|
716
|
+
new_line[@input_cursor], new_line[@input_cursor - 1]
|
|
717
|
+
replace_input_line(new_line, @input_cursor + 1)
|
|
718
|
+
end
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def redraw_screen
|
|
722
|
+
process_output("\e[2J\e[H")
|
|
723
|
+
render_prompt_natively
|
|
724
|
+
render_input_area
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
# Translate a macOS NSEvent character (which uses U+F70x for
|
|
728
|
+
# special keys) to the ANSI escape sequence a unix program reading
|
|
729
|
+
# from a pty would expect. Plain printable input passes through;
|
|
730
|
+
# Ctrl+letter gets masked to its control byte (so Ctrl-C → ETX).
|
|
731
|
+
SPECIAL_KEY_TO_ANSI = {
|
|
732
|
+
"\u{F700}" => "\e[A", # Up
|
|
733
|
+
"\u{F701}" => "\e[B", # Down
|
|
734
|
+
"\u{F703}" => "\e[C", # Right
|
|
735
|
+
"\u{F702}" => "\e[D", # Left
|
|
736
|
+
"\u{F728}" => "\e[3~", # Delete (forward)
|
|
737
|
+
"\u{F729}" => "\e[H", # Home
|
|
738
|
+
"\u{F72B}" => "\e[F", # End
|
|
739
|
+
"\u{F72C}" => "\e[5~", # PageUp
|
|
740
|
+
"\u{F72D}" => "\e[6~", # PageDown
|
|
741
|
+
}.freeze
|
|
742
|
+
|
|
743
|
+
NSEVENT_CONTROL_FLAG = 0x40000
|
|
744
|
+
NSEVENT_OPTION_FLAG = 0x80000
|
|
745
|
+
NSEVENT_SHIFT_FLAG = 0x20000
|
|
746
|
+
NSEVENT_COMMAND_FLAG = 0x100000
|
|
747
|
+
|
|
748
|
+
def word_left
|
|
749
|
+
return if @input_cursor == 0
|
|
750
|
+
i = @input_cursor
|
|
751
|
+
i -= 1 while i > 0 && @input_buffer[i - 1] == ' '
|
|
752
|
+
i -= 1 while i > 0 && @input_buffer[i - 1] != ' '
|
|
753
|
+
steps = @input_cursor - i
|
|
754
|
+
return if steps == 0
|
|
755
|
+
process_output("\e[#{steps}D")
|
|
756
|
+
@input_cursor = i
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def word_right
|
|
760
|
+
return if @input_cursor >= @input_buffer.length
|
|
761
|
+
i = @input_cursor
|
|
762
|
+
i += 1 while i < @input_buffer.length && @input_buffer[i] != ' '
|
|
763
|
+
i += 1 while i < @input_buffer.length && @input_buffer[i] == ' '
|
|
764
|
+
steps = i - @input_cursor
|
|
765
|
+
return if steps == 0
|
|
766
|
+
process_output("\e[#{steps}C")
|
|
767
|
+
@input_cursor = i
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def translate_for_pty(chars, flags)
|
|
771
|
+
mapped = SPECIAL_KEY_TO_ANSI[chars]
|
|
772
|
+
return mapped if mapped
|
|
773
|
+
if (flags & NSEVENT_CONTROL_FLAG) != 0 && chars.length == 1 && chars.ord >= 0x20
|
|
774
|
+
return (chars.ord & 0x1F).chr
|
|
775
|
+
end
|
|
776
|
+
chars
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
# Tab completion. If exactly one candidate matches the word at
|
|
780
|
+
# cursor, splice it in and add a trailing space (or `/` for dirs).
|
|
781
|
+
# Multiple candidates → print them inline below the prompt and
|
|
782
|
+
# redraw the input. Zero → no-op (silent).
|
|
783
|
+
#
|
|
784
|
+
# The data-only `completion_request` and the splice helper
|
|
785
|
+
# `apply_completion` are public so the GUI can intercept multi-
|
|
786
|
+
# candidate completions and show a native NSMenu popup instead of
|
|
787
|
+
# the inline list.
|
|
788
|
+
WORD_BREAK_CHARS = " \t\n\"'><=;|&{("
|
|
789
|
+
|
|
790
|
+
public # the completion API is reached from gui.rb (the popup)
|
|
791
|
+
|
|
792
|
+
# Pure-data: ask the embedded shell what completions are available
|
|
793
|
+
# at the current cursor and locate the start of the word being
|
|
794
|
+
# completed. Returns nil when there are no candidates. Has no
|
|
795
|
+
# side effects on the screen.
|
|
796
|
+
def completion_request
|
|
797
|
+
point = @input_cursor
|
|
798
|
+
candidates = @embedded_shell.complete_at(line: @input_buffer, point: point)
|
|
799
|
+
return nil if candidates.empty?
|
|
800
|
+
word_start = point
|
|
801
|
+
word_start -= 1 while word_start > 0 && !WORD_BREAK_CHARS.include?(@input_buffer[word_start - 1])
|
|
802
|
+
{candidates: candidates, word_start: word_start, point: point}
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# Splice `completion` into the input buffer in place of the partial
|
|
806
|
+
# word that starts at `word_start`. Adds a trailing space unless
|
|
807
|
+
# the completion already ends with `/` (a directory). Re-renders
|
|
808
|
+
# via `replace_input_line` so highlighting + autosuggestion stay
|
|
809
|
+
# consistent.
|
|
810
|
+
def apply_completion(word_start:, completion:)
|
|
811
|
+
completion = "#{completion} " unless completion.end_with?('/')
|
|
812
|
+
tail = @input_buffer[@input_cursor..] || ''
|
|
813
|
+
new_input = @input_buffer[0...word_start] + completion + tail
|
|
814
|
+
new_cursor = word_start + completion.length
|
|
815
|
+
replace_input_line(new_input, new_cursor)
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
# Replace the in-progress input with `text` (e.g., a command
|
|
819
|
+
# recovered from a Cmd-clicked prompt's OSC 133 mark). Cursor lands
|
|
820
|
+
# at end. Cleared history-walk and autosuggestion state so the next
|
|
821
|
+
# ↑/↓ starts from the freshly-recalled line.
|
|
822
|
+
def recall_command(text)
|
|
823
|
+
return if text.nil? || text.empty?
|
|
824
|
+
@history_index = nil
|
|
825
|
+
@history_saved = nil
|
|
826
|
+
replace_input_buffer(text)
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
private
|
|
830
|
+
|
|
831
|
+
def complete_input
|
|
832
|
+
req = completion_request
|
|
833
|
+
return unless req
|
|
834
|
+
candidates = req[:candidates]
|
|
835
|
+
|
|
836
|
+
if candidates.size == 1
|
|
837
|
+
apply_completion(word_start: req[:word_start], completion: candidates.first)
|
|
838
|
+
else
|
|
839
|
+
# GUI-less fallback (and what tests exercise): print the
|
|
840
|
+
# candidates inline below the prompt and redraw the input.
|
|
841
|
+
# In the windowed app the GUI intercepts Tab before this
|
|
842
|
+
# branch and shows an NSMenu popup instead.
|
|
843
|
+
process_output("\r\n")
|
|
844
|
+
per_row = 4
|
|
845
|
+
candidates.each_with_index do |c, i|
|
|
846
|
+
process_output(c.ljust(20))
|
|
847
|
+
process_output("\r\n") if i % per_row == per_row - 1
|
|
848
|
+
end
|
|
849
|
+
process_output("\r\n") unless candidates.size % per_row == 0
|
|
850
|
+
render_prompt_natively
|
|
851
|
+
@input_cursor = @input_buffer.length
|
|
852
|
+
@autosuggestion = compute_autosuggestion
|
|
853
|
+
render_input_area
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# Map of token type → SGR escape. Keywords get bold yellow; the
|
|
858
|
+
# control-flow operators get bright cyan; redirections get bright
|
|
859
|
+
# magenta. Word tokens are handled separately below — quoted ones
|
|
860
|
+
# render green, the first word at command position renders bold.
|
|
861
|
+
TOKEN_COLOR_MAP = {
|
|
862
|
+
IF: "\e[1;33m", THEN: "\e[1;33m", ELSE: "\e[1;33m", ELIF: "\e[1;33m",
|
|
863
|
+
ELSIF: "\e[1;33m", FI: "\e[1;33m", UNLESS: "\e[1;33m", WHILE: "\e[1;33m",
|
|
864
|
+
UNTIL: "\e[1;33m", FOR: "\e[1;33m", SELECT: "\e[1;33m", CASE: "\e[1;33m",
|
|
865
|
+
WHEN: "\e[1;33m", ESAC: "\e[1;33m", FUNCTION: "\e[1;33m", DEF: "\e[1;33m",
|
|
866
|
+
COPROC: "\e[1;33m", TIME: "\e[1;33m", LAZY_LOAD: "\e[1;33m",
|
|
867
|
+
PIPE: "\e[96m", PIPE_BOTH: "\e[96m", SEMICOLON: "\e[96m",
|
|
868
|
+
DOUBLE_SEMI: "\e[96m", AND: "\e[96m", OR: "\e[96m", AMPERSAND: "\e[96m",
|
|
869
|
+
REDIRECT_OUT: "\e[95m", REDIRECT_APPEND: "\e[95m", REDIRECT_IN: "\e[95m",
|
|
870
|
+
REDIRECT_ERR: "\e[95m", REDIRECT_CLOBBER: "\e[95m", DUP_OUT: "\e[95m",
|
|
871
|
+
DUP_IN: "\e[95m", HEREDOC: "\e[95m", HEREDOC_INDENT: "\e[95m",
|
|
872
|
+
HERESTRING: "\e[95m",
|
|
873
|
+
}.freeze
|
|
874
|
+
|
|
875
|
+
# Token types that put the *next* WORD into "command position" — i.e.
|
|
876
|
+
# this is where the user types a program name, which we render bold.
|
|
877
|
+
COMMAND_BOUNDARY_TYPES = %i[
|
|
878
|
+
SEMICOLON PIPE PIPE_BOTH AND OR AMPERSAND
|
|
879
|
+
IF THEN ELSE ELIF ELSIF WHILE UNTIL FOR SELECT CASE WHEN
|
|
880
|
+
DOUBLE_SEMI CASE_FALL CASE_CONT LBRACE LPAREN
|
|
881
|
+
].freeze
|
|
882
|
+
|
|
883
|
+
# Take the user's in-progress input line and return a copy with
|
|
884
|
+
# ANSI SGR escapes wrapped around each token. SGRs don't advance
|
|
885
|
+
# the cell-grid cursor, so the surrounding cell-count math in
|
|
886
|
+
# `replace_input_line` is unchanged. Falls back to the plain line
|
|
887
|
+
# on any failure — highlighting must never lose user input.
|
|
888
|
+
def colorize_input(line)
|
|
889
|
+
return line if line.empty?
|
|
890
|
+
tokens = @embedded_shell.tokenize(line)
|
|
891
|
+
return line if tokens.empty?
|
|
892
|
+
|
|
893
|
+
out = +''
|
|
894
|
+
pos = 0
|
|
895
|
+
command_position = true
|
|
896
|
+
tokens.each do |tok|
|
|
897
|
+
val = tok.value.to_s
|
|
898
|
+
next if val.empty?
|
|
899
|
+
idx = line.index(val, pos)
|
|
900
|
+
break unless idx
|
|
901
|
+
# Whitespace skipped by the lexer copies through verbatim.
|
|
902
|
+
out << line[pos...idx] if idx > pos
|
|
903
|
+
|
|
904
|
+
sgr = sgr_for_token(tok, command_position)
|
|
905
|
+
if sgr
|
|
906
|
+
out << sgr << val << "\e[0m"
|
|
907
|
+
else
|
|
908
|
+
out << val
|
|
909
|
+
end
|
|
910
|
+
pos = idx + val.length
|
|
911
|
+
|
|
912
|
+
if tok.type == :WORD
|
|
913
|
+
command_position = false
|
|
914
|
+
elsif COMMAND_BOUNDARY_TYPES.include?(tok.type)
|
|
915
|
+
command_position = true
|
|
916
|
+
end
|
|
917
|
+
end
|
|
918
|
+
out << line[pos..] if pos < line.length
|
|
919
|
+
out
|
|
920
|
+
rescue
|
|
921
|
+
line
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
# Find the most recent history entry that has @input_buffer as a
|
|
925
|
+
# strict prefix; the suffix is what we render as a dim ghost-text
|
|
926
|
+
# autosuggestion. Empty input → no suggestion. Empty during
|
|
927
|
+
# multi-line continuation: history stores commands as one entry
|
|
928
|
+
# each, so a partial second-line buffer wouldn't match meaningfully.
|
|
929
|
+
def compute_autosuggestion
|
|
930
|
+
return '' if @input_buffer.empty?
|
|
931
|
+
return '' unless @continuation_lines.empty?
|
|
932
|
+
hist = @embedded_shell.history
|
|
933
|
+
return '' if hist.empty?
|
|
934
|
+
hist.reverse_each do |entry|
|
|
935
|
+
# Multi-line history entries (saved from continuation submissions
|
|
936
|
+
# as one big string with embedded "\n") can't render as inline
|
|
937
|
+
# ghost text — skip them.
|
|
938
|
+
next if entry.include?("\n")
|
|
939
|
+
if entry.start_with?(@input_buffer) && entry.length > @input_buffer.length
|
|
940
|
+
return entry[@input_buffer.length..]
|
|
941
|
+
end
|
|
942
|
+
end
|
|
943
|
+
''
|
|
944
|
+
rescue
|
|
945
|
+
''
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
def at_end_with_suggestion?
|
|
949
|
+
@input_cursor >= @input_buffer.length && !@autosuggestion.empty?
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
# Accept the entire pending suggestion: splice it in at the cursor.
|
|
953
|
+
# `insert_at_cursor` routes through `replace_input_line`, which
|
|
954
|
+
# recomputes the now-empty suggestion against the new buffer.
|
|
955
|
+
def accept_full_autosuggestion
|
|
956
|
+
return if @autosuggestion.empty?
|
|
957
|
+
insert_at_cursor(@autosuggestion)
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
# Accept just the next word (run of whitespace + run of non-whitespace)
|
|
961
|
+
# from the pending suggestion. Mirrors fish's accept-autosuggestion-word.
|
|
962
|
+
def accept_word_of_autosuggestion
|
|
963
|
+
return if @autosuggestion.empty?
|
|
964
|
+
s = @autosuggestion
|
|
965
|
+
i = 0
|
|
966
|
+
i += 1 while i < s.length && s[i] == ' '
|
|
967
|
+
i += 1 while i < s.length && s[i] != ' '
|
|
968
|
+
insert_at_cursor(s[0, i])
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
# ---- Ctrl-R reverse-incremental history search.
|
|
972
|
+
#
|
|
973
|
+
# While in :search mode, the current line on screen is replaced with
|
|
974
|
+
# (reverse-i-search)`query': matched-history-line
|
|
975
|
+
# Typed chars narrow the query; Ctrl-R jumps to the next older match;
|
|
976
|
+
# Enter accepts the match and submits it; Esc / Ctrl-G cancels and
|
|
977
|
+
# restores the original input. Other keys (arrows, etc.) cancel the
|
|
978
|
+
# search and re-process the keystroke in :prompt mode — so e.g. ↑
|
|
979
|
+
# exits search and walks the regular history.
|
|
980
|
+
|
|
981
|
+
def enter_search
|
|
982
|
+
@input_mode = :search
|
|
983
|
+
@search_query = +''
|
|
984
|
+
@search_index = nil
|
|
985
|
+
@search_saved_buffer = @input_buffer.dup
|
|
986
|
+
@search_saved_cursor = @input_cursor
|
|
987
|
+
@search_saved_autosuggestion = @autosuggestion.dup
|
|
988
|
+
render_search
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
def handle_search_key(chars, flags)
|
|
992
|
+
ctrl = (flags & NSEVENT_CONTROL_FLAG) != 0
|
|
993
|
+
if ctrl && chars.length == 1 && chars.ord >= 0x20
|
|
994
|
+
case chars.downcase
|
|
995
|
+
when 'r' then search_step_back; return true
|
|
996
|
+
when 'g' then exit_search(:cancel); return true
|
|
997
|
+
when 'h' then search_backspace; return true
|
|
998
|
+
end
|
|
999
|
+
end
|
|
1000
|
+
|
|
1001
|
+
case chars
|
|
1002
|
+
when "\e"
|
|
1003
|
+
exit_search(:cancel)
|
|
1004
|
+
when "\r", "\n"
|
|
1005
|
+
exit_search(:accept_and_submit)
|
|
1006
|
+
when "\u{7F}", "\b"
|
|
1007
|
+
search_backspace
|
|
1008
|
+
else
|
|
1009
|
+
ord = chars.length == 1 ? chars.ord : nil
|
|
1010
|
+
# Accept printable input: ASCII printable plus normal Unicode.
|
|
1011
|
+
# Exclude the macOS NSEvent special-key range (U+F700–F7FF) —
|
|
1012
|
+
# those are arrows / Home / End / Delete and should fall through
|
|
1013
|
+
# to the cancel-and-reprocess path so the user lands back in
|
|
1014
|
+
# prompt mode and the keystroke runs there.
|
|
1015
|
+
if !ctrl && ord && ord >= 0x20 && (ord < 0xF700 || ord > 0xF7FF)
|
|
1016
|
+
@search_query << chars
|
|
1017
|
+
restart_search_from_end
|
|
1018
|
+
render_search
|
|
1019
|
+
else
|
|
1020
|
+
exit_search(:cancel)
|
|
1021
|
+
return handle_key(chars: chars, flags: flags)
|
|
1022
|
+
end
|
|
1023
|
+
end
|
|
1024
|
+
true
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
def search_backspace
|
|
1028
|
+
return if @search_query.empty?
|
|
1029
|
+
@search_query.chop!
|
|
1030
|
+
restart_search_from_end
|
|
1031
|
+
render_search
|
|
1032
|
+
end
|
|
1033
|
+
|
|
1034
|
+
# After the query changes, find the newest history entry that
|
|
1035
|
+
# contains the new query. nil index means no match.
|
|
1036
|
+
def restart_search_from_end
|
|
1037
|
+
hist = @embedded_shell.history
|
|
1038
|
+
@search_index = nil
|
|
1039
|
+
return if @search_query.empty?
|
|
1040
|
+
(hist.size - 1).downto(0) do |i|
|
|
1041
|
+
entry = hist[i]
|
|
1042
|
+
next if entry.nil? || entry.include?("\n")
|
|
1043
|
+
if entry.include?(@search_query)
|
|
1044
|
+
@search_index = i
|
|
1045
|
+
return
|
|
1046
|
+
end
|
|
1047
|
+
end
|
|
1048
|
+
end
|
|
1049
|
+
|
|
1050
|
+
# Ctrl-R while already in search: jump to the next older match for
|
|
1051
|
+
# the current query. If none, leave the index alone.
|
|
1052
|
+
def search_step_back
|
|
1053
|
+
hist = @embedded_shell.history
|
|
1054
|
+
return if @search_query.empty?
|
|
1055
|
+
start_idx = (@search_index || hist.size) - 1
|
|
1056
|
+
start_idx.downto(0) do |i|
|
|
1057
|
+
entry = hist[i]
|
|
1058
|
+
next if entry.nil? || entry.include?("\n")
|
|
1059
|
+
if entry.include?(@search_query)
|
|
1060
|
+
@search_index = i
|
|
1061
|
+
render_search
|
|
1062
|
+
return
|
|
1063
|
+
end
|
|
1064
|
+
end
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
def render_search
|
|
1068
|
+
hist = @embedded_shell.history
|
|
1069
|
+
matched = (@search_index ? hist[@search_index] : nil) || ''
|
|
1070
|
+
display = "(reverse-i-search)`#{@search_query}': #{matched}"
|
|
1071
|
+
process_output("\r\e[K")
|
|
1072
|
+
process_output(display)
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
def exit_search(action)
|
|
1076
|
+
hist = @embedded_shell.history
|
|
1077
|
+
matched = @search_index ? hist[@search_index] : nil
|
|
1078
|
+
|
|
1079
|
+
case action
|
|
1080
|
+
when :accept_and_submit
|
|
1081
|
+
accepted = matched || @search_saved_buffer || ''
|
|
1082
|
+
@input_buffer = +accepted
|
|
1083
|
+
@input_cursor = @input_buffer.length
|
|
1084
|
+
@autosuggestion = +''
|
|
1085
|
+
@input_mode = :prompt
|
|
1086
|
+
@search_query = +''
|
|
1087
|
+
@search_index = nil
|
|
1088
|
+
@search_saved_buffer = nil
|
|
1089
|
+
@search_saved_cursor = nil
|
|
1090
|
+
@search_saved_autosuggestion = nil
|
|
1091
|
+
process_output("\r\e[K")
|
|
1092
|
+
render_prompt_natively
|
|
1093
|
+
render_input_area
|
|
1094
|
+
submit_or_continue
|
|
1095
|
+
else # :cancel
|
|
1096
|
+
@input_buffer = @search_saved_buffer || +''
|
|
1097
|
+
@input_cursor = @search_saved_cursor || 0
|
|
1098
|
+
@autosuggestion = @search_saved_autosuggestion || +''
|
|
1099
|
+
@input_mode = :prompt
|
|
1100
|
+
@search_query = +''
|
|
1101
|
+
@search_index = nil
|
|
1102
|
+
@search_saved_buffer = nil
|
|
1103
|
+
@search_saved_cursor = nil
|
|
1104
|
+
@search_saved_autosuggestion = nil
|
|
1105
|
+
process_output("\r\e[K")
|
|
1106
|
+
render_prompt_natively
|
|
1107
|
+
render_input_area
|
|
1108
|
+
end
|
|
1109
|
+
end
|
|
1110
|
+
|
|
1111
|
+
def sgr_for_token(tok, command_position)
|
|
1112
|
+
if tok.type == :WORD
|
|
1113
|
+
first = tok.value.to_s[0]
|
|
1114
|
+
return "\e[32m" if first == '"' || first == "'"
|
|
1115
|
+
return "\e[1m" if command_position
|
|
1116
|
+
nil
|
|
1117
|
+
else
|
|
1118
|
+
TOKEN_COLOR_MAP[tok.type]
|
|
1119
|
+
end
|
|
1120
|
+
end
|
|
1121
|
+
end
|
|
1122
|
+
end
|