muxr 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ module Muxr
2
+ # Looks up the foreground command running inside a PTY by walking from the
3
+ # shell's pid → its tpgid (foreground process group on the controlling tty)
4
+ # → that process's command name. Hides the result when the shell itself is
5
+ # foreground so titles aren't full of "bash" / "zsh" noise.
6
+ #
7
+ # Two platform paths:
8
+ # Linux: /proc/<pid>/stat (no fork — runs fast even on the main thread,
9
+ # though Application uses a background thread anyway)
10
+ # macOS: ps -o tpgid=,pgid= -p <pid> + ps -o comm= -p <tpgid>. Two
11
+ # fork+execs, ~10–20ms total — exactly the reason callers run
12
+ # this off the event-loop thread.
13
+ #
14
+ # Returns the command name string or nil. nil also covers "couldn't read"
15
+ # so callers degrade silently rather than risk showing stale data.
16
+ module ForegroundCommand
17
+ # Command names we never want to surface — these are the empty-prompt
18
+ # case. If a user genuinely runs `bash` inside `bash` we'll under-report
19
+ # rather than mis-report.
20
+ SHELLS = %w[bash zsh fish sh dash ksh tcsh csh].freeze
21
+
22
+ module_function
23
+
24
+ def lookup(pid)
25
+ return nil unless pid.is_a?(Integer) && pid > 0
26
+ tpgid, pgid =
27
+ if File.exist?("/proc/#{pid}/stat")
28
+ linux_tpgid(pid)
29
+ else
30
+ macos_tpgid(pid)
31
+ end
32
+ return nil unless tpgid && pgid
33
+ return nil if tpgid <= 0
34
+ return nil if tpgid == pgid # shell is its own foreground — empty prompt
35
+
36
+ name =
37
+ if File.exist?("/proc/#{tpgid}/comm")
38
+ File.read("/proc/#{tpgid}/comm").strip
39
+ else
40
+ `ps -o comm= -p #{tpgid} 2>/dev/null`.strip
41
+ end
42
+ normalize(name)
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ # Public for testing — strips path/dash/whitespace and filters shells.
48
+ def normalize(name)
49
+ return nil if name.nil? || name.empty?
50
+ name = name.strip
51
+ name = name.sub(/\A-/, "") # login shells appear as "-bash"
52
+ name = File.basename(name)
53
+ return nil if name.empty?
54
+ return nil if SHELLS.include?(name)
55
+ name
56
+ end
57
+
58
+ # Public for testing — parses Linux /proc/<pid>/stat into [tpgid, pgid].
59
+ # The comm field can contain spaces and parens, so we slice from the
60
+ # last ')' rather than splitting from the start.
61
+ def parse_linux_stat(raw)
62
+ idx = raw.rindex(")")
63
+ return [nil, nil] unless idx
64
+ tail = raw[(idx + 2)..]
65
+ return [nil, nil] unless tail
66
+ fields = tail.split(" ")
67
+ # After the closing paren the fields are:
68
+ # state(0) ppid(1) pgrp(2) session(3) tty_nr(4) tpgid(5) ...
69
+ pgid = fields[2]&.to_i
70
+ tpgid = fields[5]&.to_i
71
+ [tpgid, pgid]
72
+ end
73
+
74
+ def linux_tpgid(pid)
75
+ raw = File.read("/proc/#{pid}/stat")
76
+ parse_linux_stat(raw)
77
+ end
78
+
79
+ def macos_tpgid(pid)
80
+ out = `ps -o tpgid=,pgid= -p #{pid} 2>/dev/null`.strip
81
+ return [nil, nil] if out.empty?
82
+ tpgid, pgid = out.split.map(&:to_i)
83
+ [tpgid, pgid]
84
+ end
85
+ end
86
+ end
@@ -1,17 +1,61 @@
1
1
  module Muxr
2
- # Translates raw keystrokes into either commands (when the Ctrl-a prefix is
3
- # active) or passthrough bytes to the focused pane. The handler is a small
4
- # state machine: :idle → :prefix → :idle, with a separate :command branch
5
- # for the ":"-driven mini-command line.
2
+ # Translates raw keystrokes into either commands or pane input. Two top-level
3
+ # modes:
4
+ #
5
+ # :normal Default. Single-key bindings (hjkl navigation, t/g/m for
6
+ # layouts, c/K for create/kill, etc.) act directly without
7
+ # any prefix. `i` drops into passthrough.
8
+ #
9
+ # :passthrough Historical mode: every key is forwarded to the focused
10
+ # pane unless prefixed by Ctrl-a. `Ctrl-a Esc` returns to
11
+ # normal mode.
12
+ #
13
+ # Plus the sub-states pre-existing from before modes existed:
14
+ # :prefix, :command, :confirm_quit, :help, :scrollback, :selection.
15
+ #
16
+ # One-shot sub-states (prefix, command, confirm_quit, help) return to
17
+ # @base_mode (whichever of :normal/:passthrough is active) when they
18
+ # finish. :scrollback and :selection also return to @base_mode so that
19
+ # exiting back from a scroll/yank lands you back in passthrough if that's
20
+ # where you came from.
6
21
  class InputHandler
7
22
  PREFIX = "\x01".freeze # Ctrl-a
8
23
 
24
+ # Single-key bindings in normal mode. Same actions as their Ctrl-a-
25
+ # prefixed counterparts in passthrough, just without the prefix.
26
+ # Value forms:
27
+ # :symbol → @app.public_send(:symbol)
28
+ # [:symbol, *args] → @app.public_send(:symbol, *args)
29
+ NORMAL_BINDINGS = {
30
+ "c" => :new_pane,
31
+ "K" => :close_focused,
32
+ "t" => [:set_layout, :tall],
33
+ "g" => [:set_layout, :grid],
34
+ "m" => [:set_layout, :monocle],
35
+ "\t" => :cycle_layout,
36
+ "\r" => :promote_master,
37
+ "\n" => :promote_master,
38
+ "h" => [:focus_direction, :left],
39
+ "j" => [:focus_direction, :down],
40
+ "k" => [:focus_direction, :up],
41
+ "l" => [:focus_direction, :right],
42
+ "a" => :focus_last,
43
+ "~" => :toggle_drawer,
44
+ "C" => :toggle_claude_drawer,
45
+ "P" => :toggle_private_focused,
46
+ "d" => :detach,
47
+ "?" => :show_help,
48
+ "q" => :quit_immediate,
49
+ "s" => :enter_scrollback,
50
+ "]" => :paste_from_buffer
51
+ }.freeze
52
+
9
53
  PREFIX_BINDINGS = {
10
54
  "c" => :new_pane,
11
55
  "n" => :focus_next,
12
56
  "p" => :focus_prev,
13
57
  "a" => :focus_last,
14
- "k" => :close_focused,
58
+ "K" => :close_focused,
15
59
  "\t" => :cycle_layout,
16
60
  "\r" => :promote_master,
17
61
  "\n" => :promote_master,
@@ -69,8 +113,10 @@ module Muxr
69
113
  "u" => :half_up,
70
114
  "\x06" => :full_down, # Ctrl-f
71
115
  "\x02" => :full_up, # Ctrl-b
72
- "f" => :full_down,
73
- " " => :full_down
116
+ "f" => :full_down
117
+ # NOTE: space is intentionally absent here — it's a top-level toggle
118
+ # for linear selection (see handle_selection_input), mirroring vim's
119
+ # `v` so the right thumb has a one-key way to anchor/release.
74
120
  }.freeze
75
121
 
76
122
  SELECTION_YANK = ["\r", "\n", "y"].freeze
@@ -78,22 +124,22 @@ module Muxr
78
124
 
79
125
  DIGIT_RE = /\A[1-9]\z/.freeze
80
126
 
81
- attr_reader :state, :command_buffer
127
+ attr_reader :state, :command_buffer, :base_mode
82
128
 
83
129
  def initialize(app)
84
130
  @app = app
85
- @state = :idle
131
+ @state = :normal
132
+ @base_mode = :normal
86
133
  @command_buffer = +""
87
134
  end
88
135
 
89
136
  def feed(data)
90
137
  remaining = data
91
138
  until remaining.empty?
92
- if @state == :idle
93
- # Fast path for pass-through: forward everything up to the next
94
- # Ctrl-a as a single chunk so a large paste doesn't turn into one
95
- # PTY write per byte. PREFIX is single-byte ASCII (\x01) and never
96
- # appears mid-UTF-8, so byte/char index match.
139
+ if @state == :passthrough
140
+ # Fast path: batch everything up to the next Ctrl-a as one chunk so
141
+ # a large paste doesn't turn into one PTY write per byte. PREFIX is
142
+ # single-byte ASCII (\x01) and never appears mid-UTF-8.
97
143
  idx = remaining.index(PREFIX)
98
144
  if idx.nil?
99
145
  @app.send_to_focused(remaining)
@@ -108,9 +154,11 @@ module Muxr
108
154
  ch = remaining[0]
109
155
  remaining = remaining[1..] || ""
110
156
  case @state
157
+ when :normal
158
+ handle_normal(ch)
111
159
  when :help
112
160
  @app.dismiss_help
113
- @state = :idle
161
+ @state = @base_mode
114
162
  when :confirm_quit
115
163
  handle_confirm_quit(ch)
116
164
  when :prefix
@@ -141,40 +189,96 @@ module Muxr
141
189
  @state = :selection
142
190
  end
143
191
 
192
+ # Drop into passthrough — every key reaches the focused pane until the
193
+ # user issues Ctrl-a Esc.
194
+ def enter_passthrough_mode
195
+ @state = :passthrough
196
+ @base_mode = :passthrough
197
+ end
198
+
199
+ # Return to normal mode. Used by the `Ctrl-a Esc` binding from
200
+ # passthrough — explicitly resets @base_mode so the user genuinely
201
+ # leaves passthrough.
202
+ def enter_normal_mode
203
+ @state = :normal
204
+ @base_mode = :normal
205
+ end
206
+
207
+ # Exit a sub-state (scrollback, selection-yank) and resume the mode the
208
+ # user was in before they entered scrollback. Preserves @base_mode so
209
+ # a passthrough → scrollback → exit round-trip lands back in passthrough.
144
210
  def enter_idle_mode
145
- @state = :idle
211
+ @state = @base_mode
146
212
  end
147
213
 
148
214
  def cancel
149
- @state = :idle
215
+ @state = @base_mode
150
216
  @command_buffer = +""
151
217
  end
152
218
 
153
219
  private
154
220
 
221
+ def handle_normal(ch)
222
+ if ch == "i"
223
+ # Internal state flip happens here so a bare FakeApp in tests still
224
+ # transitions; the Application callback redundantly flips state
225
+ # (idempotent) and adds the user-visible flash.
226
+ enter_passthrough_mode
227
+ @app.enter_passthrough_mode
228
+ return
229
+ end
230
+ if ch == ":"
231
+ @state = :command
232
+ @command_buffer = +""
233
+ return
234
+ end
235
+ if DIGIT_RE.match?(ch)
236
+ @app.focus_pane_number(ch.to_i)
237
+ return
238
+ end
239
+
240
+ action = NORMAL_BINDINGS[ch]
241
+ case action
242
+ when Symbol
243
+ @app.public_send(action)
244
+ when Array
245
+ @app.public_send(*action)
246
+ end
247
+ # Unknown key: ignore. Avoids accidental side-effects when the user
248
+ # mistypes — same rationale as scrollback mode.
249
+ end
250
+
155
251
  def handle_prefix(ch)
156
252
  action = PREFIX_BINDINGS[ch]
157
253
  case
254
+ when ch == "\e"
255
+ # Ctrl-a Esc → return to normal mode. Flip state directly so tests
256
+ # with a bare FakeApp transition; the Application callback is
257
+ # idempotent and adds the flash message.
258
+ enter_normal_mode
259
+ @app.enter_normal_mode
158
260
  when ch == ":"
159
261
  @state = :command
160
262
  @command_buffer = +""
161
263
  when ch == PREFIX
162
264
  @app.send_to_focused(PREFIX)
163
- @state = :idle
265
+ @state = @base_mode
164
266
  when DIGIT_RE.match?(ch)
165
267
  @app.focus_pane_number(ch.to_i)
166
- @state = :idle
268
+ @state = @base_mode
167
269
  when action
168
270
  @app.public_send(action)
169
- @state = :idle if @state == :prefix
271
+ # The action may have set a new state (confirm_quit, scrollback,
272
+ # help). Only revert to base mode if we're still in :prefix.
273
+ @state = @base_mode if @state == :prefix
170
274
  else
171
- # Unknown prefix-key: return to idle silently.
172
- @state = :idle
275
+ # Unknown prefix key: return to base mode silently.
276
+ @state = @base_mode
173
277
  end
174
278
  end
175
279
 
176
280
  def handle_confirm_quit(ch)
177
- @state = :idle
281
+ @state = @base_mode
178
282
  if ch == "y" || ch == "Y"
179
283
  @app.confirm_quit
180
284
  else
@@ -184,7 +288,7 @@ module Muxr
184
288
 
185
289
  def handle_scrollback_input(ch)
186
290
  if SCROLLBACK_EXITS.include?(ch)
187
- @state = :idle
291
+ enter_idle_mode
188
292
  @app.exit_scrollback
189
293
  return
190
294
  end
@@ -208,7 +312,7 @@ module Muxr
208
312
  return
209
313
  end
210
314
  case ch
211
- when "v"
315
+ when "v", " "
212
316
  @app.toggle_selection(:linear)
213
317
  return
214
318
  when "\x16" # Ctrl-v
@@ -225,11 +329,11 @@ module Muxr
225
329
  when "\r", "\n"
226
330
  cmd = @command_buffer.dup
227
331
  @command_buffer = +""
228
- @state = :idle
332
+ @state = @base_mode
229
333
  @app.run_command(cmd)
230
334
  when "\e"
231
335
  @command_buffer = +""
232
- @state = :idle
336
+ @state = @base_mode
233
337
  @app.invalidate
234
338
  when "\x7f", "\b"
235
339
  @command_buffer.chop!
@@ -87,5 +87,64 @@ module Muxr
87
87
  def monocle(count, area, _focused_index = 0)
88
88
  Array.new(count) { Rect.new(area.x, area.y, area.w, area.h) }
89
89
  end
90
+
91
+ # Return the index of the closest pane in `direction` (:left/:right/:up/:down)
92
+ # from the focused pane. Pure function over the rect list — does not know
93
+ # about the layout that produced the rects.
94
+ #
95
+ # Selection rule: among panes strictly on the requested side, prefer the
96
+ # one with the largest perpendicular overlap with the focused pane;
97
+ # tie-break by smallest axis-distance, then by smallest center offset.
98
+ # Returns nil when nothing qualifies (e.g. focused is the rightmost pane
99
+ # and direction is :right, or monocle where every rect is identical).
100
+ def neighbor(rects, focused_index, direction)
101
+ return nil if rects.nil? || rects.empty?
102
+ return nil unless focused_index.is_a?(Integer)
103
+ return nil unless focused_index.between?(0, rects.length - 1)
104
+ focused = rects[focused_index]
105
+ return nil unless focused
106
+
107
+ best = nil
108
+ rects.each_with_index do |rect, idx|
109
+ next if idx == focused_index || rect.nil?
110
+
111
+ case direction
112
+ when :right
113
+ next unless rect.x >= focused.x + focused.w
114
+ axis_dist = rect.x - (focused.x + focused.w)
115
+ overlap = overlap_extent(focused.y, focused.h, rect.y, rect.h)
116
+ center = ((rect.y + rect.h / 2.0) - (focused.y + focused.h / 2.0)).abs
117
+ when :left
118
+ next unless rect.x + rect.w <= focused.x
119
+ axis_dist = focused.x - (rect.x + rect.w)
120
+ overlap = overlap_extent(focused.y, focused.h, rect.y, rect.h)
121
+ center = ((rect.y + rect.h / 2.0) - (focused.y + focused.h / 2.0)).abs
122
+ when :down
123
+ next unless rect.y >= focused.y + focused.h
124
+ axis_dist = rect.y - (focused.y + focused.h)
125
+ overlap = overlap_extent(focused.x, focused.w, rect.x, rect.w)
126
+ center = ((rect.x + rect.w / 2.0) - (focused.x + focused.w / 2.0)).abs
127
+ when :up
128
+ next unless rect.y + rect.h <= focused.y
129
+ axis_dist = focused.y - (rect.y + rect.h)
130
+ overlap = overlap_extent(focused.x, focused.w, rect.x, rect.w)
131
+ center = ((rect.x + rect.w / 2.0) - (focused.x + focused.w / 2.0)).abs
132
+ else
133
+ return nil
134
+ end
135
+
136
+ score = [-overlap, axis_dist, center]
137
+ if best.nil? || (score <=> best[0]) < 0
138
+ best = [score, idx]
139
+ end
140
+ end
141
+ best && best[1]
142
+ end
143
+
144
+ def overlap_extent(a_start, a_size, b_start, b_size)
145
+ finish = [a_start + a_size, b_start + b_size].min
146
+ start = [a_start, b_start].max
147
+ [finish - start, 0].max
148
+ end
90
149
  end
91
150
  end
data/lib/muxr/pane.rb CHANGED
@@ -14,6 +14,11 @@ module Muxr
14
14
  class Pane
15
15
  attr_reader :id, :terminal, :process
16
16
  attr_accessor :rect
17
+ # Last value written by Application's foreground poller thread. nil when
18
+ # the shell itself is foreground (the common empty-prompt case) or when
19
+ # the lookup hasn't run / couldn't read. Renderer surfaces this in the
20
+ # pane title.
21
+ attr_accessor :foreground_command
17
22
 
18
23
  def initialize(id: nil, rows: 24, cols: 80, cwd: nil, command: nil, env_overrides: nil, process: nil)
19
24
  @id = id || SecureRandom.hex(3)
@@ -30,6 +35,11 @@ module Muxr
30
35
  @rect = nil
31
36
  @initial_cwd = cwd || @process.cwd
32
37
  @private_flag = false
38
+ @foreground_command = nil
39
+ end
40
+
41
+ def pid
42
+ @process.pid
33
43
  end
34
44
 
35
45
  # Private panes are invisible to the MCP control surface — their cwd is