muxr 0.1.1 → 0.1.3
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 +4 -4
- data/CHANGELOG.md +42 -1
- data/README.md +45 -8
- data/lib/muxr/application.rb +89 -24
- data/lib/muxr/client.rb +40 -7
- data/lib/muxr/input_handler.rb +31 -8
- data/lib/muxr/pane.rb +12 -0
- data/lib/muxr/protocol.rb +12 -1
- data/lib/muxr/pty_process.rb +34 -2
- data/lib/muxr/renderer.rb +4 -2
- data/lib/muxr/terminal.rb +131 -1
- data/lib/muxr/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d19432b15ff535a2ce8f4bfb5b759ddd3599b4fb0af949d7871cf1059497c483
|
|
4
|
+
data.tar.gz: ce58f726a5a9dc186deb042c0d0e31fa8694676d0cdb4c496cfb57668d5fd4c1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5b96c66aa1b96e853e7d268c012f8bc5e62fdf77c1e3d4e89360b764d4cfc1d20bdb271be81acad05f26417d09e17fe27f437680cb00a33fcf6f2c934600fef2
|
|
7
|
+
data.tar.gz: '045758a0d3ec1e4f2d6bfe4bb887743cc2ab80310bd842190b362e9a9976fd023b8818c35d13f80478839bbbb12d91372b22ddabc91ee430afda8c3ce3b0853a'
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,46 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.3] - 2026-05-11
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Large pastes into a pane no longer hang the server. PTY writes are now
|
|
13
|
+
non-blocking and buffered per pane, with the writer fd added to the
|
|
14
|
+
event loop's `IO.select` write set so back-pressure from a slow reader
|
|
15
|
+
(e.g. Claude Code processing a multi-KB paste) can't deadlock the
|
|
16
|
+
single-threaded server. Idle pass-through input is also batched into a
|
|
17
|
+
single `send_to_focused` chunk instead of one call per byte.
|
|
18
|
+
- Client↔server socket writes are now non-blocking too, with per-side
|
|
19
|
+
outgoing buffers and the socket added to `IO.select`'s write set when
|
|
20
|
+
there's queued data. The previous blocking `Protocol.write` could
|
|
21
|
+
deadlock both ends when a paste produced enough redraw traffic to fill
|
|
22
|
+
both directions of the unix-socket kernel buffer at once (vim and
|
|
23
|
+
Claude Code both reproduced this).
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- Enable bracketed paste mode (`\e[?2004h`) on the outer terminal when
|
|
27
|
+
the client attaches. The terminal emulator now wraps pastes with
|
|
28
|
+
`\e[200~...\e[201~`, those markers flow through muxr to the focused
|
|
29
|
+
pane, and apps that opt in (Claude Code, vim, modern readline) again
|
|
30
|
+
recognise the input as a paste — Claude Code collapses it to
|
|
31
|
+
`[Pasted text +N lines]` instead of typing the whole thing out.
|
|
32
|
+
|
|
33
|
+
## [0.1.2] - 2026-05-11
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
- Vim-style word and viewport motions in copy-mode selection cursor:
|
|
37
|
+
`w`/`W`/`e`/`E`/`b`/`B` walk word and WORD boundaries, `^` jumps to
|
|
38
|
+
the first non-blank on the line, and `H`/`M`/`L` land on the visible
|
|
39
|
+
top/middle/bottom rows. Yanking now drops straight back to the live
|
|
40
|
+
shell (matching vim's `v…y` returning to normal mode); the tmux-style
|
|
41
|
+
`b` alias for page-back is now `Ctrl-b` only.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
- Honor SGR 2 (dim) so faint text actually renders faint. The emulator
|
|
45
|
+
was silently dropping the attribute, which left Claude Code's
|
|
46
|
+
suggested-prompt placeholder rendering at normal intensity. SGR 22
|
|
47
|
+
now correctly clears both bold and dim per spec.
|
|
48
|
+
|
|
9
49
|
## [0.1.1] - 2026-05-11
|
|
10
50
|
|
|
11
51
|
### Fixed
|
|
@@ -51,6 +91,7 @@ Initial release.
|
|
|
51
91
|
boundaries.
|
|
52
92
|
- Renderer that composes one frame and diff-emits ANSI to STDOUT.
|
|
53
93
|
|
|
54
|
-
[Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.
|
|
94
|
+
[Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.2...HEAD
|
|
95
|
+
[0.1.2]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.2
|
|
55
96
|
[0.1.1]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.1
|
|
56
97
|
[0.1.0]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -20,6 +20,27 @@ the active layout decides geometry.
|
|
|
20
20
|
[default] panes:3 layout:tall focused:#1 drawer:shown muxr ^a ?
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
## Screenshots
|
|
24
|
+
|
|
25
|
+
The three built-in layouts (cycle with `C-a Tab`):
|
|
26
|
+
|
|
27
|
+
<table>
|
|
28
|
+
<tr>
|
|
29
|
+
<td align="center"><strong>tall</strong><br/>master + stacked slaves</td>
|
|
30
|
+
<td align="center"><strong>grid</strong><br/>even tiling</td>
|
|
31
|
+
<td align="center"><strong>monocle</strong><br/>focused pane fullscreen</td>
|
|
32
|
+
</tr>
|
|
33
|
+
<tr>
|
|
34
|
+
<td><img src="docs/screenshots/01-layout-tall.png" alt="tall layout"></td>
|
|
35
|
+
<td><img src="docs/screenshots/02-layout-grid.png" alt="grid layout"></td>
|
|
36
|
+
<td><img src="docs/screenshots/03-layout-monocle.png" alt="monocle layout"></td>
|
|
37
|
+
</tr>
|
|
38
|
+
</table>
|
|
39
|
+
|
|
40
|
+
The Quake-style drawer overlay (`C-a ~`):
|
|
41
|
+
|
|
42
|
+

|
|
43
|
+
|
|
23
44
|
## Install / run
|
|
24
45
|
|
|
25
46
|
```bash
|
|
@@ -70,17 +91,33 @@ the pane title gains `[scrollback N/M]`.
|
|
|
70
91
|
|-------------------------|-------------------------------------|
|
|
71
92
|
| `j` / `k` | scroll one line |
|
|
72
93
|
| `d` / `u` (or `C-d`/`C-u`) | half page |
|
|
73
|
-
| `f` /
|
|
94
|
+
| `f` / Space (or `C-f`/`C-b`) | full page |
|
|
74
95
|
| `g` / `G` | top / bottom |
|
|
75
96
|
| `q` / `Esc` / `C-c` | exit back to live view |
|
|
76
97
|
|
|
77
|
-
Press `v` inside scrollback to enter a movable-cursor selection mode
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
`
|
|
83
|
-
|
|
98
|
+
Press `v` inside scrollback to enter a movable-cursor selection mode.
|
|
99
|
+
Vim-style motions are supported:
|
|
100
|
+
|
|
101
|
+
| Keys | Action |
|
|
102
|
+
|-------------------------|-------------------------------------|
|
|
103
|
+
| `h` / `j` / `k` / `l` | left / down / up / right |
|
|
104
|
+
| `0` / `^` / `$` | line start / first non-blank / line end |
|
|
105
|
+
| `w` / `W` | next word / WORD start |
|
|
106
|
+
| `e` / `E` | next word / WORD end |
|
|
107
|
+
| `b` / `B` | previous word / WORD start |
|
|
108
|
+
| `g` / `G` | top / bottom of timeline |
|
|
109
|
+
| `H` / `M` / `L` | top / middle / bottom of viewport |
|
|
110
|
+
| `C-d`/`C-u`, `C-f`/`C-b`, Space | half / full page |
|
|
111
|
+
| `v` / `C-v` | anchor char / block selection (toggle) |
|
|
112
|
+
| `y` or Enter | yank and exit to live shell |
|
|
113
|
+
| `q` / `Esc` / `C-c` | cancel back to scrollback |
|
|
114
|
+
|
|
115
|
+
`v` and `C-v` toggle between character and block (rectangular) selection
|
|
116
|
+
— switching between the two preserves the anchor. `y` or Enter yanks the
|
|
117
|
+
selection into an internal buffer, pipes it to `pbcopy` in the background
|
|
118
|
+
(silent no-op when `pbcopy` is unavailable), and drops you straight back
|
|
119
|
+
to the live shell. `C-a ]` writes the yank buffer back into the focused
|
|
120
|
+
pane.
|
|
84
121
|
|
|
85
122
|
## Commands (typed after `C-a :`)
|
|
86
123
|
|
data/lib/muxr/application.rb
CHANGED
|
@@ -57,6 +57,7 @@ module Muxr
|
|
|
57
57
|
@help_visible = false
|
|
58
58
|
@next_pane_id = 0
|
|
59
59
|
@current_client = nil
|
|
60
|
+
@client_write_buffer = +"".b
|
|
60
61
|
@listening_socket = nil
|
|
61
62
|
@socket_path = self.class.socket_path_for(@session_name)
|
|
62
63
|
@paste_buffer = +""
|
|
@@ -302,6 +303,7 @@ module Muxr
|
|
|
302
303
|
def exit_selection(yank:)
|
|
303
304
|
target = focused_target
|
|
304
305
|
term = target&.terminal
|
|
306
|
+
yanked = false
|
|
305
307
|
if yank
|
|
306
308
|
# No anchor → no-op. User is still positioning; they can press v
|
|
307
309
|
# first, then yank. Esc/q is the way to exit from navigation.
|
|
@@ -311,10 +313,18 @@ module Muxr
|
|
|
311
313
|
@paste_buffer = text
|
|
312
314
|
spawn_pbcopy(text)
|
|
313
315
|
flash("yanked #{text.bytesize} bytes")
|
|
316
|
+
yanked = true
|
|
314
317
|
end
|
|
315
318
|
end
|
|
316
319
|
term&.clear_selection
|
|
317
|
-
|
|
320
|
+
if yanked
|
|
321
|
+
# vim-style: yanking drops you straight back to "normal" (idle),
|
|
322
|
+
# not back into scrollback navigation.
|
|
323
|
+
term&.scroll_to_bottom
|
|
324
|
+
@input.enter_idle_mode
|
|
325
|
+
else
|
|
326
|
+
@input.enter_scrollback_mode
|
|
327
|
+
end
|
|
318
328
|
@renderer.reset_frame!
|
|
319
329
|
invalidate
|
|
320
330
|
end
|
|
@@ -336,8 +346,18 @@ module Muxr
|
|
|
336
346
|
when :full_down then term.move_selection_cursor_by([rows - 1, 1].max, 0)
|
|
337
347
|
when :line_start then term.selection_cursor_to_line_start
|
|
338
348
|
when :line_end then term.selection_cursor_to_line_end
|
|
339
|
-
when :
|
|
340
|
-
when :
|
|
349
|
+
when :line_first_nonblank then term.selection_cursor_to_first_non_blank
|
|
350
|
+
when :top then term.selection_cursor_to_top
|
|
351
|
+
when :bottom then term.selection_cursor_to_bottom
|
|
352
|
+
when :screen_top then term.selection_cursor_to_viewport(:top)
|
|
353
|
+
when :screen_middle then term.selection_cursor_to_viewport(:middle)
|
|
354
|
+
when :screen_bottom then term.selection_cursor_to_viewport(:bottom)
|
|
355
|
+
when :word_forward then term.selection_cursor_word_forward(big: false)
|
|
356
|
+
when :word_forward_big then term.selection_cursor_word_forward(big: true)
|
|
357
|
+
when :word_end then term.selection_cursor_word_end(big: false)
|
|
358
|
+
when :word_end_big then term.selection_cursor_word_end(big: true)
|
|
359
|
+
when :word_backward then term.selection_cursor_word_backward(big: false)
|
|
360
|
+
when :word_backward_big then term.selection_cursor_word_backward(big: true)
|
|
341
361
|
end
|
|
342
362
|
invalidate
|
|
343
363
|
end
|
|
@@ -382,12 +402,29 @@ module Muxr
|
|
|
382
402
|
end
|
|
383
403
|
end
|
|
384
404
|
|
|
385
|
-
# Called by the FramedOutput adapter;
|
|
386
|
-
# currently attached client
|
|
405
|
+
# Called by the FramedOutput adapter; queues one OUTPUT frame to the
|
|
406
|
+
# currently attached client and tries to push as much as the socket
|
|
407
|
+
# will take without blocking. Anything left over stays in
|
|
408
|
+
# @client_write_buffer and gets flushed by the event loop when the
|
|
409
|
+
# socket reports writable. This prevents a slow client (or slow
|
|
410
|
+
# terminal upstream of the client) from deadlocking the server when
|
|
411
|
+
# the server is also trying to read from that same client.
|
|
387
412
|
def deliver_output(bytes)
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
413
|
+
return unless @current_client
|
|
414
|
+
@client_write_buffer << Protocol.frame(Protocol::OUTPUT, bytes)
|
|
415
|
+
drain_client_writes
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def drain_client_writes
|
|
419
|
+
return unless @current_client
|
|
420
|
+
return if @client_write_buffer.empty?
|
|
421
|
+
loop do
|
|
422
|
+
n = @current_client.write_nonblock(@client_write_buffer)
|
|
423
|
+
@client_write_buffer = @client_write_buffer.byteslice(n..-1) || +"".b
|
|
424
|
+
break if @client_write_buffer.empty?
|
|
425
|
+
end
|
|
426
|
+
rescue IO::WaitWritable
|
|
427
|
+
# Socket send buffer is full; the rest stays queued.
|
|
391
428
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
392
429
|
drop_client_silently
|
|
393
430
|
end
|
|
@@ -460,25 +497,40 @@ module Muxr
|
|
|
460
497
|
|
|
461
498
|
def loop_forever
|
|
462
499
|
while @running
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
@session.window.panes.each { |p|
|
|
466
|
-
|
|
467
|
-
|
|
500
|
+
read_ios = [@listening_socket]
|
|
501
|
+
read_ios << @current_client if @current_client
|
|
502
|
+
@session.window.panes.each { |p| read_ios << p.io if p.alive? }
|
|
503
|
+
drawer_pane = @session.drawer&.pane
|
|
504
|
+
read_ios << drawer_pane.io if drawer_pane&.alive?
|
|
505
|
+
|
|
506
|
+
write_ios = []
|
|
507
|
+
@session.window.panes.each do |p|
|
|
508
|
+
write_ios << p.writer_io if p.alive? && p.pending_write?
|
|
468
509
|
end
|
|
510
|
+
if drawer_pane&.alive? && drawer_pane.pending_write?
|
|
511
|
+
write_ios << drawer_pane.writer_io
|
|
512
|
+
end
|
|
513
|
+
write_ios << @current_client if @current_client && !@client_write_buffer.empty?
|
|
469
514
|
|
|
470
515
|
timeout = @message ? 0.25 : SELECT_TIMEOUT
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
516
|
+
ready_r, ready_w, = IO.select(read_ios, write_ios, nil, timeout)
|
|
517
|
+
|
|
518
|
+
ready_r&.each do |io|
|
|
519
|
+
if io == @listening_socket
|
|
520
|
+
accept_client
|
|
521
|
+
elsif io == @current_client
|
|
522
|
+
consume_client_frame
|
|
523
|
+
else
|
|
524
|
+
consume_pane_io(io)
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
ready_w&.each do |io|
|
|
529
|
+
if io == @current_client
|
|
530
|
+
drain_client_writes
|
|
531
|
+
else
|
|
532
|
+
pane = pane_for_writer_io(io)
|
|
533
|
+
pane&.drain_writes
|
|
482
534
|
end
|
|
483
535
|
end
|
|
484
536
|
|
|
@@ -560,6 +612,13 @@ module Muxr
|
|
|
560
612
|
nil
|
|
561
613
|
end
|
|
562
614
|
|
|
615
|
+
def pane_for_writer_io(io)
|
|
616
|
+
pane = @session.window.panes.find { |p| p.writer_io == io }
|
|
617
|
+
return pane if pane
|
|
618
|
+
return @session.drawer.pane if @session.drawer&.pane && @session.drawer.pane.writer_io == io
|
|
619
|
+
nil
|
|
620
|
+
end
|
|
621
|
+
|
|
563
622
|
def prune_dead_panes
|
|
564
623
|
dead = @session.window.panes.reject(&:alive?)
|
|
565
624
|
return if dead.empty?
|
|
@@ -593,6 +652,11 @@ module Muxr
|
|
|
593
652
|
|
|
594
653
|
def disconnect_client(reason: nil)
|
|
595
654
|
return unless @current_client
|
|
655
|
+
# Best-effort: drop any queued OUTPUT (the client is going away),
|
|
656
|
+
# send a final BYE, then close. BYE is small enough that one
|
|
657
|
+
# blocking write won't meaningfully wedge anything even if the
|
|
658
|
+
# client's recv is sluggish.
|
|
659
|
+
@client_write_buffer = +"".b
|
|
596
660
|
safe_protocol_write(@current_client, Protocol::BYE, reason || "")
|
|
597
661
|
@current_client.close rescue nil
|
|
598
662
|
@current_client = nil
|
|
@@ -602,6 +666,7 @@ module Muxr
|
|
|
602
666
|
return unless @current_client
|
|
603
667
|
@current_client.close rescue nil
|
|
604
668
|
@current_client = nil
|
|
669
|
+
@client_write_buffer = +"".b
|
|
605
670
|
end
|
|
606
671
|
|
|
607
672
|
def safe_protocol_write(io, type, payload = "")
|
data/lib/muxr/client.rb
CHANGED
|
@@ -24,6 +24,7 @@ module Muxr
|
|
|
24
24
|
@resize_pending = false
|
|
25
25
|
@exit_code = 0
|
|
26
26
|
@bye_reason = nil
|
|
27
|
+
@write_buffer = +"".b
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
# Opens the socket. Returns true on success. Raises Errno::ENOENT /
|
|
@@ -62,28 +63,55 @@ module Muxr
|
|
|
62
63
|
send_resize
|
|
63
64
|
end
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
write_ios = @write_buffer.empty? ? nil : [@sock]
|
|
67
|
+
ready_r, ready_w, = IO.select([STDIN, @sock], write_ios, nil, SELECT_TIMEOUT)
|
|
68
|
+
next unless ready_r || ready_w
|
|
67
69
|
|
|
68
|
-
|
|
70
|
+
ready_r&.each do |io|
|
|
69
71
|
if io == STDIN
|
|
70
72
|
forward_stdin
|
|
71
73
|
else
|
|
72
74
|
consume_server_frame
|
|
73
75
|
end
|
|
74
76
|
end
|
|
77
|
+
|
|
78
|
+
drain_writes if ready_w&.include?(@sock)
|
|
75
79
|
end
|
|
76
80
|
end
|
|
77
81
|
|
|
78
82
|
def forward_stdin
|
|
79
83
|
data = STDIN.read_nonblock(4096)
|
|
80
|
-
|
|
84
|
+
queue_frame(Protocol::INPUT, data)
|
|
81
85
|
rescue IO::WaitReadable
|
|
82
86
|
# spurious wake-up; nothing to do.
|
|
83
87
|
rescue EOFError, Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
84
88
|
@running = false
|
|
85
89
|
end
|
|
86
90
|
|
|
91
|
+
def queue_frame(type, payload)
|
|
92
|
+
@write_buffer << Protocol.frame(type, payload)
|
|
93
|
+
drain_writes
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Push as much of @write_buffer to the server as the socket will
|
|
97
|
+
# accept without blocking. Anything left over stays queued and the
|
|
98
|
+
# event loop picks it back up when select reports the socket
|
|
99
|
+
# writable. Mirrors the server-side OUTPUT drain — without it, a
|
|
100
|
+
# busy server (rendering large vim/Claude-code redraws) could fill
|
|
101
|
+
# this socket's send buffer and wedge the client mid-paste.
|
|
102
|
+
def drain_writes
|
|
103
|
+
return if @write_buffer.empty?
|
|
104
|
+
loop do
|
|
105
|
+
n = @sock.write_nonblock(@write_buffer)
|
|
106
|
+
@write_buffer = @write_buffer.byteslice(n..-1) || +"".b
|
|
107
|
+
break if @write_buffer.empty?
|
|
108
|
+
end
|
|
109
|
+
rescue IO::WaitWritable
|
|
110
|
+
# Socket send buffer full; remainder stays queued.
|
|
111
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
112
|
+
@running = false
|
|
113
|
+
end
|
|
114
|
+
|
|
87
115
|
def consume_server_frame
|
|
88
116
|
type, payload = Protocol.read(@sock)
|
|
89
117
|
if type.nil?
|
|
@@ -106,7 +134,7 @@ module Muxr
|
|
|
106
134
|
|
|
107
135
|
def send_resize
|
|
108
136
|
rows, cols = terminal_size
|
|
109
|
-
|
|
137
|
+
queue_frame(Protocol::RESIZE, Protocol.encode_size(rows, cols))
|
|
110
138
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
111
139
|
@running = false
|
|
112
140
|
end
|
|
@@ -124,12 +152,17 @@ module Muxr
|
|
|
124
152
|
def enter_terminal_mode
|
|
125
153
|
STDIN.raw!
|
|
126
154
|
STDIN.echo = false
|
|
127
|
-
|
|
155
|
+
# Enable bracketed paste on the outer terminal so iTerm/Terminal/etc.
|
|
156
|
+
# wrap pastes with \e[200~...\e[201~. Those markers flow through
|
|
157
|
+
# untouched to the focused pane's PTY, which lets Claude Code, vim,
|
|
158
|
+
# modern bash, etc. recognise the input as a paste and collapse it
|
|
159
|
+
# instead of typing it out character-by-character.
|
|
160
|
+
STDOUT.write("\e[?1049h\e[?25l\e[2J\e[H\e[0m\e[?2004h")
|
|
128
161
|
STDOUT.flush
|
|
129
162
|
end
|
|
130
163
|
|
|
131
164
|
def leave_terminal_mode
|
|
132
|
-
STDOUT.write("\e[0m\e[?25h\e[?1049l")
|
|
165
|
+
STDOUT.write("\e[?2004l\e[0m\e[?25h\e[?1049l")
|
|
133
166
|
STDOUT.flush
|
|
134
167
|
begin
|
|
135
168
|
STDIN.cooked!
|
data/lib/muxr/input_handler.rb
CHANGED
|
@@ -48,8 +48,19 @@ module Muxr
|
|
|
48
48
|
"k" => :up,
|
|
49
49
|
"0" => :line_start,
|
|
50
50
|
"$" => :line_end,
|
|
51
|
+
"^" => :line_first_nonblank,
|
|
51
52
|
"g" => :top,
|
|
52
53
|
"G" => :bottom,
|
|
54
|
+
"H" => :screen_top,
|
|
55
|
+
"M" => :screen_middle,
|
|
56
|
+
"L" => :screen_bottom,
|
|
57
|
+
"w" => :word_forward,
|
|
58
|
+
"W" => :word_forward_big,
|
|
59
|
+
"e" => :word_end,
|
|
60
|
+
"E" => :word_end_big,
|
|
61
|
+
# `b` is vim word-back here; the tmux-style page-back alias lives on Ctrl-b.
|
|
62
|
+
"b" => :word_backward,
|
|
63
|
+
"B" => :word_backward_big,
|
|
53
64
|
"\x04" => :half_down, # Ctrl-d
|
|
54
65
|
"\x15" => :half_up, # Ctrl-u
|
|
55
66
|
"d" => :half_down,
|
|
@@ -57,7 +68,6 @@ module Muxr
|
|
|
57
68
|
"\x06" => :full_down, # Ctrl-f
|
|
58
69
|
"\x02" => :full_up, # Ctrl-b
|
|
59
70
|
"f" => :full_down,
|
|
60
|
-
"b" => :full_up,
|
|
61
71
|
" " => :full_down
|
|
62
72
|
}.freeze
|
|
63
73
|
|
|
@@ -75,19 +85,32 @@ module Muxr
|
|
|
75
85
|
end
|
|
76
86
|
|
|
77
87
|
def feed(data)
|
|
78
|
-
|
|
88
|
+
remaining = data
|
|
89
|
+
until remaining.empty?
|
|
90
|
+
if @state == :idle
|
|
91
|
+
# Fast path for pass-through: forward everything up to the next
|
|
92
|
+
# Ctrl-a as a single chunk so a large paste doesn't turn into one
|
|
93
|
+
# PTY write per byte. PREFIX is single-byte ASCII (\x01) and never
|
|
94
|
+
# appears mid-UTF-8, so byte/char index match.
|
|
95
|
+
idx = remaining.index(PREFIX)
|
|
96
|
+
if idx.nil?
|
|
97
|
+
@app.send_to_focused(remaining)
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
@app.send_to_focused(remaining[0...idx]) if idx > 0
|
|
101
|
+
@state = :prefix
|
|
102
|
+
remaining = remaining[(idx + 1)..] || ""
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
ch = remaining[0]
|
|
107
|
+
remaining = remaining[1..] || ""
|
|
79
108
|
case @state
|
|
80
109
|
when :help
|
|
81
110
|
@app.dismiss_help
|
|
82
111
|
@state = :idle
|
|
83
112
|
when :confirm_quit
|
|
84
113
|
handle_confirm_quit(ch)
|
|
85
|
-
when :idle
|
|
86
|
-
if ch == PREFIX
|
|
87
|
-
@state = :prefix
|
|
88
|
-
else
|
|
89
|
-
@app.send_to_focused(ch)
|
|
90
|
-
end
|
|
91
114
|
when :prefix
|
|
92
115
|
handle_prefix(ch)
|
|
93
116
|
when :command
|
data/lib/muxr/pane.rb
CHANGED
|
@@ -20,6 +20,18 @@ module Muxr
|
|
|
20
20
|
@process.io
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def writer_io
|
|
24
|
+
@process.writer_io
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def pending_write?
|
|
28
|
+
@process.pending_write?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def drain_writes
|
|
32
|
+
@process.drain
|
|
33
|
+
end
|
|
34
|
+
|
|
23
35
|
def write(data)
|
|
24
36
|
@process.write(data)
|
|
25
37
|
end
|
data/lib/muxr/protocol.rb
CHANGED
|
@@ -39,9 +39,20 @@ module Muxr
|
|
|
39
39
|
|
|
40
40
|
# Writes one framed message. +payload+ is treated as raw bytes (binary).
|
|
41
41
|
def self.write(io, type, payload = "")
|
|
42
|
+
io.write(frame(type, payload))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Builds the on-the-wire bytes for a single frame without writing them.
|
|
46
|
+
# Lets callers append to an outgoing buffer (for non-blocking flushes
|
|
47
|
+
# later) instead of doing a synchronous io.write.
|
|
48
|
+
def self.frame(type, payload = "")
|
|
42
49
|
raise ArgumentError, "type must be a single byte" unless type.is_a?(String) && type.bytesize == 1
|
|
43
50
|
bytes = payload.to_s.b
|
|
44
|
-
|
|
51
|
+
buf = +"".b
|
|
52
|
+
buf << type.b
|
|
53
|
+
buf << [bytes.bytesize].pack("N")
|
|
54
|
+
buf << bytes
|
|
55
|
+
buf
|
|
45
56
|
end
|
|
46
57
|
|
|
47
58
|
# Encodes a "ROWS COLS" string for HELLO / RESIZE payloads.
|
data/lib/muxr/pty_process.rb
CHANGED
|
@@ -10,6 +10,7 @@ module Muxr
|
|
|
10
10
|
@rows = rows
|
|
11
11
|
@cols = cols
|
|
12
12
|
@exited = false
|
|
13
|
+
@write_buffer = +"".b
|
|
13
14
|
|
|
14
15
|
shell = command || ENV["SHELL"] || "/bin/sh"
|
|
15
16
|
env = ENV.to_h.merge("TERM" => "xterm-256color").merge(env_overrides)
|
|
@@ -23,11 +24,42 @@ module Muxr
|
|
|
23
24
|
resize(rows, cols)
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
# Appends bytes to the per-process outgoing buffer and tries to flush as
|
|
28
|
+
# many as the PTY will accept right now. Any remainder stays buffered;
|
|
29
|
+
# the event loop drains it later when the writer fd is reported writable.
|
|
30
|
+
# This avoids deadlocking the single-threaded server when the inner
|
|
31
|
+
# program is slow to read (large pastes were the original motivating
|
|
32
|
+
# case — see CHANGELOG 0.1.3).
|
|
26
33
|
def write(data)
|
|
27
|
-
@
|
|
28
|
-
|
|
34
|
+
return if @exited
|
|
35
|
+
return if data.nil? || data.empty?
|
|
36
|
+
@write_buffer << data.b
|
|
37
|
+
drain
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Push as much of @write_buffer through the PTY as it'll take without
|
|
41
|
+
# blocking. Safe to call repeatedly — both from write() and from the
|
|
42
|
+
# event loop when select reports the writer fd writable.
|
|
43
|
+
def drain
|
|
44
|
+
return if @exited || @write_buffer.empty?
|
|
45
|
+
loop do
|
|
46
|
+
n = @writer.write_nonblock(@write_buffer)
|
|
47
|
+
@write_buffer = @write_buffer.byteslice(n..-1) || +"".b
|
|
48
|
+
break if @write_buffer.empty?
|
|
49
|
+
end
|
|
50
|
+
rescue IO::WaitWritable
|
|
51
|
+
# Kernel buffer full; the rest stays queued.
|
|
29
52
|
rescue Errno::EIO, IOError, Errno::EPIPE
|
|
30
53
|
@exited = true
|
|
54
|
+
@write_buffer.clear
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pending_write?
|
|
58
|
+
!@write_buffer.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def writer_io
|
|
62
|
+
@writer
|
|
31
63
|
end
|
|
32
64
|
|
|
33
65
|
def read_nonblock(max = 8192)
|
data/lib/muxr/renderer.rb
CHANGED
|
@@ -264,8 +264,9 @@ module Muxr
|
|
|
264
264
|
" C-a Tab cycle layout (tall → grid → monocle)",
|
|
265
265
|
" C-a Enter promote focused pane to master",
|
|
266
266
|
" C-a ~ toggle drawer",
|
|
267
|
-
" C-a [ enter scrollback (j/k d/u f
|
|
268
|
-
"
|
|
267
|
+
" C-a [ enter scrollback (j/k d/u f g/G C-b/C-f; v→cursor, q quits)",
|
|
268
|
+
" cursor: v select, C-v block, y yank, q cancel",
|
|
269
|
+
" motions: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
|
|
269
270
|
" C-a ] paste internal copy buffer",
|
|
270
271
|
" C-a d detach (server keeps running)",
|
|
271
272
|
" C-a q kill session (asks y/n)",
|
|
@@ -439,6 +440,7 @@ module Muxr
|
|
|
439
440
|
parts = ["0"]
|
|
440
441
|
attrs = cell.attrs.to_i
|
|
441
442
|
parts << "1" if (attrs & Terminal::BOLD) != 0
|
|
443
|
+
parts << "2" if (attrs & Terminal::DIM) != 0
|
|
442
444
|
parts << "4" if (attrs & Terminal::UNDERLINE) != 0
|
|
443
445
|
parts << "7" if (attrs & Terminal::REVERSE) != 0
|
|
444
446
|
append_color(parts, cell.fg, true)
|
data/lib/muxr/terminal.rb
CHANGED
|
@@ -8,6 +8,7 @@ module Muxr
|
|
|
8
8
|
BOLD = 1
|
|
9
9
|
UNDERLINE = 2
|
|
10
10
|
REVERSE = 4
|
|
11
|
+
DIM = 8
|
|
11
12
|
|
|
12
13
|
SCROLLBACK_MAX = 5000
|
|
13
14
|
|
|
@@ -183,6 +184,96 @@ module Muxr
|
|
|
183
184
|
selection_cursor_to(timeline_size - 1, @cols - 1)
|
|
184
185
|
end
|
|
185
186
|
|
|
187
|
+
def selection_cursor_to_first_non_blank
|
|
188
|
+
return unless @selection_cursor
|
|
189
|
+
tr = @selection_cursor[0]
|
|
190
|
+
selection_cursor_to(tr, first_non_blank_col(tr))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Jump to top/middle/bottom of the visible viewport (vim H/M/L), landing
|
|
194
|
+
# on the first non-blank column of the destination line.
|
|
195
|
+
def selection_cursor_to_viewport(where)
|
|
196
|
+
return unless @selection_cursor
|
|
197
|
+
vr = case where
|
|
198
|
+
when :top then 0
|
|
199
|
+
when :middle then @rows / 2
|
|
200
|
+
when :bottom then @rows - 1
|
|
201
|
+
end
|
|
202
|
+
return if vr.nil?
|
|
203
|
+
tr = timeline_row_for_visible(vr).clamp(0, timeline_size - 1)
|
|
204
|
+
selection_cursor_to(tr, first_non_blank_col(tr))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def selection_cursor_word_forward(big: false)
|
|
208
|
+
return unless @selection_cursor
|
|
209
|
+
tr, tc = @selection_cursor
|
|
210
|
+
prev_cls = char_class_at(tr, tc, big: big)
|
|
211
|
+
loop do
|
|
212
|
+
nxt = step_forward(tr, tc)
|
|
213
|
+
break unless nxt
|
|
214
|
+
ntr, ntc = nxt
|
|
215
|
+
cur_cls = char_class_at(ntr, ntc, big: big)
|
|
216
|
+
# Row boundaries act as whitespace breaks even when the row is fully
|
|
217
|
+
# packed (no trailing pad) — visually the user sees a new line.
|
|
218
|
+
effective_prev = (ntr != tr) ? :space : prev_cls
|
|
219
|
+
if effective_prev != cur_cls && cur_cls != :space
|
|
220
|
+
selection_cursor_to(ntr, ntc)
|
|
221
|
+
return
|
|
222
|
+
end
|
|
223
|
+
tr, tc = ntr, ntc
|
|
224
|
+
prev_cls = cur_cls
|
|
225
|
+
end
|
|
226
|
+
selection_cursor_to(timeline_size - 1, @cols - 1)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def selection_cursor_word_end(big: false)
|
|
230
|
+
return unless @selection_cursor
|
|
231
|
+
tr, tc = @selection_cursor
|
|
232
|
+
pos = step_forward(tr, tc)
|
|
233
|
+
return unless pos
|
|
234
|
+
tr, tc = pos
|
|
235
|
+
while char_class_at(tr, tc, big: big) == :space
|
|
236
|
+
pos = step_forward(tr, tc)
|
|
237
|
+
break unless pos
|
|
238
|
+
tr, tc = pos
|
|
239
|
+
end
|
|
240
|
+
return if char_class_at(tr, tc, big: big) == :space
|
|
241
|
+
cls = char_class_at(tr, tc, big: big)
|
|
242
|
+
loop do
|
|
243
|
+
pos = step_forward(tr, tc)
|
|
244
|
+
if pos.nil? || pos[0] != tr || char_class_at(pos[0], pos[1], big: big) != cls
|
|
245
|
+
selection_cursor_to(tr, tc)
|
|
246
|
+
return
|
|
247
|
+
end
|
|
248
|
+
tr, tc = pos
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def selection_cursor_word_backward(big: false)
|
|
253
|
+
return unless @selection_cursor
|
|
254
|
+
tr, tc = @selection_cursor
|
|
255
|
+
pos = step_backward(tr, tc)
|
|
256
|
+
return unless pos
|
|
257
|
+
tr, tc = pos
|
|
258
|
+
while char_class_at(tr, tc, big: big) == :space
|
|
259
|
+
pos = step_backward(tr, tc)
|
|
260
|
+
unless pos
|
|
261
|
+
selection_cursor_to(tr, tc)
|
|
262
|
+
return
|
|
263
|
+
end
|
|
264
|
+
tr, tc = pos
|
|
265
|
+
end
|
|
266
|
+
cls = char_class_at(tr, tc, big: big)
|
|
267
|
+
loop do
|
|
268
|
+
pos = step_backward(tr, tc)
|
|
269
|
+
if pos.nil? || pos[0] != tr || char_class_at(pos[0], pos[1], big: big) != cls
|
|
270
|
+
selection_cursor_to(tr, tc)
|
|
271
|
+
return
|
|
272
|
+
end
|
|
273
|
+
tr, tc = pos
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
186
277
|
def clear_selection
|
|
187
278
|
return unless @selection_anchor
|
|
188
279
|
@selection_anchor = nil
|
|
@@ -594,6 +685,44 @@ module Muxr
|
|
|
594
685
|
@scrollback.size - @view_offset + r
|
|
595
686
|
end
|
|
596
687
|
|
|
688
|
+
def first_non_blank_col(tr)
|
|
689
|
+
row = timeline_row(tr)
|
|
690
|
+
return 0 unless row
|
|
691
|
+
@cols.times do |c|
|
|
692
|
+
ch = row[c]&.char
|
|
693
|
+
return c if ch && ch != " " && ch != "\t"
|
|
694
|
+
end
|
|
695
|
+
0
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
def char_class_at(tr, tc, big:)
|
|
699
|
+
row = timeline_row(tr)
|
|
700
|
+
classify_char(row && row[tc] && row[tc].char, big: big)
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# vim "word" = run of \w (alnum + _); "WORD" = any run of non-whitespace.
|
|
704
|
+
def classify_char(ch, big:)
|
|
705
|
+
return :space if ch.nil? || ch == " " || ch == "\t" || ch == ""
|
|
706
|
+
return :word if big
|
|
707
|
+
ch.match?(/\A\w\z/) ? :word : :punct
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def step_forward(tr, tc)
|
|
711
|
+
if tc + 1 < @cols
|
|
712
|
+
[tr, tc + 1]
|
|
713
|
+
elsif tr + 1 < timeline_size
|
|
714
|
+
[tr + 1, 0]
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def step_backward(tr, tc)
|
|
719
|
+
if tc > 0
|
|
720
|
+
[tr, tc - 1]
|
|
721
|
+
elsif tr > 0
|
|
722
|
+
[tr - 1, @cols - 1]
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
|
|
597
726
|
def ordered_selection
|
|
598
727
|
a = @selection_anchor
|
|
599
728
|
b = @selection_cursor
|
|
@@ -719,9 +848,10 @@ module Muxr
|
|
|
719
848
|
@bg = nil
|
|
720
849
|
@attrs = 0
|
|
721
850
|
when 1 then @attrs |= BOLD
|
|
851
|
+
when 2 then @attrs |= DIM
|
|
722
852
|
when 4 then @attrs |= UNDERLINE
|
|
723
853
|
when 7 then @attrs |= REVERSE
|
|
724
|
-
when 22 then @attrs &= ~BOLD
|
|
854
|
+
when 22 then @attrs &= ~(BOLD | DIM)
|
|
725
855
|
when 24 then @attrs &= ~UNDERLINE
|
|
726
856
|
when 27 then @attrs &= ~REVERSE
|
|
727
857
|
when 30..37 then @fg = p - 30
|
data/lib/muxr/version.rb
CHANGED