muxr 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6e431ecab7ea6e49cf31e2865c3c870603847e422c9c1e5dfc8b86ff413b95f5
4
+ data.tar.gz: e6db50aba85439befa78b6c89ec5dcd09f6d3cfa66195e0d552f403c229b7932
5
+ SHA512:
6
+ metadata.gz: 2f0513da3cfba34c6efbc73c398934ada15ccb00b29958d4b5bf70b397cb7110819da8915acbbd13db4a8dd9d922551e3bf70d09c2dbf6247e2ebea598d2f9b5
7
+ data.tar.gz: 9f0dcefe6bd25d5173044d88b81fc4efac0872b5961f010ed33e370335f341f2ee2ec98b5d0ed1ee50036a3675ba96ad3b33b50d95397ef32c6726eaabe0a29a
data/CHANGELOG.md ADDED
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ All notable changes to muxr are documented here. The format roughly follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions
5
+ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-05-11
10
+
11
+ Initial release.
12
+
13
+ ### Added
14
+ - Client/server architecture over a Unix domain socket at
15
+ `~/.muxr/sockets/<name>.sock`. `Ctrl-a d` detaches the client; the
16
+ server (and every shell it owns) keeps running, so reattaching gives
17
+ back the exact same panes with full history.
18
+ - Ctrl-a prefix keybindings: `c` (new pane), `n`/`p` (next/prev),
19
+ `a` (toggle last pane), `1`..`9` (jump to pane by label), `k` (close),
20
+ `Tab` (cycle layout), `Enter` (promote to master), `~` (toggle drawer),
21
+ `d` (detach), `q` (kill session with `y/n` confirm), `:` (command
22
+ prompt), `?` (help), `C-a` (send literal `C-a`).
23
+ - Three layouts (`tall`, `grid`, `monocle`) implemented as pure functions
24
+ of pane count and screen area.
25
+ - Quake-style drawer overlay with a persistent shell PTY that survives
26
+ hide/toggle; `drawer reset` is the only way to kill it.
27
+ - Per-pane scrollback (bounded 5000-row ring) with `Ctrl-a [` copy-mode
28
+ and vi-style navigation (`j`/`k`/`d`/`u`/`f`/`b`/Space/`g`/`G`).
29
+ - Visual selection inside scrollback: `v` for character, `C-v` for
30
+ block; `y`/Enter yanks into an internal buffer and pipes to `pbcopy`.
31
+ `Ctrl-a ]` pastes the yank buffer into the focused pane.
32
+ - Command prompt (`Ctrl-a :`): `layout`, `drawer`, `save`, `restore`,
33
+ `sessions`/`ls`, `new`, `close`, `next`, `prev`, `master`, `detach`,
34
+ `quit`.
35
+ - CLI flags: `--list`, `--version`, `--help`, `-s <name>`.
36
+ - Session persistence to `~/.muxr/sessions/<name>.json` as cold-storage
37
+ fallback (the live session lives in the running server between
38
+ detaches).
39
+ - Real VT100 emulator per pane: cursor movement, SGR (16-color,
40
+ 256-color, truecolor, underline subparameters and underline color),
41
+ erase/insert/delete, autowrap, scroll regions, UTF-8 across PTY read
42
+ boundaries.
43
+ - Renderer that composes one frame and diff-emits ANSI to STDOUT.
44
+
45
+ [Unreleased]: https://github.com/roelbondoc/muxr/compare/v0.1.0...HEAD
46
+ [0.1.0]: https://github.com/roelbondoc/muxr/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roel Bondoc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # muxr
2
+
3
+ A keyboard-driven terminal multiplexer in pure Ruby. `muxr` (Ruby + Unix)
4
+ combines the familiar keybindings of **GNU Screen**, the automatic tiling
5
+ of **xmonad**, and a **Quake-style drop-down drawer**. Panes are treated
6
+ like tiling-window-manager clients — you never resize them by hand;
7
+ the active layout decides geometry.
8
+
9
+ ```
10
+ ┌ #1 ★ (tall) ──────────────┬ #2 ──────────────────────────┐
11
+ │ master pane │ stacked slave pane │
12
+ │ │ │
13
+ │ ├──────────────────────────────┤
14
+ │ │ stacked slave pane │
15
+ │ │ │
16
+ └───────────────────────────┴──────────────────────────────┘
17
+ ┌ Drawer ─────────────────────────────────────────────────┐
18
+ │ persistent overlay shell, opens from the bottom │
19
+ └─────────────────────────────────────────────────────────┘
20
+ [default] panes:3 layout:tall focused:#1 drawer:shown muxr ^a ?
21
+ ```
22
+
23
+ ## Install / run
24
+
25
+ ```bash
26
+ git clone https://github.com/roelbondoc/muxr
27
+ cd muxr
28
+ bin/muxr # attach the "default" session (auto-spawn if needed)
29
+ bin/muxr work # attach (or start) a named session
30
+ bin/muxr --list # list saved sessions and exit
31
+ bin/muxr --help
32
+ ```
33
+
34
+ Requires **Ruby ≥ 3.4**. No runtime gems — just `PTY`, `IO.console`, `JSON`,
35
+ `Socket`, and `FileUtils` from stdlib.
36
+
37
+ `bin/muxr` is the client. The first invocation for a session daemonizes a
38
+ server in the background; subsequent invocations attach to it over a Unix
39
+ socket. `Ctrl-a d` detaches the client and leaves the server (and every
40
+ shell it owns) running, so reattaching gives you back the exact same panes
41
+ with their full history.
42
+
43
+ ## Keybindings (Ctrl-a prefix)
44
+
45
+ | Keys | Action |
46
+ |----------------|---------------------------------------------------------|
47
+ | `C-a c` | new pane |
48
+ | `C-a n` / `p` | focus next / previous pane |
49
+ | `C-a a` | toggle last (previously focused) pane |
50
+ | `C-a 1` … `9` | jump to pane by its label |
51
+ | `C-a k` | close focused pane (or hide drawer) |
52
+ | `C-a Tab` | cycle layout (`tall` → `grid` → `monocle`) |
53
+ | `C-a Enter` | promote focused pane to master |
54
+ | `C-a ~` | toggle drawer |
55
+ | `C-a [` | enter scrollback / copy-mode |
56
+ | `C-a ]` | paste internal yank buffer into focused pane |
57
+ | `C-a d` | detach (server keeps running) |
58
+ | `C-a q` | kill session (asks `kill session? (y/n)`) |
59
+ | `C-a :` | command prompt |
60
+ | `C-a ?` | help |
61
+ | `C-a C-a` | send literal `C-a` to focused pane |
62
+
63
+ ### Scrollback and copy-mode
64
+
65
+ Each pane keeps a bounded (5000-row) scrollback ring. `C-a [` enters
66
+ scrollback with vi-style navigation; the status bar shows a key hint and
67
+ the pane title gains `[scrollback N/M]`.
68
+
69
+ | Keys | Action |
70
+ |-------------------------|-------------------------------------|
71
+ | `j` / `k` | scroll one line |
72
+ | `d` / `u` (or `C-d`/`C-u`) | half page |
73
+ | `f` / `b` / Space (or `C-f`/`C-b`) | full page |
74
+ | `g` / `G` | top / bottom |
75
+ | `q` / `Esc` / `C-c` | exit back to live view |
76
+
77
+ Press `v` inside scrollback to enter a movable-cursor selection mode
78
+ (`h j k l`, `0`/`$`, `g`/`G`, `C-d`/`C-u`, `C-f`/`C-b`, Space). Press `v`
79
+ again to anchor a character selection or `C-v` to anchor a block
80
+ (rectangular) selection — toggling between the two preserves the anchor.
81
+ `y` or Enter yanks the selection into an internal buffer and pipes it to
82
+ `pbcopy` in the background (silent no-op when `pbcopy` is unavailable).
83
+ `C-a ]` writes the yank buffer back into the focused pane.
84
+
85
+ ## Commands (typed after `C-a :`)
86
+
87
+ ```
88
+ layout {tall|grid|monocle} # also: layout (no arg) → cycle
89
+ drawer {toggle|show|hide|reset}
90
+ save # persist session to ~/.muxr/sessions/<name>.json
91
+ restore # show path to saved session
92
+ sessions | ls # list saved sessions
93
+ new | close | next | prev | master
94
+ detach | quit # quit asks for y/n confirmation
95
+ ```
96
+
97
+ ## Architecture
98
+
99
+ muxr runs as **two processes** that talk over a Unix domain socket at
100
+ `~/.muxr/sockets/<name>.sock`. The server owns the PTYs and all session
101
+ state; the client is a thin TTY front-end that comes and goes across
102
+ detach/reattach.
103
+
104
+ ```
105
+ Client (foreground, owns the TTY) Server (daemon, owns the PTYs)
106
+ ├─ STDIN in raw mode + alt screen Application (event loop, lifecycle)
107
+ ├─ SIGWINCH → RESIZE frame ├─ Session ─ Window ─ Pane[ ] ─ Terminal + PTYProcess
108
+ │ │ └─ Drawer ─ Pane
109
+ └─ Protocol ├─ Renderer – diff-emits ANSI as OUTPUT frames
110
+ ◄── OUTPUT bytes ──── Renderer ◄────────────┤ InputHandler – Ctrl-a state machine
111
+ ──── INPUT bytes ───► InputHandler ├─ CommandDispatcher – parses ":"-prefixed commands
112
+ ──── HELLO/RESIZE ──► apply_size ├─ LayoutManager – pure (layout, count, area) → [Rect]
113
+ ◄── BYE ───────────── disconnect_client └─ UNIXServer listener (one client at a time)
114
+ ```
115
+
116
+ Frames are length-prefixed (`[1-byte type][4-byte BE length][payload]`):
117
+ `H` hello, `I` input, `R` resize, `B` bye, `O` output.
118
+
119
+ The server's event loop is single-threaded `IO.select` over the listening
120
+ socket, the attached client (when present), every pane PTY, and the
121
+ drawer PTY. Layouts are pure — `LayoutManager` has no mutable state, so
122
+ the renderer recomputes geometry on every tick after a resize or
123
+ pane add/remove without bookkeeping.
124
+
125
+ `Ctrl-a d` detaches the client but leaves the server (and its shells)
126
+ running; reattaching gives you back the same panes with their full
127
+ history. `Ctrl-a q` and `:quit` flash `kill session? (y/n)` in the status
128
+ bar and only tear the server down on `y` — there is no "kill without
129
+ confirm" keybinding by design.
130
+
131
+ The drawer's PTY is **never torn down** when the drawer is hidden — its
132
+ shell process keeps running so the next toggle restores the previous
133
+ session. Its initial working directory is inherited from whatever pane
134
+ was focused when the drawer was first created; only `drawer reset` kills
135
+ the PTY.
136
+
137
+ The per-pane `Terminal` is a real VT100 emulator (cursor movement, SGR
138
+ including 256-color/truecolor and underline subparameters, erase/insert/
139
+ delete, autowrap, scroll regions). Scrollback is composited into the
140
+ visible grid through a view-offset that auto-tracks new rows while
141
+ scrolled back, so reviewed content stays frozen.
142
+
143
+ ## Session persistence
144
+
145
+ Sessions live in `~/.muxr/sessions/<name>.json`:
146
+
147
+ ```json
148
+ {
149
+ "name": "default",
150
+ "layout": "tall",
151
+ "focused_index": 0,
152
+ "master_index": 0,
153
+ "panes": [{"cwd": "/home/me/code"}, {"cwd": "/tmp"}],
154
+ "drawer": {"visible": true, "cwd": "/home/me/code"}
155
+ }
156
+ ```
157
+
158
+ The JSON file is mainly a **cold-storage fallback**. Between detaches the
159
+ live session lives inside the running server process, so `Ctrl-a d` then
160
+ `bin/muxr <name>` reattaches to the exact same shells with their full
161
+ history. The JSON only matters once the server is gone (after `Ctrl-a q`
162
+ or a reboot): re-launching `muxr <name>` rebuilds pane and drawer shells
163
+ using the saved working directories. Shell command history within those
164
+ panes is **not** persisted — that's the job of your shell's own history
165
+ file. Run `:save` from inside muxr to write the snapshot.
166
+
167
+ ## Development
168
+
169
+ ```bash
170
+ bundle install # only minitest and rake
171
+ rake test # full suite (100+ unit tests)
172
+
173
+ # Run a single file or test
174
+ ruby -Ilib -Itest test/test_layout_manager.rb
175
+ ruby -Ilib -Itest test/test_terminal.rb -n test_csi_cursor_position
176
+ ```
177
+
178
+ Tests cover the layout algorithms, drawer state machine, window pane
179
+ ordering, session JSON round-trip, the client/server framing protocol,
180
+ the input-handler state machine (including scrollback and selection
181
+ modes), the renderer's diff-emit, and the VT100 emulator's cursor
182
+ movement, SGR (including colon-subparameter and underline-color forms),
183
+ erase, scroll-region, and autowrap handling. PTY-dependent code paths
184
+ are exercised via dependency injection so tests don't spawn shells.
185
+
186
+ On-disk layout:
187
+
188
+ ```
189
+ ~/.muxr/
190
+ ├─ sessions/<name>.json structural snapshot written by `:save`
191
+ ├─ sockets/<name>.sock server's Unix listener (auto-managed)
192
+ └─ logs/<name>.log server stdout/stderr
193
+ ```
194
+
195
+ ## Contributing
196
+
197
+ Contributions are welcome from anyone, with one requirement: **the code
198
+ must be generated by a frontier LLM** (e.g. Claude, GPT, Gemini at their
199
+ current top-tier model). Hand-written patches will not be accepted.
200
+
201
+ When you open a PR, please:
202
+
203
+ - State which model produced the change in the PR description.
204
+ - Include the prompt(s) you used, or a short summary of the conversation
205
+ that produced the diff.
206
+ - Drive the model yourself — review, push back, iterate. You are
207
+ responsible for the patch: it should pass `rake test`, follow the
208
+ conventions in `CLAUDE.md`, and not regress existing behavior.
209
+
210
+ Bug reports, feature requests, and design discussion in issues are
211
+ welcome regardless of how they're written.
data/bin/muxr ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "muxr"
6
+ require "fileutils"
7
+ require "rbconfig"
8
+ require "socket"
9
+
10
+ if ARGV.include?("-v") || ARGV.include?("--version")
11
+ puts "muxr #{Muxr::VERSION}"
12
+ exit 0
13
+ end
14
+
15
+ if ARGV.include?("-l") || ARGV.include?("--list")
16
+ names = Muxr::Session.list
17
+ puts names.join("\n") unless names.empty?
18
+ exit 0
19
+ end
20
+
21
+ if ARGV.include?("-h") || ARGV.include?("--help")
22
+ puts <<~USAGE
23
+ muxr #{Muxr::VERSION} — a tiling terminal multiplexer
24
+
25
+ Usage:
26
+ muxr attach the default session (auto-spawn if needed)
27
+ muxr <name> attach (or start) the named session
28
+ muxr -s <name> same as above
29
+ muxr --list list saved session files and exit
30
+ muxr --version print version and exit
31
+ muxr --help this help
32
+
33
+ Keybindings (Ctrl-a prefix):
34
+ C-a c new pane C-a n / p next / prev pane
35
+ C-a a toggle last pane C-a 1..9 jump to pane by number
36
+ C-a k close pane C-a Tab cycle layout
37
+ C-a ~ toggle drawer C-a Enter promote to master
38
+ C-a : command prompt C-a ? toggle help
39
+ C-a d detach (server stays running)
40
+ C-a q kill session (with y/n confirmation)
41
+ C-a C-a send literal C-a
42
+ USAGE
43
+ exit 0
44
+ end
45
+
46
+ # Internal: run as the server process. bin/muxr fork-execs itself with this
47
+ # flag when no server is alive for the requested session.
48
+ if ARGV.include?("--server")
49
+ args = ARGV.reject { |a| a == "--server" }
50
+ # Detach from the controlling terminal so the server survives the parent's
51
+ # shell closing. nochdir=true preserves cwd (the first pane inherits it);
52
+ # noclose=true keeps the stdin/out/err redirections Process.spawn set up.
53
+ begin
54
+ Process.daemon(true, true)
55
+ rescue NotImplementedError
56
+ # Platforms without Process.daemon (rare on POSIX) just run inline.
57
+ end
58
+ begin
59
+ Muxr::Application.new(args).run
60
+ rescue => e
61
+ $stderr.puts "muxr server: #{e.class}: #{e.message}"
62
+ $stderr.puts e.backtrace.first(20).join("\n")
63
+ exit 1
64
+ end
65
+ exit 0
66
+ end
67
+
68
+ # ---- client mode (the default) ----
69
+
70
+ def session_name_from(argv)
71
+ idx = argv.index("-s") || argv.index("--session")
72
+ return argv[idx + 1] if idx && argv[idx + 1]
73
+ argv.find { |a| !a.start_with?("-") } || "default"
74
+ end
75
+
76
+ def probe_socket(path)
77
+ return :missing unless File.exist?(path)
78
+ begin
79
+ UNIXSocket.new(path).close
80
+ :alive
81
+ rescue Errno::ECONNREFUSED, Errno::ENOENT
82
+ File.unlink(path) rescue nil
83
+ :stale
84
+ rescue Errno::EACCES
85
+ :forbidden
86
+ end
87
+ end
88
+
89
+ def spawn_server(name, log_path)
90
+ FileUtils.mkdir_p(File.dirname(log_path))
91
+ log = File.open(log_path, "a")
92
+ pid = Process.spawn(
93
+ RbConfig.ruby, __FILE__, "--server", name,
94
+ in: "/dev/null",
95
+ out: log,
96
+ err: log,
97
+ pgroup: true
98
+ )
99
+ log.close
100
+ Process.detach(pid)
101
+ pid
102
+ end
103
+
104
+ def wait_for_socket(path, deadline)
105
+ until File.exist?(path)
106
+ return false if Time.now >= deadline
107
+ sleep 0.05
108
+ end
109
+ true
110
+ end
111
+
112
+ name = session_name_from(ARGV)
113
+ socket_path = Muxr::Application.socket_path_for(name)
114
+
115
+ case probe_socket(socket_path)
116
+ when :forbidden
117
+ $stderr.puts "muxr: cannot access #{socket_path} (permission denied)"
118
+ exit 1
119
+ when :alive
120
+ # great — attach.
121
+ when :missing, :stale
122
+ log_path = File.join(Dir.home, ".muxr", "logs", "#{name}.log")
123
+ spawn_server(name, log_path)
124
+ unless wait_for_socket(socket_path, Time.now + 3.0)
125
+ $stderr.puts "muxr: server did not start within 3s (see #{log_path})"
126
+ exit 1
127
+ end
128
+ end
129
+
130
+ client = Muxr::Client.new(name)
131
+ begin
132
+ client.connect
133
+ rescue Errno::ECONNREFUSED, Errno::ENOENT => e
134
+ $stderr.puts "muxr: cannot connect to server at #{socket_path}: #{e.message}"
135
+ exit 1
136
+ end
137
+ exit(client.run || 0)