muxr 0.1.6 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ef82a33d0eab76d850265772196a2d405e7e03b1161496973f4e88369b0e628
4
- data.tar.gz: 86a621eaef858c02317431a57ac109dea309c3f2f450f0f28b2d30bd71f4d145
3
+ metadata.gz: 03650b7088c85aa8fbe993dbfe17e7f8aac8334f617c3ff148589964d83bb701
4
+ data.tar.gz: 9b878beb1c05f1c83273f3f1c8e51944be7c75b3fdcd13935c96f851a2aa8405
5
5
  SHA512:
6
- metadata.gz: '0958e09357a6a80965128c0b90e7348a86d8b1bddb63b6891718a34a01703a3bb8d8b8281b9e8c0e2094b8824c3cc64c2b255bbeab0919531848ee46e39e1180'
7
- data.tar.gz: ab9d8f51cfa56e0e550bd9b608cebaa6a85fcd80871f1018917ef498efbca89f0c8a1a0885bdafafcf2fc1e6a2922b13c977e2a13f98561acd41e21b38c74c11
6
+ metadata.gz: 91ccd01254428f3a2333e3abe94cd3d039598813c484730a4819c938f9f0d733ab611bd9c975314ca5bfb0af07c243c69814ed918fc02315e7652436454170b8
7
+ data.tar.gz: c58966f41d4a62978e3bfd7560d72ef132b6fcbdededddf129cf94216d77689b1f1f52c44cf7660cab9780a7ec8f3180fe138a9f2f75f8ec741548595c415665
data/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.7] - 2026-05-20
10
+
11
+ ### Added
12
+ - Scrollback search via `/` (forward) and `?` (backward). Enter commits
13
+ the query, `n` / `N` cycle through matches with wrap. Smart-case
14
+ matching scans both scrollback and the live buffer; the chosen match
15
+ is centered in the viewport and every match is highlighted in yellow
16
+ until the user exits scrollback.
17
+ - Arrow / page keys in scrollback. `↑`/`↓` scroll a line, `PgUp`/`PgDn`
18
+ half-page, `Home`/`End` jump to top/bottom. `InputHandler#feed` now
19
+ peeks for CSI escape sequences so a bare `Esc` still exits scrollback
20
+ the way it always has.
21
+ - Shift-`H`/`J`/`K`/`L` swaps the focused pane with the spatial
22
+ neighbor in that direction (linear next/prev fallback in monocle).
23
+ Focus tracks the moved pane so you can keep dragging it; swapping
24
+ into position 0 of tall/grid promotes it to master.
25
+
26
+ ### Changed
27
+ - Pane close is now confirmed. The close binding moved from `K` to
28
+ `x` (in both normal mode and the Ctrl-a prefix) so shift-`HJKL` is
29
+ free for the new move action. Close routes through a `:confirm_close`
30
+ state that flashes `close pane? (y/n)` — drawer hide stays
31
+ prompt-free since it's reversible.
32
+
9
33
  ## [0.1.6] - 2026-05-15
10
34
 
11
35
  ### Added
data/README.md CHANGED
@@ -88,8 +88,9 @@ bin/muxr # same flags as the installed `muxr` executable
88
88
  muxr has two top-level input modes, modeled on vim:
89
89
 
90
90
  - **Normal** (default at startup) — single keys act on the multiplexer.
91
- `hjkl` moves focus between panes, `c`/`K` create/kill panes,
92
- `t`/`g`/`m` set the layout, etc. No prefix needed.
91
+ `hjkl` moves focus between panes, `HJKL` moves the focused pane
92
+ itself, `c`/`x` create/close panes, `t`/`g`/`m` set the layout, etc.
93
+ No prefix needed.
93
94
  - **Passthrough** (entered with `i`) — every keystroke is forwarded to
94
95
  the focused pane, exactly like a regular terminal. muxr commands are
95
96
  reached via the historical `Ctrl-a` prefix. `Ctrl-a Esc` returns to
@@ -98,18 +99,20 @@ muxr has two top-level input modes, modeled on vim:
98
99
  The active mode appears as a `[MODE]` chip in the top-right corner of
99
100
  the focused pane (and the leftmost slot of the status bar). The
100
101
  focused pane's border is colored by mode — cyan for normal, green for
101
- passthrough, orange for scrollback, magenta for selection, yellow for
102
- the command prompt, red during the kill-session confirmation, blue
103
- while help is open. Unfocused panes always render with the grey
104
- unfocused border, regardless of mode.
102
+ passthrough, orange for scrollback (and its `/` search prompt),
103
+ magenta for selection, yellow for the command prompt, red during the
104
+ kill-session or close-pane confirmation, blue while help is open.
105
+ Unfocused panes always render with the grey unfocused border,
106
+ regardless of mode.
105
107
 
106
108
  ### Normal mode
107
109
 
108
110
  | Keys | Action |
109
111
  |----------------------|-----------------------------------------------------|
110
112
  | `h` / `j` / `k` / `l`| focus pane left / down / up / right (spatial) |
113
+ | `H` / `J` / `K` / `L`| move focused pane left / down / up / right |
111
114
  | `i` | drop into passthrough mode |
112
- | `c` / `K` | new / close focused pane |
115
+ | `c` / `x` | new / close focused pane (close asks `y/n`) |
113
116
  | `t` / `g` / `m` | layout: tall / grid / monocle |
114
117
  | `Tab` / `Enter` | cycle layout / promote focused to master |
115
118
  | `a` / `1` … `9` | toggle last pane / jump to pane by number |
@@ -125,6 +128,12 @@ direction. In monocle (where every pane occupies the full area) it
125
128
  falls back to linear next/previous so the keys still do something
126
129
  useful.
127
130
 
131
+ `H`/`J`/`K`/`L` swap the focused pane with the spatial neighbor in
132
+ that direction, then keep focus on the moved pane so you can keep
133
+ dragging it. Swapping into position 0 of `tall`/`grid` promotes the
134
+ pane to master (the layout master is always `panes[0]`). In monocle
135
+ the move falls back to linear next/prev shuffling.
136
+
128
137
  ### Passthrough mode (`Ctrl-a` prefix)
129
138
 
130
139
  | Keys | Action |
@@ -134,7 +143,7 @@ useful.
134
143
  | `C-a n` / `p` | focus next / previous pane (linear) |
135
144
  | `C-a a` | toggle last (previously focused) pane |
136
145
  | `C-a 1` … `9` | jump to pane by its label |
137
- | `C-a K` | close focused pane (or hide drawer) |
146
+ | `C-a x` | close focused pane (asks `y/n`; hides drawer with no prompt) |
138
147
  | `C-a Tab` | cycle layout (`tall` → `grid` → `monocle`) |
139
148
  | `C-a Enter` | promote focused pane to master |
140
149
  | `C-a ~` | toggle drawer (shell) |
@@ -157,12 +166,19 @@ navigation; the status bar shows a key hint and the pane title gains
157
166
 
158
167
  | Keys | Action |
159
168
  |-------------------------|-------------------------------------|
160
- | `j` / `k` | scroll one line |
161
- | `d` / `u` (or `C-d`/`C-u`) | half page |
169
+ | `j` / `k` or `↓` / `↑` | scroll one line |
170
+ | `d` / `u` (or `C-d`/`C-u`, `PgDn`/`PgUp`) | half page |
162
171
  | `f` / Space (or `C-f`/`C-b`) | full page |
163
- | `g` / `G` | top / bottom |
172
+ | `g` / `G` (or `Home` / `End`) | top / bottom |
173
+ | `/` *query* `Enter` | search forward (toward newer); `?` searches backward |
174
+ | `n` / `N` | next / previous match in the search direction (wraps) |
164
175
  | `q` / `Esc` / `C-c` | exit back to normal mode |
165
176
 
177
+ Search uses smart-case (case-insensitive unless the query has an
178
+ uppercase letter), scans both scrollback and the live buffer, and
179
+ centers the chosen match in the viewport. Matches stay highlighted in
180
+ yellow while you're in scrollback; exiting clears the highlight.
181
+
166
182
  Press `v` inside scrollback to enter a movable-cursor selection mode.
167
183
  Vim-style motions are supported:
168
184
 
@@ -177,6 +177,37 @@ module Muxr
177
177
  invalidate
178
178
  end
179
179
 
180
+ # Swap the focused pane with its spatial neighbor in `direction`. Bound
181
+ # to shift-HJKL in normal mode. Mirrors focus_direction's geometry-aware
182
+ # lookup so the same "what does my arrow point at" intuition decides
183
+ # which neighbor gets bumped. Monocle has no spatial layout, so HJKL
184
+ # falls back to reordering by linear next/prev — useful for shuffling
185
+ # the master before flipping back to tall/grid.
186
+ def move_direction(direction)
187
+ return if @session.window.panes.empty?
188
+ # The drawer isn't part of the tiled pane list; HJKL while focused on
189
+ # it would be ambiguous. No-op.
190
+ return if @session.focus_drawer && @session.drawer&.visible?
191
+
192
+ win = @session.window
193
+ idx = LayoutManager.neighbor(current_pane_rects, win.focused_index, direction)
194
+ if idx.nil? && win.layout == :monocle
195
+ target = case direction
196
+ when :right, :down then (win.focused_index + 1) % win.panes.length
197
+ when :left, :up then (win.focused_index - 1) % win.panes.length
198
+ end
199
+ if target && target != win.focused_index
200
+ win.move_focused_to(target)
201
+ invalidate
202
+ end
203
+ return
204
+ end
205
+
206
+ return unless idx
207
+ win.move_focused_to(idx)
208
+ invalidate
209
+ end
210
+
180
211
  # Explicit layout set, used by the normal-mode t/g/m bindings and the
181
212
  # `:layout <name>` command.
182
213
  def set_layout(layout)
@@ -202,6 +233,31 @@ module Muxr
202
233
  invalidate
203
234
  end
204
235
 
236
+ # Two-step close — same shape as the quit flow. Hiding the drawer is
237
+ # cheap and reversible, so we skip the prompt for the drawer case.
238
+ def request_close
239
+ if @session.focus_drawer && @session.drawer&.visible?
240
+ hide_drawer
241
+ return
242
+ end
243
+ return unless focused_pane
244
+ return if @input.state == :confirm_close
245
+ @input.enter_confirm_close
246
+ flash("close pane? (y/n)")
247
+ invalidate
248
+ end
249
+
250
+ def confirm_close
251
+ close_focused
252
+ end
253
+
254
+ def cancel_close
255
+ @message = nil
256
+ @message_expires = nil
257
+ flash("cancelled")
258
+ invalidate
259
+ end
260
+
205
261
  def close_focused
206
262
  if @session.focus_drawer && @session.drawer&.visible?
207
263
  hide_drawer
@@ -337,11 +393,63 @@ module Muxr
337
393
  def exit_scrollback
338
394
  target = focused_target
339
395
  target&.terminal&.clear_selection
396
+ target&.terminal&.clear_search
340
397
  target&.terminal&.scroll_to_bottom
341
398
  @renderer.reset_frame!
342
399
  invalidate
343
400
  end
344
401
 
402
+ # Bound to `/` (forward) and `?` (backward) in scrollback mode. Drops
403
+ # the user into a buffered prompt; commit_search / cancel_search exit
404
+ # back to scrollback.
405
+ def enter_search(direction: :forward)
406
+ @input.enter_search_mode(direction: direction)
407
+ invalidate
408
+ end
409
+
410
+ def commit_search(query)
411
+ target = focused_target
412
+ return unless target
413
+ term = target.terminal
414
+ direction = @input.search_direction
415
+ count = term.search(query, direction: direction)
416
+ if query.empty?
417
+ # Empty query just dismisses the prompt; leave the prior search
418
+ # state alone (term.search already cleared it though).
419
+ elsif count.zero?
420
+ flash("not found: #{query}")
421
+ else
422
+ flash("#{count} match#{count == 1 ? "" : "es"} (n/N to navigate)")
423
+ end
424
+ @renderer.reset_frame!
425
+ invalidate
426
+ end
427
+
428
+ def cancel_search
429
+ @renderer.reset_frame!
430
+ invalidate
431
+ end
432
+
433
+ def find_next
434
+ step_search(@input.search_direction)
435
+ end
436
+
437
+ def find_prev
438
+ step_search(@input.search_direction == :forward ? :backward : :forward)
439
+ end
440
+
441
+ def step_search(direction)
442
+ target = focused_target
443
+ return unless target
444
+ term = target.terminal
445
+ if term.search_matches.empty?
446
+ flash("no search active")
447
+ return
448
+ end
449
+ term.find_in_direction(direction)
450
+ invalidate
451
+ end
452
+
345
453
  def scroll_focused(action)
346
454
  target = focused_target
347
455
  return unless target
@@ -834,6 +942,8 @@ module Muxr
834
942
  @session,
835
943
  input_state: @input.state,
836
944
  command_buffer: @input.command_buffer,
945
+ search_buffer: @input.search_buffer,
946
+ search_direction: @input.search_direction,
837
947
  message: @message,
838
948
  help: @help_visible
839
949
  )
@@ -26,7 +26,7 @@ module Muxr
26
26
  when "new", "c"
27
27
  @app.new_pane
28
28
  when "close", "kill", "k"
29
- @app.close_focused
29
+ @app.request_close
30
30
  when "next" then @app.focus_next
31
31
  when "prev" then @app.focus_prev
32
32
  when "master" then @app.promote_master
@@ -11,7 +11,8 @@ module Muxr
11
11
  # normal mode.
12
12
  #
13
13
  # Plus the sub-states pre-existing from before modes existed:
14
- # :prefix, :command, :confirm_quit, :help, :scrollback, :selection.
14
+ # :prefix, :command, :confirm_quit, :confirm_close, :help, :scrollback,
15
+ # :search, :selection.
15
16
  #
16
17
  # One-shot sub-states (prefix, command, confirm_quit, help) return to
17
18
  # @base_mode (whichever of :normal/:passthrough is active) when they
@@ -28,7 +29,7 @@ module Muxr
28
29
  # [:symbol, *args] → @app.public_send(:symbol, *args)
29
30
  NORMAL_BINDINGS = {
30
31
  "c" => :new_pane,
31
- "K" => :close_focused,
32
+ "x" => :request_close,
32
33
  "t" => [:set_layout, :tall],
33
34
  "g" => [:set_layout, :grid],
34
35
  "m" => [:set_layout, :monocle],
@@ -39,6 +40,10 @@ module Muxr
39
40
  "j" => [:focus_direction, :down],
40
41
  "k" => [:focus_direction, :up],
41
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],
42
47
  "a" => :focus_last,
43
48
  "~" => :toggle_drawer,
44
49
  "C" => :toggle_claude_drawer,
@@ -55,7 +60,7 @@ module Muxr
55
60
  "n" => :focus_next,
56
61
  "p" => :focus_prev,
57
62
  "a" => :focus_last,
58
- "K" => :close_focused,
63
+ "x" => :request_close,
59
64
  "\t" => :cycle_layout,
60
65
  "\r" => :promote_master,
61
66
  "\n" => :promote_master,
@@ -85,6 +90,20 @@ module Muxr
85
90
  "G" => :bottom
86
91
  }.freeze
87
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
+
88
107
  SCROLLBACK_EXITS = ["q", "\e", "\x03"].freeze # q, Esc, Ctrl-c
89
108
 
90
109
  SELECTION_BINDINGS = {
@@ -124,13 +143,15 @@ module Muxr
124
143
 
125
144
  DIGIT_RE = /\A[1-9]\z/.freeze
126
145
 
127
- attr_reader :state, :command_buffer, :base_mode
146
+ attr_reader :state, :command_buffer, :search_buffer, :search_direction, :base_mode
128
147
 
129
148
  def initialize(app)
130
149
  @app = app
131
150
  @state = :normal
132
151
  @base_mode = :normal
133
152
  @command_buffer = +""
153
+ @search_buffer = +""
154
+ @search_direction = :forward
134
155
  end
135
156
 
136
157
  def feed(data)
@@ -151,6 +172,21 @@ module Muxr
151
172
  next
152
173
  end
153
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
+
154
190
  ch = remaining[0]
155
191
  remaining = remaining[1..] || ""
156
192
  case @state
@@ -161,12 +197,16 @@ module Muxr
161
197
  @state = @base_mode
162
198
  when :confirm_quit
163
199
  handle_confirm_quit(ch)
200
+ when :confirm_close
201
+ handle_confirm_close(ch)
164
202
  when :prefix
165
203
  handle_prefix(ch)
166
204
  when :command
167
205
  handle_command_input(ch)
168
206
  when :scrollback
169
207
  handle_scrollback_input(ch)
208
+ when :search
209
+ handle_search_input(ch)
170
210
  when :selection
171
211
  handle_selection_input(ch)
172
212
  end
@@ -181,10 +221,20 @@ module Muxr
181
221
  @state = :confirm_quit
182
222
  end
183
223
 
224
+ def enter_confirm_close
225
+ @state = :confirm_close
226
+ end
227
+
184
228
  def enter_scrollback_mode
185
229
  @state = :scrollback
186
230
  end
187
231
 
232
+ def enter_search_mode(direction: :forward)
233
+ @state = :search
234
+ @search_direction = direction
235
+ @search_buffer = +""
236
+ end
237
+
188
238
  def enter_selection_mode
189
239
  @state = :selection
190
240
  end
@@ -268,8 +318,8 @@ module Muxr
268
318
  @state = @base_mode
269
319
  when action
270
320
  @app.public_send(action)
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.
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.
273
323
  @state = @base_mode if @state == :prefix
274
324
  else
275
325
  # Unknown prefix key: return to base mode silently.
@@ -286,6 +336,15 @@ module Muxr
286
336
  end
287
337
  end
288
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
+
289
348
  def handle_scrollback_input(ch)
290
349
  if SCROLLBACK_EXITS.include?(ch)
291
350
  enter_idle_mode
@@ -296,12 +355,78 @@ module Muxr
296
355
  @app.enter_selection
297
356
  return
298
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
299
376
  action = SCROLLBACK_BINDINGS[ch]
300
377
  @app.scroll_focused(action) if action
301
378
  # Unknown keys: ignored. Avoids accidental shell input when the user
302
379
  # mistypes inside scrollback mode.
303
380
  end
304
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
+
305
430
  def handle_selection_input(ch)
306
431
  if SELECTION_YANK.include?(ch)
307
432
  @app.exit_selection(yank: true)
data/lib/muxr/renderer.rb CHANGED
@@ -23,11 +23,19 @@ module Muxr
23
23
  prefix: [:c256, 42].freeze, # green (passthrough sub-state)
24
24
  command: [:c256, 226].freeze, # yellow
25
25
  scrollback: [:c256, 214].freeze, # orange
26
+ search: [:c256, 214].freeze, # orange (scrollback sub-state)
26
27
  selection: [:c256, 201].freeze, # magenta
27
- confirm_quit: [:c256, 196].freeze, # red
28
- help: [:c256, 39].freeze # blue
28
+ confirm_quit: [:c256, 196].freeze, # red
29
+ confirm_close: [:c256, 196].freeze, # red
30
+ help: [:c256, 39].freeze # blue
29
31
  }.freeze
30
32
 
33
+ # Background applied to cells that match the active scrollback search.
34
+ # Bright enough to stand out over typical foreground SGRs while leaving
35
+ # the original glyph readable.
36
+ SEARCH_MATCH_BG = [:c256, 226].freeze # yellow
37
+ SEARCH_MATCH_FG = [:c256, 16].freeze # black
38
+
31
39
  HORIZONTAL = "─".freeze
32
40
  VERTICAL = "│".freeze
33
41
  TL = "┌".freeze
@@ -66,7 +74,7 @@ module Muxr
66
74
  @prev = nil
67
75
  end
68
76
 
69
- def render(session, input_state: :normal, command_buffer: "", message: nil, help: false)
77
+ def render(session, input_state: :normal, command_buffer: "", search_buffer: "", search_direction: :forward, message: nil, help: false)
70
78
  w = session.width
71
79
  h = session.height
72
80
  return if w < 4 || h < 3
@@ -75,10 +83,17 @@ module Muxr
75
83
 
76
84
  compose_panes(frame, session, input_state: input_state)
77
85
  compose_drawer(frame, session, input_state: input_state) if session.drawer&.visible?
78
- compose_status_bar(frame, session, input_state: input_state, command_buffer: command_buffer, message: message)
86
+ compose_status_bar(
87
+ frame, session,
88
+ input_state: input_state,
89
+ command_buffer: command_buffer,
90
+ search_buffer: search_buffer,
91
+ search_direction: search_direction,
92
+ message: message
93
+ )
79
94
  compose_help(frame, session) if help
80
95
 
81
- emit_frame(frame, session, input_state: input_state, command_buffer: command_buffer)
96
+ emit_frame(frame, session, input_state: input_state, command_buffer: command_buffer, search_buffer: search_buffer)
82
97
  end
83
98
 
84
99
  private
@@ -216,14 +231,16 @@ module Muxr
216
231
  when :prefix then "^A"
217
232
  when :command then "CMD"
218
233
  when :scrollback then "SCROLL"
234
+ when :search then "SEARCH"
219
235
  when :selection then "SEL"
220
- when :confirm_quit then "QUIT?"
221
- when :help then "HELP"
236
+ when :confirm_quit then "QUIT?"
237
+ when :confirm_close then "CLOSE?"
238
+ when :help then "HELP"
222
239
  else "?"
223
240
  end
224
241
  end
225
242
 
226
- def compose_status_bar(frame, session, input_state:, command_buffer:, message:)
243
+ def compose_status_bar(frame, session, input_state:, command_buffer:, search_buffer: "", search_direction: :forward, message: nil)
227
244
  y = session.height - 1
228
245
  w = session.width
229
246
  win = session.window
@@ -294,7 +311,25 @@ module Muxr
294
311
  c.attrs = 0
295
312
  end
296
313
  elsif input_state == :scrollback
297
- overlay = " SCROLLBACK j/k line d/u half f/b page g/G top/bot v select q quit "
314
+ overlay = " SCROLLBACK ↑↓/j/k line d/u half f/b page g/G top/bot / search n/N next/prev v select q quit "
315
+ overlay = overlay[0, w]
316
+ overlay.each_char.with_index do |ch, x|
317
+ c = frame[y][x]
318
+ c.char = ch
319
+ c.fg = [:c256, 232]
320
+ c.bg = [:c256, 214]
321
+ c.attrs = Terminal::BOLD
322
+ end
323
+ (overlay.length...w).each do |x|
324
+ c = frame[y][x]
325
+ c.char = " "
326
+ c.fg = nil
327
+ c.bg = [:c256, 214]
328
+ c.attrs = 0
329
+ end
330
+ elsif input_state == :search
331
+ prefix = search_direction == :backward ? "?" : "/"
332
+ overlay = "#{prefix}#{search_buffer}"
298
333
  overlay = overlay[0, w]
299
334
  overlay.each_char.with_index do |ch, x|
300
335
  c = frame[y][x]
@@ -353,8 +388,9 @@ module Muxr
353
388
  "",
354
389
  "NORMAL mode (default; no prefix)",
355
390
  " h / j / k / l focus pane left / down / up / right",
391
+ " H / J / K / L move pane left / down / up / right",
356
392
  " i drop into passthrough mode",
357
- " c / K new / close pane",
393
+ " c / x new / close pane (close asks y/n)",
358
394
  " t / g / m layout: tall / grid / monocle",
359
395
  " Tab / Enter cycle layout / promote to master",
360
396
  " a / 1..9 last pane / jump by number",
@@ -365,14 +401,15 @@ module Muxr
365
401
  "",
366
402
  "PASSTHROUGH mode (keys reach the focused pane; prefix is Ctrl-a)",
367
403
  " C-a Esc return to normal mode",
368
- " C-a c K t g m same as normal-mode bindings",
404
+ " C-a c x t g m same as normal-mode bindings",
369
405
  " C-a Tab Enter cycle layout / promote master",
370
406
  " C-a n / p / a next / prev / last pane",
371
407
  " C-a [ ] scrollback / paste buffer",
372
408
  " C-a C-a send literal Ctrl-a to focused pane",
373
409
  "",
374
410
  "SCROLLBACK mode (exits to the mode you came from)",
375
- " j/k d/u f/b g/G scroll C-b/C-f page v→cursor",
411
+ " j/k ↑/↓ d/u f/b g/G scroll C-b/C-f page v→cursor",
412
+ " / search-fwd ? search-back n/N next/prev match",
376
413
  " cursor: h/j/k/l 0/^/$ w/e/b W/E/B H/M/L g/G",
377
414
  " v select, C-v block, y/Enter yank, q/Esc cancel",
378
415
  "",
@@ -445,6 +482,7 @@ module Muxr
445
482
  rows = term.rows
446
483
  cols = term.cols
447
484
  selection = term.selection_active?
485
+ search = term.search_active?
448
486
  rows.times do |r|
449
487
  fy = dst_y + r
450
488
  next if fy < 0 || fy >= frame.length
@@ -457,6 +495,13 @@ module Muxr
457
495
  dst.fg = src.fg
458
496
  dst.bg = src.bg
459
497
  dst.attrs = src.attrs
498
+ if search && term.cell_in_match?(r, c)
499
+ # Highlight wins over the cell's own bg so matches stay visible
500
+ # across whatever SGR the underlying program was using. Selection
501
+ # still applies on top via REVERSE below.
502
+ dst.fg = SEARCH_MATCH_FG
503
+ dst.bg = SEARCH_MATCH_BG
504
+ end
460
505
  dst.attrs |= Terminal::REVERSE if selection && term.selected_at_visible?(r, c)
461
506
  dst.hyperlink = src.hyperlink
462
507
  end
@@ -473,7 +518,7 @@ module Muxr
473
518
  c.attrs = attrs
474
519
  end
475
520
 
476
- def emit_frame(frame, session, input_state:, command_buffer:)
521
+ def emit_frame(frame, session, input_state:, command_buffer:, search_buffer: "")
477
522
  # \e[?2026h enters synchronized-output mode so terminals that support it
478
523
  # (Ghostty, kitty, iTerm2 ≥3.5, WezTerm, Alacritty ≥0.13, foot) present
479
524
  # the whole frame atomically instead of repainting incrementally as bytes
@@ -517,7 +562,7 @@ module Muxr
517
562
  end
518
563
  out << "\e]8;;\e\\" if cur_hyperlink
519
564
  out << "\e[0m"
520
- out << cursor_position(session, input_state: input_state, command_buffer: command_buffer)
565
+ out << cursor_position(session, input_state: input_state, command_buffer: command_buffer, search_buffer: search_buffer)
521
566
  out << "\e[?2026l"
522
567
  @out.write(out)
523
568
  @out.flush
@@ -526,11 +571,15 @@ module Muxr
526
571
  @prev_h = frame.length
527
572
  end
528
573
 
529
- def cursor_position(session, input_state:, command_buffer:)
574
+ def cursor_position(session, input_state:, command_buffer:, search_buffer: "")
530
575
  if input_state == :command
531
576
  col = 1 + command_buffer.length + 1 # ':' + buffer
532
577
  return "\e[#{session.height};#{col}H\e[?25h"
533
578
  end
579
+ if input_state == :search
580
+ col = 1 + search_buffer.length + 1 # '/' or '?' + buffer
581
+ return "\e[#{session.height};#{col}H\e[?25h"
582
+ end
534
583
 
535
584
  target =
536
585
  if session.focus_drawer && session.drawer&.visible? && session.drawer.pane
data/lib/muxr/terminal.rb CHANGED
@@ -77,6 +77,10 @@ module Muxr
77
77
  @sync_pending = false
78
78
  @sync_started_at = nil
79
79
  @pending_replies = +"".b
80
+ @search_query = nil
81
+ @search_direction = :forward
82
+ @search_matches = []
83
+ @search_current = nil
80
84
  end
81
85
 
82
86
  # Bytes the emulator owes back to the inner program in response to a
@@ -351,6 +355,81 @@ module Muxr
351
355
  @dirty = true
352
356
  end
353
357
 
358
+ # ---------- search ----------
359
+ #
360
+ # Substring search over the full timeline (scrollback + live buffer).
361
+ # Smart-case: case-insensitive if the query is all-lowercase, sensitive
362
+ # otherwise. Matches are kept in timeline coordinates so they stay
363
+ # anchored to the same text as the user pages history.
364
+
365
+ attr_reader :search_query, :search_matches, :search_current, :search_direction
366
+
367
+ def search(query, direction: :forward)
368
+ query = query.to_s
369
+ if query.empty?
370
+ clear_search
371
+ return 0
372
+ end
373
+ @search_query = query
374
+ @search_direction = direction
375
+ @search_matches = collect_matches(query)
376
+ if @search_matches.empty?
377
+ @search_current = nil
378
+ @dirty = true
379
+ return 0
380
+ end
381
+ @search_current = nearest_match_in_direction(current_search_anchor_row, direction, inclusive: true)
382
+ @search_current ||= direction == :forward ? 0 : @search_matches.length - 1
383
+ scroll_view_to_match(@search_current)
384
+ @dirty = true
385
+ @search_matches.length
386
+ end
387
+
388
+ # Move to the next/previous match in the given direction, wrapping.
389
+ # Returns the new current match index, or nil if there are no matches.
390
+ # Anchors on the current match (not the viewport top) so n always
391
+ # advances even when scroll_view_to_match has centered the previous
392
+ # hit and dragged the viewport top behind it.
393
+ def find_in_direction(direction)
394
+ return nil if @search_matches.empty?
395
+ anchor_tr =
396
+ if @search_current && @search_matches[@search_current]
397
+ @search_matches[@search_current][0]
398
+ else
399
+ current_search_anchor_row
400
+ end
401
+ idx = strict_next_in_direction(anchor_tr, direction)
402
+ if idx.nil?
403
+ # Wrap: pick the far end depending on direction.
404
+ idx = direction == :forward ? 0 : @search_matches.length - 1
405
+ end
406
+ @search_current = idx
407
+ scroll_view_to_match(idx)
408
+ @dirty = true
409
+ idx
410
+ end
411
+
412
+ def cell_in_match?(visible_r, c)
413
+ return false if @search_matches.empty?
414
+ tr = timeline_row_for_visible(visible_r)
415
+ # Linear scan is fine: SCROLLBACK_MAX caps matches at O(rows*cols), and
416
+ # the renderer touches each visible cell once per frame. A row-indexed
417
+ # cache would matter at much larger buffer sizes than ours.
418
+ @search_matches.any? { |mr, sc, ec| mr == tr && c >= sc && c <= ec }
419
+ end
420
+
421
+ def search_active?
422
+ !(@search_query.nil? || @search_matches.empty?)
423
+ end
424
+
425
+ def clear_search
426
+ return if @search_query.nil? && @search_matches.empty?
427
+ @search_query = nil
428
+ @search_matches = []
429
+ @search_current = nil
430
+ @dirty = true
431
+ end
432
+
354
433
  def selected_at_visible?(r, c)
355
434
  return false unless @selection_anchor
356
435
  tr = timeline_row_for_visible(r)
@@ -770,6 +849,11 @@ module Muxr
770
849
  @selection_anchor[0] = [@selection_anchor[0] - 1, 0].max
771
850
  @selection_cursor[0] = [@selection_cursor[0] - 1, 0].max
772
851
  end
852
+ unless @search_matches.empty?
853
+ @search_matches.each { |m| m[0] -= 1 }
854
+ @search_matches.reject! { |m| m[0] < 0 }
855
+ @search_current = nil if @search_current && @search_current >= @search_matches.length
856
+ end
773
857
  end
774
858
  # Keep the user's view frozen on the same content when new rows arrive
775
859
  # while they're scrolled back.
@@ -873,6 +957,73 @@ module Muxr
873
957
  end
874
958
  end
875
959
 
960
+ def collect_matches(query)
961
+ case_sensitive = query.match?(/[A-Z]/)
962
+ needle = case_sensitive ? query : query.downcase
963
+ matches = []
964
+ timeline_size.times do |tr|
965
+ row = timeline_row(tr)
966
+ next if row.nil?
967
+ line = String.new(capacity: @cols)
968
+ @cols.times { |c| line << (row[c]&.char || " ") }
969
+ haystack = case_sensitive ? line : line.downcase
970
+ start = 0
971
+ while (idx = haystack.index(needle, start))
972
+ matches << [tr, idx, idx + needle.length - 1]
973
+ # Advance past the start of this match so overlapping needles
974
+ # ("aa" in "aaaa") still emit one match per starting position.
975
+ start = idx + 1
976
+ end
977
+ end
978
+ matches
979
+ end
980
+
981
+ # Top of the current viewport in timeline coordinates. Used as the
982
+ # reference point for "search from where the user is looking now".
983
+ def current_search_anchor_row
984
+ timeline_row_for_visible(0)
985
+ end
986
+
987
+ # Smallest match index whose row is >= anchor_tr (forward) or largest
988
+ # match index whose row is <= anchor_tr (backward). Used by search() so
989
+ # the first jump lands on the nearest match in the search direction
990
+ # without forcing the user to press n.
991
+ def nearest_match_in_direction(anchor_tr, direction, inclusive:)
992
+ if direction == :forward
993
+ @search_matches.each_with_index do |(mr, _, _), i|
994
+ return i if inclusive ? mr >= anchor_tr : mr > anchor_tr
995
+ end
996
+ nil
997
+ else
998
+ best = nil
999
+ @search_matches.each_with_index do |(mr, _, _), i|
1000
+ if (inclusive ? mr <= anchor_tr : mr < anchor_tr)
1001
+ best = i
1002
+ else
1003
+ break
1004
+ end
1005
+ end
1006
+ best
1007
+ end
1008
+ end
1009
+
1010
+ # Strict next match (n/N) — never matches the current row; that would
1011
+ # leave the user stuck on the same line they're already on.
1012
+ def strict_next_in_direction(anchor_tr, direction)
1013
+ nearest_match_in_direction(anchor_tr, direction, inclusive: false)
1014
+ end
1015
+
1016
+ # Center the match in the viewport when possible. set_view_offset clamps
1017
+ # to [0, scrollback.size] so recent matches end up showing the live
1018
+ # buffer at the bottom and very old ones pin to the top.
1019
+ def scroll_view_to_match(idx)
1020
+ match = @search_matches[idx]
1021
+ return unless match
1022
+ tr = match[0]
1023
+ desired = @scrollback.size - tr + (@rows / 2)
1024
+ set_view_offset(desired)
1025
+ end
1026
+
876
1027
  def ensure_selection_cursor_visible
877
1028
  return unless @selection_cursor
878
1029
  tr = @selection_cursor[0]
data/lib/muxr/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Muxr
2
- VERSION = "0.1.6"
2
+ VERSION = "0.1.7"
3
3
  end
data/lib/muxr/window.rb CHANGED
@@ -86,6 +86,19 @@ module Muxr
86
86
  @panes[i], @panes[j] = @panes[j], @panes[i]
87
87
  end
88
88
 
89
+ # Swap the focused pane with the pane at `idx` and keep focus on the
90
+ # moved pane (so HJKL can keep dragging it across the layout). Unlike
91
+ # `focused_index=`, this does not record a last_focused_pane — the user
92
+ # is still on the same pane, just at a new index in the array.
93
+ def move_focused_to(idx)
94
+ return false if @panes.empty?
95
+ return false unless idx.is_a?(Integer) && idx >= 0 && idx < @panes.length
96
+ return false if idx == @focused_index
97
+ @panes[@focused_index], @panes[idx] = @panes[idx], @panes[@focused_index]
98
+ @focused_index = idx
99
+ true
100
+ end
101
+
89
102
  def cycle_layout
90
103
  i = LAYOUTS.index(@layout) || 0
91
104
  @layout = LAYOUTS[(i + 1) % LAYOUTS.length]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: muxr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roel Bondoc