muxr 0.1.5 → 0.1.7

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.
@@ -1,17 +1,66 @@
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, :confirm_close, :help, :scrollback,
15
+ # :search, :selection.
16
+ #
17
+ # One-shot sub-states (prefix, command, confirm_quit, help) return to
18
+ # @base_mode (whichever of :normal/:passthrough is active) when they
19
+ # finish. :scrollback and :selection also return to @base_mode so that
20
+ # exiting back from a scroll/yank lands you back in passthrough if that's
21
+ # where you came from.
6
22
  class InputHandler
7
23
  PREFIX = "\x01".freeze # Ctrl-a
8
24
 
25
+ # Single-key bindings in normal mode. Same actions as their Ctrl-a-
26
+ # prefixed counterparts in passthrough, just without the prefix.
27
+ # Value forms:
28
+ # :symbol → @app.public_send(:symbol)
29
+ # [:symbol, *args] → @app.public_send(:symbol, *args)
30
+ NORMAL_BINDINGS = {
31
+ "c" => :new_pane,
32
+ "x" => :request_close,
33
+ "t" => [:set_layout, :tall],
34
+ "g" => [:set_layout, :grid],
35
+ "m" => [:set_layout, :monocle],
36
+ "\t" => :cycle_layout,
37
+ "\r" => :promote_master,
38
+ "\n" => :promote_master,
39
+ "h" => [:focus_direction, :left],
40
+ "j" => [:focus_direction, :down],
41
+ "k" => [:focus_direction, :up],
42
+ "l" => [:focus_direction, :right],
43
+ "H" => [:move_direction, :left],
44
+ "J" => [:move_direction, :down],
45
+ "K" => [:move_direction, :up],
46
+ "L" => [:move_direction, :right],
47
+ "a" => :focus_last,
48
+ "~" => :toggle_drawer,
49
+ "C" => :toggle_claude_drawer,
50
+ "P" => :toggle_private_focused,
51
+ "d" => :detach,
52
+ "?" => :show_help,
53
+ "q" => :quit_immediate,
54
+ "s" => :enter_scrollback,
55
+ "]" => :paste_from_buffer
56
+ }.freeze
57
+
9
58
  PREFIX_BINDINGS = {
10
59
  "c" => :new_pane,
11
60
  "n" => :focus_next,
12
61
  "p" => :focus_prev,
13
62
  "a" => :focus_last,
14
- "k" => :close_focused,
63
+ "x" => :request_close,
15
64
  "\t" => :cycle_layout,
16
65
  "\r" => :promote_master,
17
66
  "\n" => :promote_master,
@@ -41,6 +90,20 @@ module Muxr
41
90
  "G" => :bottom
42
91
  }.freeze
43
92
 
93
+ # CSI sequences (arrow / page keys) recognized in scrollback mode. Built
94
+ # for terminal raw-mode emission: arrow keys come through as ESC `[`
95
+ # followed by a single final letter, PageUp/PageDown as ESC `[5~` /
96
+ # `[6~`. Lookahead in #feed peels these off as one chunk so a bare ESC
97
+ # still exits scrollback the way it always has.
98
+ SCROLLBACK_CSI = {
99
+ "\e[A" => :line_back, # Up
100
+ "\e[B" => :line_forward, # Down
101
+ "\e[5~" => :half_back, # PageUp
102
+ "\e[6~" => :half_forward, # PageDown
103
+ "\e[H" => :top, # Home
104
+ "\e[F" => :bottom # End
105
+ }.freeze
106
+
44
107
  SCROLLBACK_EXITS = ["q", "\e", "\x03"].freeze # q, Esc, Ctrl-c
45
108
 
46
109
  SELECTION_BINDINGS = {
@@ -69,8 +132,10 @@ module Muxr
69
132
  "u" => :half_up,
70
133
  "\x06" => :full_down, # Ctrl-f
71
134
  "\x02" => :full_up, # Ctrl-b
72
- "f" => :full_down,
73
- " " => :full_down
135
+ "f" => :full_down
136
+ # NOTE: space is intentionally absent here — it's a top-level toggle
137
+ # for linear selection (see handle_selection_input), mirroring vim's
138
+ # `v` so the right thumb has a one-key way to anchor/release.
74
139
  }.freeze
75
140
 
76
141
  SELECTION_YANK = ["\r", "\n", "y"].freeze
@@ -78,22 +143,24 @@ module Muxr
78
143
 
79
144
  DIGIT_RE = /\A[1-9]\z/.freeze
80
145
 
81
- attr_reader :state, :command_buffer
146
+ attr_reader :state, :command_buffer, :search_buffer, :search_direction, :base_mode
82
147
 
83
148
  def initialize(app)
84
149
  @app = app
85
- @state = :idle
150
+ @state = :normal
151
+ @base_mode = :normal
86
152
  @command_buffer = +""
153
+ @search_buffer = +""
154
+ @search_direction = :forward
87
155
  end
88
156
 
89
157
  def feed(data)
90
158
  remaining = data
91
159
  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.
160
+ if @state == :passthrough
161
+ # Fast path: batch everything up to the next Ctrl-a as one chunk so
162
+ # a large paste doesn't turn into one PTY write per byte. PREFIX is
163
+ # single-byte ASCII (\x01) and never appears mid-UTF-8.
97
164
  idx = remaining.index(PREFIX)
98
165
  if idx.nil?
99
166
  @app.send_to_focused(remaining)
@@ -105,20 +172,41 @@ module Muxr
105
172
  next
106
173
  end
107
174
 
175
+ # Multi-byte CSI lookahead for scrollback / search: arrow / page
176
+ # keys arrive as `\e[<final>` and would otherwise trip the
177
+ # bare-Esc-exits behavior. In :scrollback we map them to scroll
178
+ # actions; in :search we silently consume them so a stray arrow
179
+ # doesn't kick the user out of the prompt. An incomplete `\e[…`
180
+ # (rare in raw-mode TTY) falls through and the bare `\e` exits as
181
+ # before.
182
+ if (@state == :scrollback || @state == :search) && remaining.start_with?("\e[")
183
+ consumed = consume_csi_escape(remaining)
184
+ if consumed > 0
185
+ remaining = remaining[consumed..] || ""
186
+ next
187
+ end
188
+ end
189
+
108
190
  ch = remaining[0]
109
191
  remaining = remaining[1..] || ""
110
192
  case @state
193
+ when :normal
194
+ handle_normal(ch)
111
195
  when :help
112
196
  @app.dismiss_help
113
- @state = :idle
197
+ @state = @base_mode
114
198
  when :confirm_quit
115
199
  handle_confirm_quit(ch)
200
+ when :confirm_close
201
+ handle_confirm_close(ch)
116
202
  when :prefix
117
203
  handle_prefix(ch)
118
204
  when :command
119
205
  handle_command_input(ch)
120
206
  when :scrollback
121
207
  handle_scrollback_input(ch)
208
+ when :search
209
+ handle_search_input(ch)
122
210
  when :selection
123
211
  handle_selection_input(ch)
124
212
  end
@@ -133,48 +221,114 @@ module Muxr
133
221
  @state = :confirm_quit
134
222
  end
135
223
 
224
+ def enter_confirm_close
225
+ @state = :confirm_close
226
+ end
227
+
136
228
  def enter_scrollback_mode
137
229
  @state = :scrollback
138
230
  end
139
231
 
232
+ def enter_search_mode(direction: :forward)
233
+ @state = :search
234
+ @search_direction = direction
235
+ @search_buffer = +""
236
+ end
237
+
140
238
  def enter_selection_mode
141
239
  @state = :selection
142
240
  end
143
241
 
242
+ # Drop into passthrough — every key reaches the focused pane until the
243
+ # user issues Ctrl-a Esc.
244
+ def enter_passthrough_mode
245
+ @state = :passthrough
246
+ @base_mode = :passthrough
247
+ end
248
+
249
+ # Return to normal mode. Used by the `Ctrl-a Esc` binding from
250
+ # passthrough — explicitly resets @base_mode so the user genuinely
251
+ # leaves passthrough.
252
+ def enter_normal_mode
253
+ @state = :normal
254
+ @base_mode = :normal
255
+ end
256
+
257
+ # Exit a sub-state (scrollback, selection-yank) and resume the mode the
258
+ # user was in before they entered scrollback. Preserves @base_mode so
259
+ # a passthrough → scrollback → exit round-trip lands back in passthrough.
144
260
  def enter_idle_mode
145
- @state = :idle
261
+ @state = @base_mode
146
262
  end
147
263
 
148
264
  def cancel
149
- @state = :idle
265
+ @state = @base_mode
150
266
  @command_buffer = +""
151
267
  end
152
268
 
153
269
  private
154
270
 
271
+ def handle_normal(ch)
272
+ if ch == "i"
273
+ # Internal state flip happens here so a bare FakeApp in tests still
274
+ # transitions; the Application callback redundantly flips state
275
+ # (idempotent) and adds the user-visible flash.
276
+ enter_passthrough_mode
277
+ @app.enter_passthrough_mode
278
+ return
279
+ end
280
+ if ch == ":"
281
+ @state = :command
282
+ @command_buffer = +""
283
+ return
284
+ end
285
+ if DIGIT_RE.match?(ch)
286
+ @app.focus_pane_number(ch.to_i)
287
+ return
288
+ end
289
+
290
+ action = NORMAL_BINDINGS[ch]
291
+ case action
292
+ when Symbol
293
+ @app.public_send(action)
294
+ when Array
295
+ @app.public_send(*action)
296
+ end
297
+ # Unknown key: ignore. Avoids accidental side-effects when the user
298
+ # mistypes — same rationale as scrollback mode.
299
+ end
300
+
155
301
  def handle_prefix(ch)
156
302
  action = PREFIX_BINDINGS[ch]
157
303
  case
304
+ when ch == "\e"
305
+ # Ctrl-a Esc → return to normal mode. Flip state directly so tests
306
+ # with a bare FakeApp transition; the Application callback is
307
+ # idempotent and adds the flash message.
308
+ enter_normal_mode
309
+ @app.enter_normal_mode
158
310
  when ch == ":"
159
311
  @state = :command
160
312
  @command_buffer = +""
161
313
  when ch == PREFIX
162
314
  @app.send_to_focused(PREFIX)
163
- @state = :idle
315
+ @state = @base_mode
164
316
  when DIGIT_RE.match?(ch)
165
317
  @app.focus_pane_number(ch.to_i)
166
- @state = :idle
318
+ @state = @base_mode
167
319
  when action
168
320
  @app.public_send(action)
169
- @state = :idle if @state == :prefix
321
+ # The action may have set a new state (confirm_quit, confirm_close,
322
+ # scrollback, help). Only revert to base mode if we're still in :prefix.
323
+ @state = @base_mode if @state == :prefix
170
324
  else
171
- # Unknown prefix-key: return to idle silently.
172
- @state = :idle
325
+ # Unknown prefix key: return to base mode silently.
326
+ @state = @base_mode
173
327
  end
174
328
  end
175
329
 
176
330
  def handle_confirm_quit(ch)
177
- @state = :idle
331
+ @state = @base_mode
178
332
  if ch == "y" || ch == "Y"
179
333
  @app.confirm_quit
180
334
  else
@@ -182,9 +336,18 @@ module Muxr
182
336
  end
183
337
  end
184
338
 
339
+ def handle_confirm_close(ch)
340
+ @state = @base_mode
341
+ if ch == "y" || ch == "Y"
342
+ @app.confirm_close
343
+ else
344
+ @app.cancel_close
345
+ end
346
+ end
347
+
185
348
  def handle_scrollback_input(ch)
186
349
  if SCROLLBACK_EXITS.include?(ch)
187
- @state = :idle
350
+ enter_idle_mode
188
351
  @app.exit_scrollback
189
352
  return
190
353
  end
@@ -192,12 +355,78 @@ module Muxr
192
355
  @app.enter_selection
193
356
  return
194
357
  end
358
+ case ch
359
+ when "/"
360
+ # Flip state directly so tests with a bare FakeApp transition; the
361
+ # Application callback redundantly flips state and runs side-effects.
362
+ enter_search_mode(direction: :forward)
363
+ @app.enter_search(direction: :forward)
364
+ return
365
+ when "?"
366
+ enter_search_mode(direction: :backward)
367
+ @app.enter_search(direction: :backward)
368
+ return
369
+ when "n"
370
+ @app.find_next
371
+ return
372
+ when "N"
373
+ @app.find_prev
374
+ return
375
+ end
195
376
  action = SCROLLBACK_BINDINGS[ch]
196
377
  @app.scroll_focused(action) if action
197
378
  # Unknown keys: ignored. Avoids accidental shell input when the user
198
379
  # mistypes inside scrollback mode.
199
380
  end
200
381
 
382
+ def handle_search_input(ch)
383
+ case ch
384
+ when "\r", "\n"
385
+ query = @search_buffer.dup
386
+ @search_buffer = +""
387
+ @state = :scrollback
388
+ @app.commit_search(query)
389
+ when "\e", "\x03"
390
+ @search_buffer = +""
391
+ @state = :scrollback
392
+ @app.cancel_search
393
+ when "\x7f", "\b"
394
+ @search_buffer.chop!
395
+ @app.invalidate
396
+ else
397
+ # Printable ASCII / UTF-8. We treat anything at or above 0x20 as
398
+ # input; control bytes besides the ones handled above are dropped
399
+ # to keep stray Ctrl-keys from corrupting the query.
400
+ @search_buffer << ch if ch.ord >= 0x20
401
+ @app.invalidate
402
+ end
403
+ end
404
+
405
+ # Find the final byte of a CSI escape sequence and return the number
406
+ # of bytes consumed. In :scrollback we map recognized sequences to
407
+ # scroll actions; in :search we just swallow them so a stray arrow
408
+ # key doesn't kick the user out of the prompt. Returns 0 only when
409
+ # the sequence is incomplete in this chunk — the caller falls through
410
+ # so a bare \e still exits.
411
+ def consume_csi_escape(remaining)
412
+ i = 2
413
+ max = [remaining.bytesize, 16].min
414
+ while i < max
415
+ b = remaining.getbyte(i)
416
+ if b >= 0x40 && b <= 0x7e
417
+ seq = remaining.byteslice(0, i + 1)
418
+ if @state == :scrollback
419
+ action = SCROLLBACK_CSI[seq]
420
+ @app.scroll_focused(action) if action
421
+ end
422
+ return i + 1
423
+ end
424
+ return 0 if b < 0x20 || b > 0x7e # malformed; fall through
425
+ i += 1
426
+ end
427
+ 0
428
+ end
429
+
201
430
  def handle_selection_input(ch)
202
431
  if SELECTION_YANK.include?(ch)
203
432
  @app.exit_selection(yank: true)
@@ -208,7 +437,7 @@ module Muxr
208
437
  return
209
438
  end
210
439
  case ch
211
- when "v"
440
+ when "v", " "
212
441
  @app.toggle_selection(:linear)
213
442
  return
214
443
  when "\x16" # Ctrl-v
@@ -225,11 +454,11 @@ module Muxr
225
454
  when "\r", "\n"
226
455
  cmd = @command_buffer.dup
227
456
  @command_buffer = +""
228
- @state = :idle
457
+ @state = @base_mode
229
458
  @app.run_command(cmd)
230
459
  when "\e"
231
460
  @command_buffer = +""
232
- @state = :idle
461
+ @state = @base_mode
233
462
  @app.invalidate
234
463
  when "\x7f", "\b"
235
464
  @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