detached 00a245c

feat(tabs): add session tab picker

commit
00a245cc8e0184d7d46cc666b47c35d6f4790fca
author
brice · · 2026-07-02 21:10 UTC
parent
2b3374c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +1695 -365
M README.md +6 -5
@@ -23,7 +23,7 @@ vev --help show help
2323 vev --version show version
2424 ```
2525
26-Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last window closes. The daemon starts on first use and exits when no sessions remain.
26+Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last tab closes. The daemon starts on first use and exits when no sessions remain.
2727
2828 Remote attach runs `ssh user@host vev _stdio ...` under the hood, so vev must be installed on the remote host and reachable via your normal SSH config. Each remote attach opens one ssh connection. To make reconnects near-instant, enable OpenSSH connection reuse for your hosts in `~/.ssh/config`:
2929
@@ -42,12 +42,13 @@ All bindings use the Alt modifier directly, no prefix key.
4242
4343 | Key | Action |
4444 |---|---|
45-| Alt+c | new window |
46-| Alt+n / Alt+p | next / previous window |
47-| Alt+1 .. Alt+9 | select window by number |
48-| Alt+x | close window (last window ends the session) |
45+| Alt+c | new tab |
46+| Alt+n / Alt+p | next / previous tab |
47+| Alt+1 .. Alt+9 | select tab by number |
48+| Alt+x | close tab (last tab ends the session) |
4949 | Alt+d | detach |
5050 | Alt+r | promote the current ephemeral session to named |
51+| Alt+t | open session/tab picker |
5152 | Alt+u | enter copy mode |
5253
5354 ## Copy mode
@@ -23,7 +23,7 @@ vev --help show help
23 vev --version show version23 vev --version show version
24 ```24 ```
25 25
26-Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last window closes. The daemon starts on first use and exits when no sessions remain.26+Ephemeral sessions get a number (0, 1, 2...) and die when you detach. Named sessions survive detach and live until killed or their last tab closes. The daemon starts on first use and exits when no sessions remain.
27 27
28 Remote attach runs `ssh user@host vev _stdio ...` under the hood, so vev must be installed on the remote host and reachable via your normal SSH config. Each remote attach opens one ssh connection. To make reconnects near-instant, enable OpenSSH connection reuse for your hosts in `~/.ssh/config`:28 Remote attach runs `ssh user@host vev _stdio ...` under the hood, so vev must be installed on the remote host and reachable via your normal SSH config. Each remote attach opens one ssh connection. To make reconnects near-instant, enable OpenSSH connection reuse for your hosts in `~/.ssh/config`:
29 29
@@ -42,12 +42,13 @@ All bindings use the Alt modifier directly, no prefix key.
42 42
43 | Key | Action |43 | Key | Action |
44 |---|---|44 |---|---|
45-| Alt+c | new window |45+| Alt+c | new tab |
46-| Alt+n / Alt+p | next / previous window |46+| Alt+n / Alt+p | next / previous tab |
47-| Alt+1 .. Alt+9 | select window by number |47+| Alt+1 .. Alt+9 | select tab by number |
48-| Alt+x | close window (last window ends the session) |48+| Alt+x | close tab (last tab ends the session) |
49 | Alt+d | detach |49 | Alt+d | detach |
50 | Alt+r | promote the current ephemeral session to named |50 | Alt+r | promote the current ephemeral session to named |
51+| Alt+t | open session/tab picker |
51 | Alt+u | enter copy mode |52 | Alt+u | enter copy mode |
52 53
53 ## Copy mode54 ## Copy mode
M internal/app/run.go +2 -2
@@ -317,13 +317,13 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
317317 return
318318 }
319319 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
320- _, _ = fmt.Fprintln(tw, "NAME\tWINDOWS\tATTACHED")
320+ _, _ = fmt.Fprintln(tw, "NAME\tTABS\tATTACHED")
321321 for _, s := range sessions {
322322 attached := "no"
323323 if s.Attached {
324324 attached = "yes"
325325 }
326- _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Windows, attached)
326+ _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Tabs, attached)
327327 }
328328 _ = tw.Flush()
329329 }
@@ -317,13 +317,13 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
317 return317 return
318 }318 }
319 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)319 tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
320- _, _ = fmt.Fprintln(tw, "NAME\tWINDOWS\tATTACHED")320+ _, _ = fmt.Fprintln(tw, "NAME\tTABS\tATTACHED")
321 for _, s := range sessions {321 for _, s := range sessions {
322 attached := "no"322 attached := "no"
323 if s.Attached {323 if s.Attached {
324 attached = "yes"324 attached = "yes"
325 }325 }
326- _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Windows, attached)326+ _, _ = fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, s.Tabs, attached)
327 }327 }
328 _ = tw.Flush()328 _ = tw.Flush()
329 }329 }
M internal/domain/types.go +8 -8
@@ -18,22 +18,22 @@ type Rect struct {
1818 // SessionID uniquely identifies a session.
1919 type SessionID string
2020
21-// WindowID uniquely identifies a window within a session.
22-type WindowID string
21+// TabID uniquely identifies a tab within a session.
22+type TabID string
2323
24-// Window is a single window (tab) within a session.
25-type Window struct {
26- ID WindowID
24+// Tab is a single tab within a session.
25+type Tab struct {
26+ ID TabID
2727 Index int // 0-based; drives Alt+1..9 and status-bar order
2828 Name string
2929 Active bool
3030 }
3131
32-// Session is a persistent multiplexer session containing one or more windows.
32+// Session is a persistent multiplexer session containing one or more tabs.
3333 type Session struct {
3434 ID SessionID
3535 Name string // display name; numbered "0","1"... when ephemeral
3636 Ephemeral bool
37- Windows []Window
38- ActiveWin WindowID
37+ Tabs []Tab
38+ ActiveTab TabID
3939 }
@@ -18,22 +18,22 @@ type Rect struct {
18 // SessionID uniquely identifies a session.18 // SessionID uniquely identifies a session.
19 type SessionID string19 type SessionID string
20 20
21-// WindowID uniquely identifies a window within a session.21+// TabID uniquely identifies a tab within a session.
22-type WindowID string22+type TabID string
23 23
24-// Window is a single window (tab) within a session.24+// Tab is a single tab within a session.
25-type Window struct {25+type Tab struct {
26- ID WindowID26+ ID TabID
27 Index int // 0-based; drives Alt+1..9 and status-bar order27 Index int // 0-based; drives Alt+1..9 and status-bar order
28 Name string28 Name string
29 Active bool29 Active bool
30 }30 }
31 31
32-// Session is a persistent multiplexer session containing one or more windows.32+// Session is a persistent multiplexer session containing one or more tabs.
33 type Session struct {33 type Session struct {
34 ID SessionID34 ID SessionID
35 Name string // display name; numbered "0","1"... when ephemeral35 Name string // display name; numbered "0","1"... when ephemeral
36 Ephemeral bool36 Ephemeral bool
37- Windows []Window37+ Tabs []Tab
38- ActiveWin WindowID38+ ActiveTab TabID
39 }39 }
M internal/ports/wire.go +3 -3
@@ -111,7 +111,7 @@ type SessionInfo struct {
111111 SessionID string
112112 Name string
113113 Ephemeral bool
114- Windows uint16
114+ Tabs uint16
115115 Attached bool
116116 }
117117
@@ -456,7 +456,7 @@ func MarshalSessions(m Sessions) []byte {
456456 } else {
457457 w.putUint8(0)
458458 }
459- w.putUint16(s.Windows)
459+ w.putUint16(s.Tabs)
460460 if s.Attached {
461461 w.putUint8(1)
462462 } else {
@@ -487,7 +487,7 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
487487 return Sessions{}, err
488488 }
489489 s.Ephemeral = eph != 0
490- if s.Windows, err = r.getUint16(); err != nil {
490+ if s.Tabs, err = r.getUint16(); err != nil {
491491 return Sessions{}, err
492492 }
493493 att, err := r.getUint8()
@@ -111,7 +111,7 @@ type SessionInfo struct {
111 SessionID string111 SessionID string
112 Name string112 Name string
113 Ephemeral bool113 Ephemeral bool
114- Windows uint16114+ Tabs uint16
115 Attached bool115 Attached bool
116 }116 }
117 117
@@ -456,7 +456,7 @@ func MarshalSessions(m Sessions) []byte {
456 } else {456 } else {
457 w.putUint8(0)457 w.putUint8(0)
458 }458 }
459- w.putUint16(s.Windows)459+ w.putUint16(s.Tabs)
460 if s.Attached {460 if s.Attached {
461 w.putUint8(1)461 w.putUint8(1)
462 } else {462 } else {
@@ -487,7 +487,7 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
487 return Sessions{}, err487 return Sessions{}, err
488 }488 }
489 s.Ephemeral = eph != 0489 s.Ephemeral = eph != 0
490- if s.Windows, err = r.getUint16(); err != nil {490+ if s.Tabs, err = r.getUint16(); err != nil {
491 return Sessions{}, err491 return Sessions{}, err
492 }492 }
493 att, err := r.getUint8()493 att, err := r.getUint8()
M internal/ports/wire_test.go +2 -2
@@ -358,8 +358,8 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) {
358358 {
359359 name: "two",
360360 msg: Sessions{Sessions: []SessionInfo{
361- {SessionID: "0", Name: "0", Ephemeral: true, Windows: 1, Attached: false},
362- {SessionID: "work", Name: "proj", Ephemeral: false, Windows: 5, Attached: true},
361+ {SessionID: "0", Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
362+ {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true},
363363 }},
364364 want: []byte{
365365 0x00, 0x02,
@@ -358,8 +358,8 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) {
358 {358 {
359 name: "two",359 name: "two",
360 msg: Sessions{Sessions: []SessionInfo{360 msg: Sessions{Sessions: []SessionInfo{
361- {SessionID: "0", Name: "0", Ephemeral: true, Windows: 1, Attached: false},361+ {SessionID: "0", Name: "0", Ephemeral: true, Tabs: 1, Attached: false},
362- {SessionID: "work", Name: "proj", Ephemeral: false, Windows: 5, Attached: true},362+ {SessionID: "work", Name: "proj", Ephemeral: false, Tabs: 5, Attached: true},
363 }},363 }},
364 want: []byte{364 want: []byte{
365 0x00, 0x02,365 0x00, 0x02,
M internal/usecase/daemon/daemon.go +592 -220
@@ -1,8 +1,8 @@
11 // Package daemon holds vev's server-side session multiplexer use case: the
2-// accept loop, the ephemeral/named session registry, the per-window PTY reader
2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
33 // and VT screen, and the per-client debounced render scheduler.
44 //
5-// Concurrency model (one window per session for the MVP; multi-window is M3):
5+// Concurrency model (one tab per session for the MVP; multi-tab is M3):
66 //
77 // - Serve runs the accept loop. Each accepted connection is handled by its
88 // own goroutine (handleConn): it reads the first frame and routes it to a
@@ -18,10 +18,10 @@
1818 // attached clients with ReasonServerShutdown).
1919 //
2020 // Locking: a session's screen and per-client renderer shadow are both guarded
21-// by window.mu; the attached-client pointer by session.mu; the registry by
21+// by tab.mu; the attached-client pointer by session.mu; the registry by
2222 // Daemon.mu. When more than one is held the order is always
2323 // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// window.mu — the PTY reader only ever takes window.mu, so it never blocks on
24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
2525 // a slow client.
2626 package daemon
2727
@@ -43,6 +43,8 @@ import (
4343 scopy "github.com/bnema/vev/internal/usecase/copy"
4444 "github.com/bnema/vev/internal/usecase/keys"
4545 "github.com/bnema/vev/internal/usecase/mouse"
46+ "github.com/bnema/vev/internal/usecase/picker"
47+ "github.com/bnema/vev/internal/usecase/ui"
4648 "github.com/bnema/vev/pkg/renderer"
4749 "github.com/bnema/vev/pkg/vt"
4850 )
@@ -56,13 +58,15 @@ const (
5658 maxSyncUpdateDuration = 500 * time.Millisecond
5759 )
5860
61+var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "}
62+
5963 // debounceInterval is kept as a test/back-compat alias for the initial delay.
6064 const debounceInterval = minDebounceInterval
6165
6266 // ptyReadBufSize is the PTY reader's read buffer.
6367 const ptyReadBufSize = 32 * 1024
6468
65-// defaultScrollbackRows is the per-window retained history size.
69+// defaultScrollbackRows is the per-tab retained history size.
6670 const defaultScrollbackRows = 10_000
6771
6872 // detachNotifyTimeout bounds the best-effort Detached notification on the
@@ -117,7 +121,7 @@ type Daemon struct {
117121 }
118122
119123 // session is a single multiplexed session. It owns one or more full-screen
120-// windows and at most one attached client.
124+// tabs and at most one attached client.
121125 type session struct {
122126 id domain.SessionID
123127 name string
@@ -126,16 +130,16 @@ type session struct {
126130 ctx context.Context
127131 cancel context.CancelFunc
128132
129- mu sync.Mutex // guards windows, active, and client
130- windows []*window
131- active int
132- client *attachedClient
133+ mu sync.Mutex // guards tabs, active, and client
134+ tabs []*tab
135+ active int
136+ client *attachedClient
133137 }
134138
135-// window is one PTY-backed screen. dirty is a cap-1 channel so bursty output
139+// tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
136140 // applies back-pressure by collapsing (a full buffer means "a render is
137141 // already pending"), never by blocking the reader.
138-type window struct {
142+type tab struct {
139143 pty ports.PTY
140144 mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
141145 screen *vt.Screen
@@ -143,27 +147,36 @@ type window struct {
143147 dirty chan struct{}
144148 flush chan struct{}
145149 syncGen uint64
146- size domain.Size
147- ctx context.Context
148- cancel context.CancelFunc
150+ // previewClient tracks the one client currently previewing this tab in the picker.
151+ // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
152+ previewClient *attachedClient
153+ size domain.Size
154+ ctx context.Context
155+ cancel context.CancelFunc
149156 }
150157
151-// attachedClient is a client currently attached to a session's window. rend is
158+// attachedClient is a client currently attached to a session's tab. rend is
152159 // its private renderer shadow (so each client gets output minimised against
153160 // what it has actually seen). sendMu serialises the two senders — the render
154161 // scheduler and the connection handler — so the transport's single-writer
155162 // contract holds and Draw→Send stays atomic (correct output ordering).
156163 type attachedClient struct {
157- tr ports.Transport
158- rend *renderer.Renderer
159- size domain.Size
160- keys *keys.Router
161- copyMu sync.Mutex
162- copyMode *scopy.Mode
163- copyPending []byte
164- mouseScan mouse.Scanner
165- lastCursor cursorOut
166- sendMu sync.Mutex
164+ tr ports.Transport
165+ rend *renderer.Renderer
166+ size domain.Size
167+ keys *keys.Router
168+ sessMu sync.Mutex
169+ sess *session
170+ copyMu sync.Mutex
171+ copyMode *scopy.Mode
172+ copyPending []byte
173+ pickerMu sync.Mutex
174+ picker *picker.Model
175+ pickerPreview *tab
176+ pickerPending []byte
177+ mouseScan mouse.Scanner
178+ lastCursor cursorOut
179+ sendMu sync.Mutex
167180 }
168181
169182 type cursorOut struct {
@@ -175,12 +188,30 @@ type cursorOut struct {
175188 hasStyle bool
176189 }
177190
191+func (ac *attachedClient) currentSession() *session {
192+ ac.sessMu.Lock()
193+ defer ac.sessMu.Unlock()
194+ return ac.sess
195+}
196+
197+func (ac *attachedClient) setSession(sess *session) {
198+ ac.sessMu.Lock()
199+ defer ac.sessMu.Unlock()
200+ ac.sess = sess
201+}
202+
178203 func (ac *attachedClient) copyModeActive() bool {
179204 ac.copyMu.Lock()
180205 defer ac.copyMu.Unlock()
181206 return ac.copyMode != nil
182207 }
183208
209+func (ac *attachedClient) pickerActive() bool {
210+ ac.pickerMu.Lock()
211+ defer ac.pickerMu.Unlock()
212+ return ac.picker != nil
213+}
214+
184215 // send serialises a frame onto the client's transport.
185216 func (ac *attachedClient) send(f ports.Frame) error {
186217 ac.sendMu.Lock()
@@ -391,7 +422,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
391422 SessionID: string(s.id),
392423 Name: s.name,
393424 Ephemeral: s.ephemeral,
394- Windows: uint16(len(s.windows)),
425+ Tabs: uint16(len(s.tabs)),
395426 Attached: s.client != nil,
396427 }
397428 s.mu.Unlock()
@@ -528,8 +559,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
528559 // createSessionLocked spawns a PTY-backed session and starts its reader and
529560 // scheduler goroutines. Caller holds d.mu.
530561 func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size) (*session, error) {
531- winSize := windowSize(sz)
532- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), winSize)
562+ tbSize := tabSize(sz)
563+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), tbSize)
533564 if err != nil {
534565 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
535566 }
@@ -537,43 +568,43 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
537568 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
538569 d.nextID++
539570
540- win := newWindow(pty, winSize)
571+ tb := newTab(pty, tbSize)
541572 sctx, cancel := context.WithCancel(d.serveCtx)
542- win.ctx, win.cancel = context.WithCancel(sctx)
573+ tb.ctx, tb.cancel = context.WithCancel(sctx)
543574 sess := &session{
544575 id: id,
545576 name: name,
546577 ephemeral: ephemeral,
547578 ctx: sctx,
548579 cancel: cancel,
549- windows: []*window{win},
580+ tabs: []*tab{tb},
550581 }
551582 d.sessions[id] = sess
552- d.startWindowGoroutines(sess, win)
583+ d.startTabGoroutines(sess, tb)
553584 return sess, nil
554585 }
555586
556-func (d *Daemon) createWindow(sess *session, sz domain.Size) error {
557- winSize := windowSize(sz)
558- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), winSize)
587+func (d *Daemon) createTab(sess *session, sz domain.Size) error {
588+ tbSize := tabSize(sz)
589+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), tbSize)
559590 if err != nil {
560- return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err)
591+ return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err)
561592 }
562- win := newWindow(pty, winSize)
563- win.ctx, win.cancel = context.WithCancel(sess.ctx)
593+ tb := newTab(pty, tbSize)
594+ tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
564595 sess.mu.Lock()
565- sess.windows = append(sess.windows, win)
566- sess.active = len(sess.windows) - 1
596+ sess.tabs = append(sess.tabs, tb)
597+ sess.active = len(sess.tabs) - 1
567598 sess.mu.Unlock()
568- d.startWindowGoroutines(sess, win)
599+ d.startTabGoroutines(sess, tb)
569600 return nil
570601 }
571602
572-func newWindow(pty ports.PTY, sz domain.Size) *window {
603+func newTab(pty ports.PTY, sz domain.Size) *tab {
573604 sb := scopy.NewScrollback(defaultScrollbackRows)
574605 screen := vt.NewScreen(sz.Cols, sz.Rows)
575606 screen.OnLineEvicted = sb.Append
576- return &window{
607+ return &tab{
577608 pty: pty,
578609 screen: screen,
579610 scrollback: sb,
@@ -583,7 +614,7 @@ func newWindow(pty ports.PTY, sz domain.Size) *window {
583614 }
584615 }
585616
586-func windowSize(clientSize domain.Size) domain.Size {
617+func tabSize(clientSize domain.Size) domain.Size {
587618 if !clientSize.Valid() {
588619 clientSize = defaultSize
589620 }
@@ -591,10 +622,10 @@ func windowSize(clientSize domain.Size) domain.Size {
591622 return domain.Size{Cols: clientSize.Cols, Rows: rows}
592623 }
593624
594-func (d *Daemon) startWindowGoroutines(sess *session, win *window) {
625+func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
595626 d.sessWg.Add(2)
596- go d.ptyReader(sess, win)
597- go d.scheduler(sess, win)
627+ go d.ptyReader(sess, tb)
628+ go d.scheduler(sess, tb)
598629 }
599630
600631 // attachClient makes ac the session's current client, displacing any prior one
@@ -606,13 +637,16 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
606637 rend: renderer.New(renderer.Capabilities{}),
607638 size: sz,
608639 }
609- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})
640+ ac.setSession(sess)
641+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
610642 sess.mu.Lock()
611643 old := sess.client
612644 sess.client = ac
613645 sess.mu.Unlock()
614646
615647 if old != nil {
648+ d.unregisterPreview(old)
649+ old.setSession(nil)
616650 // Async + bounded: a dead or wedged old client must not stall the new
617651 // client's handshake.
618652 d.notifyDetachedAsync(old, ports.ReasonDetach)
@@ -621,18 +655,18 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
621655 }
622656
623657 // firstPaint guarantees the freshly attached client sees the full screen: if
624-// the window size differs from the client's it resizes first (which paints a
658+// the tab size differs from the client's it resizes first (which paints a
625659 // full redraw), otherwise it forces a full paint against the fresh renderer.
626660 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
627- win := sess.activeWindow()
628- if win == nil {
661+ tb := sess.activeTab()
662+ if tb == nil {
629663 return
630664 }
631- win.mu.Lock()
632- wsz := win.size
633- win.mu.Unlock()
665+ tb.mu.Lock()
666+ wsz := tb.size
667+ tb.mu.Unlock()
634668
635- if clientSize.Valid() && wsz != windowSize(clientSize) {
669+ if clientSize.Valid() && wsz != tabSize(clientSize) {
636670 d.resize(sess, ac, clientSize)
637671 return
638672 }
@@ -643,11 +677,19 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
643677 // until detach, EOF, or a transport error.
644678 func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) {
645679 for {
680+ sess = ac.currentSession()
681+ if sess == nil {
682+ return
683+ }
646684 f, err := ac.tr.Recv()
647685 if err != nil {
648686 d.clientGone(sess, ac, false)
649687 return
650688 }
689+ sess = ac.currentSession()
690+ if sess == nil {
691+ return
692+ }
651693 switch f.Type {
652694 case ports.MsgInput:
653695 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
@@ -676,6 +718,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
676718 if !sess.detachIfCurrent(ac) {
677719 return // already displaced by a newer client; nothing to do
678720 }
721+ d.unregisterPreview(ac)
679722 if explicit {
680723 // Synchronous so the ack is delivered before the transport closes
681724 // (the client is actively awaiting it), but deadline-bounded so a
@@ -696,24 +739,29 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
696739 defer s.mu.Unlock()
697740 if s.client == ac {
698741 s.client = nil
742+ ac.setSession(nil)
699743 return true
700744 }
701745 return false
702746 }
703747
704-func (s *session) activeWindow() *window {
748+func (s *session) activeTab() *tab {
705749 s.mu.Lock()
706750 defer s.mu.Unlock()
707- if s.active < 0 || s.active >= len(s.windows) {
751+ if s.active < 0 || s.active >= len(s.tabs) {
708752 return nil
709753 }
710- return s.windows[s.active]
754+ return s.tabs[s.active]
711755 }
712756
713757 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
714758 ac.mouseScan.Scan(data,
715759 func(ev mouse.Event) { d.handleMouse(sess, ac, ev) },
716760 func(b []byte) {
761+ if ac.pickerActive() {
762+ d.handlePickerInput(sess, ac, b)
763+ return
764+ }
717765 if ac.copyModeActive() {
718766 d.handleCopyInput(sess, ac, b)
719767 return
@@ -724,8 +772,8 @@ func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
724772 }
725773
726774 func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) {
727- win := sess.activeWindow()
728- if win == nil {
775+ tb := sess.activeTab()
776+ if tb == nil {
729777 return
730778 }
731779
@@ -739,53 +787,53 @@ func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event)
739787 return
740788 }
741789
742- win.mu.Lock()
743- childRows := win.screen.Frame.Height
744- mouseMode, mouseSGR := win.screen.MouseMode()
745- altScreen := win.screen.AltScreenActive()
746- win.mu.Unlock()
790+ tb.mu.Lock()
791+ childRows := tb.screen.Frame.Height
792+ mouseMode, mouseSGR := tb.screen.MouseMode()
793+ altScreen := tb.screen.AltScreenActive()
794+ tb.mu.Unlock()
747795
748796 if mouseMode != 0 {
749797 if !mouseSGR || ev.Row >= childRows {
750798 return
751799 }
752- daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward(ev.Raw)
800+ daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw)
753801 return
754802 }
755803
756804 switch ev.Button {
757805 case mouse.WheelUp:
758806 if altScreen {
759- daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
807+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
760808 return
761809 }
762810 d.enterCopyMode(sess, ac)
763811 d.copyWheel(sess, ac, -3)
764812 case mouse.WheelDown:
765813 if altScreen {
766- daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
814+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
767815 }
768816 }
769817 }
770818
771819 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
772- win := sess.activeWindow()
773- if win == nil {
820+ tb := sess.activeTab()
821+ if tb == nil {
774822 return
775823 }
776824
777- win.mu.Lock()
825+ tb.mu.Lock()
778826 ac.copyMu.Lock()
779827 if ac.copyMode == nil {
780828 ac.copyMu.Unlock()
781- win.mu.Unlock()
829+ tb.mu.Unlock()
782830 return
783831 }
784- snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
832+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
785833 if delta > 0 && ac.copyMode.AtBottom(snap) {
786834 ac.copyMode = nil
787835 ac.copyMu.Unlock()
788- win.mu.Unlock()
836+ tb.mu.Unlock()
789837 d.paint(sess, ac, true)
790838 return
791839 }
@@ -795,19 +843,19 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
795843 ac.copyMode = nil
796844 }
797845 ac.copyMu.Unlock()
798- win.mu.Unlock()
846+ tb.mu.Unlock()
799847
800848 d.paint(sess, ac, true)
801849 }
802850
803851 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
804- win := sess.activeWindow()
805- if win == nil {
852+ tb := sess.activeTab()
853+ if tb == nil {
806854 return
807855 }
808- win.mu.Lock()
809- snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
810- win.mu.Unlock()
856+ tb.mu.Lock()
857+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
858+ tb.mu.Unlock()
811859 ac.copyMu.Lock()
812860 ac.copyMode = scopy.NewMode(snap)
813861 ac.copyMu.Unlock()
@@ -815,17 +863,17 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
815863 }
816864
817865 func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) {
818- win := sess.activeWindow()
819- if win == nil {
866+ tb := sess.activeTab()
867+ if tb == nil {
820868 return
821869 }
822870
823- win.mu.Lock()
871+ tb.mu.Lock()
824872 ac.copyMu.Lock()
825873 if ac.copyMode == nil {
826874 ac.copyPending = nil
827875 ac.copyMu.Unlock()
828- win.mu.Unlock()
876+ tb.mu.Unlock()
829877 return
830878 }
831879 if len(ac.copyPending) > 0 {
@@ -835,7 +883,7 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
835883 data = combined
836884 ac.copyPending = nil
837885 }
838- snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
886+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
839887 changed := false
840888 copyOut := false
841889 exit := false
@@ -884,7 +932,7 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
884932 ac.copyMode = nil
885933 }
886934 ac.copyMu.Unlock()
887- win.mu.Unlock()
935+ tb.mu.Unlock()
888936
889937 if copyOut && text != "" {
890938 for _, chunk := range scopy.OSC52(text) {
@@ -932,64 +980,323 @@ func isCopyEscapePrefix(data []byte) bool {
932980 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
933981 }
934982
983+func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
984+ views, curTab := d.pickerViews(sess)
985+ ac.pickerMu.Lock()
986+ ac.picker = picker.New(views, sess.id, curTab)
987+ ac.pickerPending = nil
988+ ac.pickerPreview = nil
989+ ac.pickerMu.Unlock()
990+ d.registerPreviewForSelection(ac)
991+ d.paint(sess, ac, true)
992+}
993+
994+func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
995+ d.mu.Lock()
996+ sessions := make([]*session, 0, len(d.sessions))
997+ for _, s := range d.sessions {
998+ sessions = append(sessions, s)
999+ }
1000+ d.mu.Unlock()
1001+ sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
1002+
1003+ views := make([]picker.SessionView, 0, len(sessions))
1004+ curTab := 0
1005+ for _, s := range sessions {
1006+ s.mu.Lock()
1007+ view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
1008+ for i := range s.tabs {
1009+ view.Tabs[i] = strconv.Itoa(i + 1)
1010+ }
1011+ if s == cur {
1012+ curTab = s.active
1013+ }
1014+ s.mu.Unlock()
1015+ views = append(views, view)
1016+ }
1017+ return views, curTab
1018+}
1019+
1020+func (d *Daemon) handlePickerInput(sess *session, ac *attachedClient, data []byte) {
1021+ ac.pickerMu.Lock()
1022+ if ac.picker == nil {
1023+ ac.pickerPending = nil
1024+ ac.pickerMu.Unlock()
1025+ return
1026+ }
1027+ if len(ac.pickerPending) > 0 {
1028+ combined := make([]byte, 0, len(ac.pickerPending)+len(data))
1029+ combined = append(combined, ac.pickerPending...)
1030+ combined = append(combined, data...)
1031+ data = combined
1032+ ac.pickerPending = nil
1033+ }
1034+ changed := false
1035+ exit := false
1036+ switchTarget := false
1037+ for i := 0; i < len(data); i++ {
1038+ switch data[i] {
1039+ case 'j':
1040+ ac.picker.Down()
1041+ changed = true
1042+ case 'k':
1043+ ac.picker.Up()
1044+ changed = true
1045+ case '\r', '\n':
1046+ switchTarget = true
1047+ exit = true
1048+ case 'q', 0x03, 0x1b:
1049+ if data[i] == 0x1b {
1050+ tail := data[i:]
1051+ consumed, ok := routePickerEscape(ac.picker, tail)
1052+ if ok {
1053+ i += consumed - 1
1054+ changed = true
1055+ continue
1056+ }
1057+ if isPickerEscapePrefix(tail) {
1058+ ac.pickerPending = append(ac.pickerPending[:0], tail...)
1059+ break
1060+ }
1061+ }
1062+ exit = true
1063+ }
1064+ }
1065+ var target picker.Target
1066+ var ok bool
1067+ if switchTarget {
1068+ target, ok = ac.picker.Selected()
1069+ }
1070+ ac.pickerMu.Unlock()
1071+
1072+ if changed {
1073+ d.registerPreviewForSelection(ac)
1074+ }
1075+ if exit {
1076+ d.closePicker(ac)
1077+ }
1078+ if switchTarget && ok {
1079+ d.switchToTarget(sess, ac, target)
1080+ return
1081+ }
1082+ if exit || changed {
1083+ d.paint(sess, ac, true)
1084+ }
1085+}
1086+
1087+func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
1088+ if len(data) >= 3 && data[1] == '[' {
1089+ switch data[2] {
1090+ case 'A':
1091+ m.Up()
1092+ return 3, true
1093+ case 'B':
1094+ m.Down()
1095+ return 3, true
1096+ }
1097+ }
1098+ return 0, false
1099+}
1100+
1101+func isPickerEscapePrefix(data []byte) bool {
1102+ return len(data) == 2 && data[0] == 0x1b && data[1] == '['
1103+}
1104+
1105+func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
1106+ ac.pickerMu.Lock()
1107+ var target picker.Target
1108+ var ok bool
1109+ if ac.picker != nil {
1110+ target, ok = ac.picker.Selected()
1111+ }
1112+ ac.pickerMu.Unlock()
1113+
1114+ var next *tab
1115+ if ok {
1116+ next = d.tabByTarget(target)
1117+ }
1118+
1119+ ac.pickerMu.Lock()
1120+ old := ac.pickerPreview
1121+ if ac.picker == nil {
1122+ next = nil
1123+ }
1124+ ac.pickerPreview = next
1125+ ac.pickerMu.Unlock()
1126+
1127+ if old != nil && old != next {
1128+ old.mu.Lock()
1129+ if old.previewClient == ac {
1130+ old.previewClient = nil
1131+ }
1132+ old.mu.Unlock()
1133+ }
1134+ if next != nil {
1135+ next.mu.Lock()
1136+ next.previewClient = ac
1137+ next.mu.Unlock()
1138+
1139+ ac.pickerMu.Lock()
1140+ keep := ac.pickerPreview == next
1141+ ac.pickerMu.Unlock()
1142+ if !keep {
1143+ next.mu.Lock()
1144+ if next.previewClient == ac {
1145+ next.previewClient = nil
1146+ }
1147+ next.mu.Unlock()
1148+ }
1149+ }
1150+}
1151+
1152+func (d *Daemon) unregisterPreview(ac *attachedClient) {
1153+ ac.pickerMu.Lock()
1154+ old := ac.pickerPreview
1155+ ac.pickerPreview = nil
1156+ ac.pickerMu.Unlock()
1157+
1158+ if old != nil {
1159+ old.mu.Lock()
1160+ if old.previewClient == ac {
1161+ old.previewClient = nil
1162+ }
1163+ old.mu.Unlock()
1164+ }
1165+}
1166+
1167+func (d *Daemon) closePicker(ac *attachedClient) {
1168+ ac.pickerMu.Lock()
1169+ ac.picker = nil
1170+ ac.pickerPending = nil
1171+ ac.pickerMu.Unlock()
1172+ d.unregisterPreview(ac)
1173+}
1174+
1175+func (d *Daemon) tabByTarget(target picker.Target) *tab {
1176+ d.mu.Lock()
1177+ sess := d.sessions[target.Session]
1178+ d.mu.Unlock()
1179+ if sess == nil {
1180+ return nil
1181+ }
1182+ sess.mu.Lock()
1183+ defer sess.mu.Unlock()
1184+ if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
1185+ return nil
1186+ }
1187+ return sess.tabs[target.TabIndex]
1188+}
1189+
1190+func (d *Daemon) sessionByID(id domain.SessionID) *session {
1191+ d.mu.Lock()
1192+ defer d.mu.Unlock()
1193+ return d.sessions[id]
1194+}
1195+
1196+func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
1197+ targetSess := d.sessionByID(target.Session)
1198+ if targetSess == nil {
1199+ d.paint(sess, ac, true)
1200+ return
1201+ }
1202+ if targetSess == sess {
1203+ if sess.switchTab(target.TabIndex) {
1204+ d.paint(sess, ac, true)
1205+ } else {
1206+ d.paint(sess, ac, true)
1207+ }
1208+ return
1209+ }
1210+ if !sess.detachIfCurrent(ac) {
1211+ return
1212+ }
1213+ targetSess.stealClient(d, ac, target.TabIndex)
1214+ ac.setSession(targetSess)
1215+ d.firstPaint(targetSess, ac, ac.size)
1216+}
1217+
1218+func (s *session) stealClient(d *Daemon, ac *attachedClient, idx int) {
1219+ s.mu.Lock()
1220+ old := s.client
1221+ s.client = ac
1222+ if idx >= 0 && idx < len(s.tabs) {
1223+ s.active = idx
1224+ }
1225+ s.mu.Unlock()
1226+ if old != nil && old != ac {
1227+ d.unregisterPreview(old)
1228+ old.setSession(nil)
1229+ d.notifyDetachedAsync(old, ports.ReasonDetach)
1230+ }
1231+}
1232+
9351233 type daemonKeyHandler struct {
936- d *Daemon
937- sess *session
938- ac *attachedClient
1234+ d *Daemon
1235+ ac *attachedClient
9391236 }
9401237
9411238 func (h daemonKeyHandler) Forward(data []byte) {
942- win := h.sess.activeWindow()
943- if win == nil {
1239+ sess := h.ac.currentSession()
1240+ if sess == nil {
9441241 return
9451242 }
946- if _, err := win.pty.Write(data); err != nil {
947- h.d.log.Error("pty write failed", "err", err, "session", h.sess.name)
1243+ tb := sess.activeTab()
1244+ if tb == nil {
1245+ return
1246+ }
1247+ if _, err := tb.pty.Write(data); err != nil {
1248+ h.d.log.Error("pty write failed", "err", err, "session", sess.name)
9481249 }
9491250 }
9501251
9511252 func (h daemonKeyHandler) Action(action keys.Action) {
1253+ sess := h.ac.currentSession()
1254+ if sess == nil {
1255+ return
1256+ }
9521257 switch action {
953- case keys.ActionCreateWindow:
954- if err := h.d.createWindow(h.sess, h.ac.size); err != nil {
955- h.d.log.Error("create window failed", "err", err, "session", h.sess.name)
1258+ case keys.ActionCreateTab:
1259+ if err := h.d.createTab(sess, h.ac.size); err != nil {
1260+ h.d.log.Error("create tab failed", "err", err, "session", sess.name)
9561261 return
9571262 }
958- h.d.paint(h.sess, h.ac, true)
959- case keys.ActionNextWindow:
960- if h.sess.switchRelative(1) {
961- h.d.paint(h.sess, h.ac, true)
1263+ h.d.paint(sess, h.ac, true)
1264+ case keys.ActionNextTab:
1265+ if sess.switchRelative(1) {
1266+ h.d.paint(sess, h.ac, true)
9621267 }
963- case keys.ActionPreviousWindow:
964- if h.sess.switchRelative(-1) {
965- h.d.paint(h.sess, h.ac, true)
1268+ case keys.ActionPreviousTab:
1269+ if sess.switchRelative(-1) {
1270+ h.d.paint(sess, h.ac, true)
9661271 }
9671272 case keys.ActionDetach:
968- h.d.clientGone(h.sess, h.ac, true)
969- case keys.ActionCloseWindow:
970- win := h.sess.activeWindow()
971- if win != nil {
972- h.d.closeWindow(h.sess, win, true)
1273+ h.d.clientGone(sess, h.ac, true)
1274+ case keys.ActionCloseTab:
1275+ tb := sess.activeTab()
1276+ if tb != nil {
1277+ h.d.closeTab(sess, tb, true)
9731278 }
9741279 case keys.ActionCopyMode:
975- h.d.enterCopyMode(h.sess, h.ac)
1280+ h.d.enterCopyMode(sess, h.ac)
9761281 case keys.ActionRenameSession:
977- h.sess.promoteEphemeral()
978- h.d.paint(h.sess, h.ac, true)
979- case keys.ActionSwitchWindow1, keys.ActionSwitchWindow2, keys.ActionSwitchWindow3,
980- keys.ActionSwitchWindow4, keys.ActionSwitchWindow5, keys.ActionSwitchWindow6,
981- keys.ActionSwitchWindow7, keys.ActionSwitchWindow8, keys.ActionSwitchWindow9:
982- idx := int(action - keys.ActionSwitchWindow1)
983- if h.sess.switchWindow(idx) {
984- h.d.paint(h.sess, h.ac, true)
1282+ sess.promoteEphemeral()
1283+ h.d.paint(sess, h.ac, true)
1284+ case keys.ActionOpenPicker:
1285+ h.d.enterPicker(sess, h.ac)
1286+ case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
1287+ keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
1288+ keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
1289+ idx := int(action - keys.ActionSwitchTab1)
1290+ if sess.switchTab(idx) {
1291+ h.d.paint(sess, h.ac, true)
9851292 }
9861293 }
9871294 }
9881295
989-func (s *session) switchWindow(idx int) bool {
1296+func (s *session) switchTab(idx int) bool {
9901297 s.mu.Lock()
9911298 defer s.mu.Unlock()
992- if idx < 0 || idx >= len(s.windows) || idx == s.active {
1299+ if idx < 0 || idx >= len(s.tabs) || idx == s.active {
9931300 return false
9941301 }
9951302 s.active = idx
@@ -999,10 +1306,10 @@ func (s *session) switchWindow(idx int) bool {
9991306 func (s *session) switchRelative(delta int) bool {
10001307 s.mu.Lock()
10011308 defer s.mu.Unlock()
1002- if len(s.windows) < 2 {
1309+ if len(s.tabs) < 2 {
10031310 return false
10041311 }
1005- s.active = (s.active + delta + len(s.windows)) % len(s.windows)
1312+ s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
10061313 return true
10071314 }
10081315
@@ -1014,11 +1321,11 @@ func (s *session) promoteEphemeral() {
10141321 s.ephemeral = false
10151322 }
10161323
1017-func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) {
1324+func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
10181325 sess.mu.Lock()
10191326 idx := -1
1020- for i, w := range sess.windows {
1021- if w == win {
1327+ for i, w := range sess.tabs {
1328+ if w == tb {
10221329 idx = i
10231330 break
10241331 }
@@ -1027,24 +1334,24 @@ func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) {
10271334 sess.mu.Unlock()
10281335 return
10291336 }
1030- if len(sess.windows) == 1 {
1337+ if len(sess.tabs) == 1 {
10311338 sess.mu.Unlock()
10321339 d.killSession(sess, ports.ReasonSessionKilled)
10331340 return
10341341 }
1035- sess.windows = append(sess.windows[:idx], sess.windows[idx+1:]...)
1036- if sess.active >= len(sess.windows) {
1037- sess.active = len(sess.windows) - 1
1342+ sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
1343+ if sess.active >= len(sess.tabs) {
1344+ sess.active = len(sess.tabs) - 1
10381345 } else if idx < sess.active {
10391346 sess.active--
10401347 }
10411348 ac := sess.client
10421349 sess.mu.Unlock()
10431350
1044- if win.cancel != nil {
1045- win.cancel()
1351+ if tb.cancel != nil {
1352+ tb.cancel()
10461353 }
1047- _ = win.pty.Close()
1354+ _ = tb.pty.Close()
10481355 if repaint && ac != nil {
10491356 d.paint(sess, ac, true)
10501357 }
@@ -1060,57 +1367,57 @@ func signal(ch chan struct{}) {
10601367 }
10611368 }
10621369
1063-func (d *Daemon) ptyReader(sess *session, win *window) {
1370+func (d *Daemon) ptyReader(sess *session, tb *tab) {
10641371 defer d.sessWg.Done()
10651372 buf := make([]byte, ptyReadBufSize)
10661373 var resp []byte
1067- win.mu.Lock()
1068- win.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
1069- win.mu.Unlock()
1374+ tb.mu.Lock()
1375+ tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
1376+ tb.mu.Unlock()
10701377 for {
1071- n, err := win.pty.Read(buf)
1378+ n, err := tb.pty.Read(buf)
10721379 if n > 0 {
10731380 data := buf[:n]
1074- win.mu.Lock()
1075- wasSyncing := win.screen.SyncUpdateActive()
1076- win.screen.Write(data)
1077- isSyncing := win.screen.SyncUpdateActive()
1078- win.mu.Unlock()
1381+ tb.mu.Lock()
1382+ wasSyncing := tb.screen.SyncUpdateActive()
1383+ tb.screen.Write(data)
1384+ isSyncing := tb.screen.SyncUpdateActive()
1385+ tb.mu.Unlock()
10791386 if len(resp) > 0 {
1080- if _, writeErr := win.pty.Write(resp); writeErr != nil {
1387+ if _, writeErr := tb.pty.Write(resp); writeErr != nil {
10811388 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
10821389 }
10831390 resp = resp[:0]
10841391 }
10851392 if wasSyncing != isSyncing {
1086- win.mu.Lock()
1087- win.syncGen++
1088- gen := win.syncGen
1089- win.mu.Unlock()
1393+ tb.mu.Lock()
1394+ tb.syncGen++
1395+ gen := tb.syncGen
1396+ tb.mu.Unlock()
10901397 if isSyncing {
1091- go d.syncWatchdog(win, gen)
1398+ go d.syncWatchdog(tb, gen)
10921399 }
10931400 }
10941401 if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
1095- signal(win.flush)
1402+ signal(tb.flush)
10961403 continue
10971404 }
10981405 if isSyncing {
10991406 continue
11001407 }
1101- signal(win.dirty)
1408+ signal(tb.dirty)
11021409 }
11031410 if err != nil {
1104- d.closeWindow(sess, win, true)
1411+ d.closeTab(sess, tb, true)
11051412 return
11061413 }
11071414 }
11081415 }
11091416
1110-// scheduler debounces dirty signals. The first dirty opens a short window;
1111-// sustained floods progressively widen that window, while isolated updates
1417+// scheduler debounces dirty signals. The first dirty opens a short tab;
1418+// sustained floods progressively widen that tab, while isolated updates
11121419 // return to the minimum delay for interactive latency.
1113-func (d *Daemon) scheduler(sess *session, win *window) {
1420+func (d *Daemon) scheduler(sess *session, tb *tab) {
11141421 defer d.sessWg.Done()
11151422 delay := minDebounceInterval
11161423 lastRender := d.clock.Now()
@@ -1119,13 +1426,13 @@ outer:
11191426 select {
11201427 case <-sess.ctx.Done():
11211428 return
1122- case <-windowDone(win):
1429+ case <-tabDone(tb):
11231430 return
1124- case <-win.flush:
1125- d.render(sess, win)
1431+ case <-tb.flush:
1432+ d.render(sess, tb)
11261433 lastRender = d.clock.Now()
11271434 continue
1128- case <-win.dirty:
1435+ case <-tb.dirty:
11291436 if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {
11301437 delay = minDebounceInterval
11311438 }
@@ -1139,27 +1446,27 @@ outer:
11391446 case <-sess.ctx.Done():
11401447 timer.Stop()
11411448 return
1142- case <-windowDone(win):
1449+ case <-tabDone(tb):
11431450 timer.Stop()
11441451 return
1145- case <-win.flush:
1452+ case <-tb.flush:
11461453 if !timer.Stop() {
11471454 select {
11481455 case <-timer.C():
11491456 default:
11501457 }
11511458 }
1152- d.render(sess, win)
1459+ d.render(sess, tb)
11531460 lastRender = d.clock.Now()
11541461 continue outer
1155- case <-win.dirty:
1462+ case <-tb.dirty:
11561463 coalesced++
11571464 case <-timer.C():
11581465 break absorb
11591466 }
11601467 }
11611468 delay = nextDebounceDelay(delay, coalesced)
1162- d.render(sess, win)
1469+ d.render(sess, tb)
11631470 lastRender = d.clock.Now()
11641471 }
11651472 }
@@ -1178,30 +1485,30 @@ func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
11781485 return delay
11791486 }
11801487
1181-func (d *Daemon) syncWatchdog(win *window, gen uint64) {
1488+func (d *Daemon) syncWatchdog(tb *tab, gen uint64) {
11821489 timer := d.clock.NewTimer(maxSyncUpdateDuration)
11831490 select {
1184- case <-windowDone(win):
1491+ case <-tabDone(tb):
11851492 timer.Stop()
11861493 return
11871494 case <-timer.C():
11881495 }
11891496
1190- win.mu.Lock()
1191- if win.syncGen != gen || !win.screen.SyncUpdateActive() {
1192- win.mu.Unlock()
1497+ tb.mu.Lock()
1498+ if tb.syncGen != gen || !tb.screen.SyncUpdateActive() {
1499+ tb.mu.Unlock()
11931500 return
11941501 }
1195- win.screen.ForceSyncEnd()
1196- win.mu.Unlock()
1197- signal(win.flush)
1502+ tb.screen.ForceSyncEnd()
1503+ tb.mu.Unlock()
1504+ signal(tb.flush)
11981505 }
11991506
1200-func windowDone(win *window) <-chan struct{} {
1201- if win.ctx == nil {
1507+func tabDone(tb *tab) <-chan struct{} {
1508+ if tb.ctx == nil {
12021509 return nil
12031510 }
1204- return win.ctx.Done()
1511+ return tb.ctx.Done()
12051512 }
12061513
12071514 func syncUpdateEndIn(data []byte) bool {
@@ -1210,60 +1517,92 @@ func syncUpdateEndIn(data []byte) bool {
12101517
12111518 // render paints the current client, or (when detached) just clears accumulated
12121519 // damage so it never grows unbounded while headless.
1213-func (d *Daemon) render(sess *session, win *window) {
1214- win.mu.Lock()
1215- if win.screen.SyncUpdateActive() {
1216- win.mu.Unlock()
1520+func (d *Daemon) render(sess *session, tb *tab) {
1521+ tb.mu.Lock()
1522+ if tb.screen.SyncUpdateActive() {
1523+ tb.mu.Unlock()
12171524 return
12181525 }
1219- win.mu.Unlock()
1526+ tb.mu.Unlock()
1527+
1528+ tb.mu.Lock()
1529+ previewer := tb.previewClient
1530+ tb.mu.Unlock()
12201531
12211532 sess.mu.Lock()
12221533 ac := sess.client
1223- active := sess.active >= 0 && sess.active < len(sess.windows) && sess.windows[sess.active] == win
1534+ active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
12241535 sess.mu.Unlock()
12251536
1537+ if previewer != nil {
1538+ if previewSess := previewer.currentSession(); previewSess != nil {
1539+ d.paint(previewSess, previewer, false)
1540+ }
1541+ if ac != nil && active && ac != previewer {
1542+ d.paint(sess, ac, false)
1543+ return
1544+ }
1545+ tb.mu.Lock()
1546+ tb.screen.ClearDamage()
1547+ tb.mu.Unlock()
1548+ return
1549+ }
1550+
12261551 if ac == nil || !active {
1227- win.mu.Lock()
1228- win.screen.ClearDamage()
1229- win.mu.Unlock()
1552+ tb.mu.Lock()
1553+ tb.screen.ClearDamage()
1554+ tb.mu.Unlock()
12301555 return
12311556 }
12321557 d.paint(sess, ac, false)
12331558 }
12341559
1235-// paint draws the composed client frame (active window plus status bar) and
1560+// paint draws the composed client frame (active tab plus status bar) and
12361561 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
12371562 // such as switch/create/close/rename/resize so the repaint is complete.
12381563 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1239- win := sess.activeWindow()
1240- if win == nil {
1564+ tb := sess.activeTab()
1565+ if tb == nil {
12411566 return
12421567 }
12431568
12441569 ac.sendMu.Lock()
1245- win.mu.Lock()
12461570 ac.copyMu.Lock()
12471571 copyActive := ac.copyMode != nil
1248- if reset || copyActive {
1572+ copyMode := ac.copyMode
1573+ ac.copyMu.Unlock()
1574+ ac.pickerMu.Lock()
1575+ pickerActive := ac.picker != nil
1576+ pickerModel := ac.picker
1577+ previewTab := ac.pickerPreview
1578+ preview := snapshotPickerPreview(previewTab)
1579+
1580+ tb.mu.Lock()
1581+ if reset || copyActive || pickerActive {
12491582 ac.rend.Reset()
12501583 }
1251- if reset {
1584+ if reset || pickerActive {
12521585 ac.lastCursor.valid = false
12531586 }
1254- frame, damage := composeClientFrame(sess, win, reset)
1587+ frame, damage := composeClientFrame(sess, tb, reset)
12551588 if copyActive {
1256- frame, damage = composeCopyClientFrame(ac.copyMode, win)
1589+ frame, damage = composeCopyClientFrame(copyMode, tb)
12571590 }
1258- desiredCursor := desiredCursorOut(win.screen, copyActive)
1259- ac.copyMu.Unlock()
1591+ if pickerActive {
1592+ if previewTab == tb {
1593+ preview = pickerPreviewFromLockedTab(tb)
1594+ }
1595+ frame, damage = composePickerClientFrame(pickerModel, preview, frame)
1596+ }
1597+ ac.pickerMu.Unlock()
1598+ desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive)
12601599 data, err := ac.rend.Draw(frame, damage)
12611600 var cursorTail []byte
12621601 if err == nil {
12631602 cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
12641603 }
1265- win.screen.ClearDamage()
1266- win.mu.Unlock()
1604+ tb.screen.ClearDamage()
1605+ tb.mu.Unlock()
12671606
12681607 var serr error
12691608 if err == nil {
@@ -1283,7 +1622,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
12831622 }
12841623 }
12851624
1286-// resize applies a client size change to every window using rows-1 for the PTY
1625+// resize applies a client size change to every tab using rows-1 for the PTY
12871626 // and VT screen, then resets the renderer shadow and repaints the composed
12881627 // client-sized frame (including the status row).
12891628 func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
@@ -1323,27 +1662,55 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte
13231662 return b
13241663 }
13251664
1326-func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) {
1327- width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height
1665+func composeClientFrame(sess *session, tb *tab, full bool) (renderer.Frame, []renderer.Damage) {
1666+ width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
13281667 frame := renderer.NewFrame(width, screenRows+1)
13291668 for y := range screenRows {
1330- copy(frame.Row(y), win.screen.Frame.Row(y))
1669+ copy(frame.Row(y), tb.screen.Frame.Row(y))
13311670 }
13321671 drawStatus(frame.Row(screenRows), sess)
13331672 if full {
13341673 return frame, []renderer.Damage{renderer.FullRedraw()}
13351674 }
1336- damage := append([]renderer.Damage(nil), win.screen.Damage()...)
1675+ damage := append([]renderer.Damage(nil), tb.screen.Damage()...)
13371676 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1})
13381677 return frame, damage
13391678 }
13401679
1341-func composeCopyClientFrame(mode *scopy.Mode, win *window) (renderer.Frame, []renderer.Damage) {
1342- snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
1680+func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) {
1681+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
13431682 frame := mode.Render(snap)
13441683 return frame, []renderer.Damage{renderer.FullRedraw()}
13451684 }
13461685
1686+func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
1687+ inner := pickerModal.Composite(base, renderer.DefaultStyle())
1688+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview)
1689+ for y := range min(inner.Height, modalFrame.Height) {
1690+ for x := range min(inner.Width, modalFrame.Width) {
1691+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
1692+ }
1693+ }
1694+ return base, []renderer.Damage{renderer.FullRedraw()}
1695+}
1696+
1697+func snapshotPickerPreview(tb *tab) picker.Preview {
1698+ if tb == nil {
1699+ return picker.Preview{}
1700+ }
1701+ tb.mu.Lock()
1702+ defer tb.mu.Unlock()
1703+ return pickerPreviewFromLockedTab(tb)
1704+}
1705+
1706+func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
1707+ rows := make([][]renderer.Cell, tb.screen.Frame.Height)
1708+ for y := range rows {
1709+ rows[y] = append([]renderer.Cell(nil), tb.screen.Frame.Row(y)...)
1710+ }
1711+ return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}
1712+}
1713+
13471714 func drawStatus(row []renderer.Cell, sess *session) {
13481715 for i := range row {
13491716 row[i] = renderer.BlankCell()
@@ -1351,7 +1718,7 @@ func drawStatus(row []renderer.Cell, sess *session) {
13511718 status := sess.statusSegments()
13521719 x := 0
13531720 writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle())
1354- for _, w := range status.windows {
1721+ for _, w := range status.tabs {
13551722 style := renderer.DefaultStyle()
13561723 if w.active {
13571724 style.Inverse = true
@@ -1362,10 +1729,10 @@ func drawStatus(row []renderer.Cell, sess *session) {
13621729
13631730 type statusSnapshot struct {
13641731 session string
1365- windows []statusWindow
1732+ tabs []statusTab
13661733 }
13671734
1368-type statusWindow struct {
1735+type statusTab struct {
13691736 name string
13701737 active bool
13711738 }
@@ -1373,10 +1740,10 @@ type statusWindow struct {
13731740 func (s *session) statusSegments() statusSnapshot {
13741741 s.mu.Lock()
13751742 defer s.mu.Unlock()
1376- snap := statusSnapshot{session: s.name, windows: make([]statusWindow, len(s.windows))}
1377- for i := range s.windows {
1743+ snap := statusSnapshot{session: s.name, tabs: make([]statusTab, len(s.tabs))}
1744+ for i := range s.tabs {
13781745 name := strconv.Itoa(i + 1)
1379- snap.windows[i] = statusWindow{name: name, active: i == s.active}
1746+ snap.tabs[i] = statusTab{name: name, active: i == s.active}
13801747 }
13811748 return snap
13821749 }
@@ -1395,24 +1762,24 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
13951762 if !sz.Valid() {
13961763 return
13971764 }
1398- winSize := windowSize(sz)
1765+ tbSize := tabSize(sz)
13991766 ac.size = sz
14001767
14011768 sess.mu.Lock()
1402- windows := append([]*window(nil), sess.windows...)
1769+ tabs := append([]*tab(nil), sess.tabs...)
14031770 sess.mu.Unlock()
1404- if len(windows) == 0 {
1771+ if len(tabs) == 0 {
14051772 return
14061773 }
14071774
1408- for _, win := range windows {
1409- if err := win.pty.Resize(winSize); err != nil {
1775+ for _, tb := range tabs {
1776+ if err := tb.pty.Resize(tbSize); err != nil {
14101777 d.log.Warn("pty resize failed", "err", err, "session", sess.name)
14111778 }
1412- win.mu.Lock()
1413- win.screen.Resize(winSize.Cols, winSize.Rows)
1414- win.size = winSize
1415- win.mu.Unlock()
1779+ tb.mu.Lock()
1780+ tb.screen.Resize(tbSize.Cols, tbSize.Rows)
1781+ tb.size = tbSize
1782+ tb.mu.Unlock()
14161783 }
14171784 d.paint(sess, ac, true)
14181785 }
@@ -1422,6 +1789,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
14221789 // its client's); a named session keeps running headless.
14231790 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
14241791 if sess.detachIfCurrent(ac) {
1792+ d.unregisterPreview(ac)
14251793 _ = ac.tr.Close()
14261794 d.log.Warn("detached client after send error", "session", sess.name)
14271795 if sess.ephemeral {
@@ -1458,13 +1826,17 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
14581826 ac := sess.client
14591827 sess.client = nil
14601828 sess.mu.Unlock()
1829+ if ac != nil {
1830+ d.unregisterPreview(ac)
1831+ ac.setSession(nil)
1832+ }
14611833
14621834 sess.cancel()
14631835 sess.mu.Lock()
1464- windows := append([]*window(nil), sess.windows...)
1836+ tabs := append([]*tab(nil), sess.tabs...)
14651837 sess.mu.Unlock()
1466- for _, win := range windows {
1467- _ = win.pty.Close()
1838+ for _, tb := range tabs {
1839+ _ = tb.pty.Close()
14681840 }
14691841 if empty {
14701842 d.doneOnce.Do(func() { close(d.done) })
@@ -1,8 +1,8 @@
1 // Package daemon holds vev's server-side session multiplexer use case: the1 // Package daemon holds vev's server-side session multiplexer use case: the
2-// accept loop, the ephemeral/named session registry, the per-window PTY reader2+// accept loop, the ephemeral/named session registry, the per-tab PTY reader
3 // and VT screen, and the per-client debounced render scheduler.3 // and VT screen, and the per-client debounced render scheduler.
4 //4 //
5-// Concurrency model (one window per session for the MVP; multi-window is M3):5+// Concurrency model (one tab per session for the MVP; multi-tab is M3):
6 //6 //
7 // - Serve runs the accept loop. Each accepted connection is handled by its7 // - Serve runs the accept loop. Each accepted connection is handled by its
8 // own goroutine (handleConn): it reads the first frame and routes it to a8 // own goroutine (handleConn): it reads the first frame and routes it to a
@@ -18,10 +18,10 @@
18 // attached clients with ReasonServerShutdown).18 // attached clients with ReasonServerShutdown).
19 //19 //
20 // Locking: a session's screen and per-client renderer shadow are both guarded20 // Locking: a session's screen and per-client renderer shadow are both guarded
21-// by window.mu; the attached-client pointer by session.mu; the registry by21+// by tab.mu; the attached-client pointer by session.mu; the registry by
22 // Daemon.mu. When more than one is held the order is always22 // Daemon.mu. When more than one is held the order is always
23 // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >23 // Daemon.mu > session.mu, and (for the transport) attachedClient.sendMu >
24-// window.mu — the PTY reader only ever takes window.mu, so it never blocks on24+// tab.mu — the PTY reader only ever takes tab.mu, so it never blocks on
25 // a slow client.25 // a slow client.
26 package daemon26 package daemon
27 27
@@ -43,6 +43,8 @@ import (
43 scopy "github.com/bnema/vev/internal/usecase/copy"43 scopy "github.com/bnema/vev/internal/usecase/copy"
44 "github.com/bnema/vev/internal/usecase/keys"44 "github.com/bnema/vev/internal/usecase/keys"
45 "github.com/bnema/vev/internal/usecase/mouse"45 "github.com/bnema/vev/internal/usecase/mouse"
46+ "github.com/bnema/vev/internal/usecase/picker"
47+ "github.com/bnema/vev/internal/usecase/ui"
46 "github.com/bnema/vev/pkg/renderer"48 "github.com/bnema/vev/pkg/renderer"
47 "github.com/bnema/vev/pkg/vt"49 "github.com/bnema/vev/pkg/vt"
48 )50 )
@@ -56,13 +58,15 @@ const (
56 maxSyncUpdateDuration = 500 * time.Millisecond58 maxSyncUpdateDuration = 500 * time.Millisecond
57 )59 )
58 60
61+var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions "}
62+
59 // debounceInterval is kept as a test/back-compat alias for the initial delay.63 // debounceInterval is kept as a test/back-compat alias for the initial delay.
60 const debounceInterval = minDebounceInterval64 const debounceInterval = minDebounceInterval
61 65
62 // ptyReadBufSize is the PTY reader's read buffer.66 // ptyReadBufSize is the PTY reader's read buffer.
63 const ptyReadBufSize = 32 * 102467 const ptyReadBufSize = 32 * 1024
64 68
65-// defaultScrollbackRows is the per-window retained history size.69+// defaultScrollbackRows is the per-tab retained history size.
66 const defaultScrollbackRows = 10_00070 const defaultScrollbackRows = 10_000
67 71
68 // detachNotifyTimeout bounds the best-effort Detached notification on the72 // detachNotifyTimeout bounds the best-effort Detached notification on the
@@ -117,7 +121,7 @@ type Daemon struct {
117 }121 }
118 122
119 // session is a single multiplexed session. It owns one or more full-screen123 // session is a single multiplexed session. It owns one or more full-screen
120-// windows and at most one attached client.124+// tabs and at most one attached client.
121 type session struct {125 type session struct {
122 id domain.SessionID126 id domain.SessionID
123 name string127 name string
@@ -126,16 +130,16 @@ type session struct {
126 ctx context.Context130 ctx context.Context
127 cancel context.CancelFunc131 cancel context.CancelFunc
128 132
129- mu sync.Mutex // guards windows, active, and client133+ mu sync.Mutex // guards tabs, active, and client
130- windows []*window134+ tabs []*tab
131- active int135+ active int
132- client *attachedClient136+ client *attachedClient
133 }137 }
134 138
135-// window is one PTY-backed screen. dirty is a cap-1 channel so bursty output139+// tab is one PTY-backed screen. dirty is a cap-1 channel so bursty output
136 // applies back-pressure by collapsing (a full buffer means "a render is140 // applies back-pressure by collapsing (a full buffer means "a render is
137 // already pending"), never by blocking the reader.141 // already pending"), never by blocking the reader.
138-type window struct {142+type tab struct {
139 pty ports.PTY143 pty ports.PTY
140 mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow144 mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
141 screen *vt.Screen145 screen *vt.Screen
@@ -143,27 +147,36 @@ type window struct {
143 dirty chan struct{}147 dirty chan struct{}
144 flush chan struct{}148 flush chan struct{}
145 syncGen uint64149 syncGen uint64
146- size domain.Size150+ // previewClient tracks the one client currently previewing this tab in the picker.
147- ctx context.Context151+ // v1 is last-writer-wins: multiple clients previewing the same tab are not supported.
148- cancel context.CancelFunc152+ previewClient *attachedClient
153+ size domain.Size
154+ ctx context.Context
155+ cancel context.CancelFunc
149 }156 }
150 157
151-// attachedClient is a client currently attached to a session's window. rend is158+// attachedClient is a client currently attached to a session's tab. rend is
152 // its private renderer shadow (so each client gets output minimised against159 // its private renderer shadow (so each client gets output minimised against
153 // what it has actually seen). sendMu serialises the two senders — the render160 // what it has actually seen). sendMu serialises the two senders — the render
154 // scheduler and the connection handler — so the transport's single-writer161 // scheduler and the connection handler — so the transport's single-writer
155 // contract holds and Draw→Send stays atomic (correct output ordering).162 // contract holds and Draw→Send stays atomic (correct output ordering).
156 type attachedClient struct {163 type attachedClient struct {
157- tr ports.Transport164+ tr ports.Transport
158- rend *renderer.Renderer165+ rend *renderer.Renderer
159- size domain.Size166+ size domain.Size
160- keys *keys.Router167+ keys *keys.Router
161- copyMu sync.Mutex168+ sessMu sync.Mutex
162- copyMode *scopy.Mode169+ sess *session
163- copyPending []byte170+ copyMu sync.Mutex
164- mouseScan mouse.Scanner171+ copyMode *scopy.Mode
165- lastCursor cursorOut172+ copyPending []byte
166- sendMu sync.Mutex173+ pickerMu sync.Mutex
174+ picker *picker.Model
175+ pickerPreview *tab
176+ pickerPending []byte
177+ mouseScan mouse.Scanner
178+ lastCursor cursorOut
179+ sendMu sync.Mutex
167 }180 }
168 181
169 type cursorOut struct {182 type cursorOut struct {
@@ -175,12 +188,30 @@ type cursorOut struct {
175 hasStyle bool188 hasStyle bool
176 }189 }
177 190
191+func (ac *attachedClient) currentSession() *session {
192+ ac.sessMu.Lock()
193+ defer ac.sessMu.Unlock()
194+ return ac.sess
195+}
196+
197+func (ac *attachedClient) setSession(sess *session) {
198+ ac.sessMu.Lock()
199+ defer ac.sessMu.Unlock()
200+ ac.sess = sess
201+}
202+
178 func (ac *attachedClient) copyModeActive() bool {203 func (ac *attachedClient) copyModeActive() bool {
179 ac.copyMu.Lock()204 ac.copyMu.Lock()
180 defer ac.copyMu.Unlock()205 defer ac.copyMu.Unlock()
181 return ac.copyMode != nil206 return ac.copyMode != nil
182 }207 }
183 208
209+func (ac *attachedClient) pickerActive() bool {
210+ ac.pickerMu.Lock()
211+ defer ac.pickerMu.Unlock()
212+ return ac.picker != nil
213+}
214+
184 // send serialises a frame onto the client's transport.215 // send serialises a frame onto the client's transport.
185 func (ac *attachedClient) send(f ports.Frame) error {216 func (ac *attachedClient) send(f ports.Frame) error {
186 ac.sendMu.Lock()217 ac.sendMu.Lock()
@@ -391,7 +422,7 @@ func (d *Daemon) handleList(tr ports.Transport) {
391 SessionID: string(s.id),422 SessionID: string(s.id),
392 Name: s.name,423 Name: s.name,
393 Ephemeral: s.ephemeral,424 Ephemeral: s.ephemeral,
394- Windows: uint16(len(s.windows)),425+ Tabs: uint16(len(s.tabs)),
395 Attached: s.client != nil,426 Attached: s.client != nil,
396 }427 }
397 s.mu.Unlock()428 s.mu.Unlock()
@@ -528,8 +559,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
528 // createSessionLocked spawns a PTY-backed session and starts its reader and559 // createSessionLocked spawns a PTY-backed session and starts its reader and
529 // scheduler goroutines. Caller holds d.mu.560 // scheduler goroutines. Caller holds d.mu.
530 func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size) (*session, error) {561 func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size) (*session, error) {
531- winSize := windowSize(sz)562+ tbSize := tabSize(sz)
532- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), winSize)563+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), tbSize)
533 if err != nil {564 if err != nil {
534 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)565 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
535 }566 }
@@ -537,43 +568,43 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
537 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))568 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
538 d.nextID++569 d.nextID++
539 570
540- win := newWindow(pty, winSize)571+ tb := newTab(pty, tbSize)
541 sctx, cancel := context.WithCancel(d.serveCtx)572 sctx, cancel := context.WithCancel(d.serveCtx)
542- win.ctx, win.cancel = context.WithCancel(sctx)573+ tb.ctx, tb.cancel = context.WithCancel(sctx)
543 sess := &session{574 sess := &session{
544 id: id,575 id: id,
545 name: name,576 name: name,
546 ephemeral: ephemeral,577 ephemeral: ephemeral,
547 ctx: sctx,578 ctx: sctx,
548 cancel: cancel,579 cancel: cancel,
549- windows: []*window{win},580+ tabs: []*tab{tb},
550 }581 }
551 d.sessions[id] = sess582 d.sessions[id] = sess
552- d.startWindowGoroutines(sess, win)583+ d.startTabGoroutines(sess, tb)
553 return sess, nil584 return sess, nil
554 }585 }
555 586
556-func (d *Daemon) createWindow(sess *session, sz domain.Size) error {587+func (d *Daemon) createTab(sess *session, sz domain.Size) error {
557- winSize := windowSize(sz)588+ tbSize := tabSize(sz)
558- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), winSize)589+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), tbSize)
559 if err != nil {590 if err != nil {
560- return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err)591+ return fmt.Errorf("daemon: spawning tab for session %q: %w", sess.name, err)
561 }592 }
562- win := newWindow(pty, winSize)593+ tb := newTab(pty, tbSize)
563- win.ctx, win.cancel = context.WithCancel(sess.ctx)594+ tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
564 sess.mu.Lock()595 sess.mu.Lock()
565- sess.windows = append(sess.windows, win)596+ sess.tabs = append(sess.tabs, tb)
566- sess.active = len(sess.windows) - 1597+ sess.active = len(sess.tabs) - 1
567 sess.mu.Unlock()598 sess.mu.Unlock()
568- d.startWindowGoroutines(sess, win)599+ d.startTabGoroutines(sess, tb)
569 return nil600 return nil
570 }601 }
571 602
572-func newWindow(pty ports.PTY, sz domain.Size) *window {603+func newTab(pty ports.PTY, sz domain.Size) *tab {
573 sb := scopy.NewScrollback(defaultScrollbackRows)604 sb := scopy.NewScrollback(defaultScrollbackRows)
574 screen := vt.NewScreen(sz.Cols, sz.Rows)605 screen := vt.NewScreen(sz.Cols, sz.Rows)
575 screen.OnLineEvicted = sb.Append606 screen.OnLineEvicted = sb.Append
576- return &window{607+ return &tab{
577 pty: pty,608 pty: pty,
578 screen: screen,609 screen: screen,
579 scrollback: sb,610 scrollback: sb,
@@ -583,7 +614,7 @@ func newWindow(pty ports.PTY, sz domain.Size) *window {
583 }614 }
584 }615 }
585 616
586-func windowSize(clientSize domain.Size) domain.Size {617+func tabSize(clientSize domain.Size) domain.Size {
587 if !clientSize.Valid() {618 if !clientSize.Valid() {
588 clientSize = defaultSize619 clientSize = defaultSize
589 }620 }
@@ -591,10 +622,10 @@ func windowSize(clientSize domain.Size) domain.Size {
591 return domain.Size{Cols: clientSize.Cols, Rows: rows}622 return domain.Size{Cols: clientSize.Cols, Rows: rows}
592 }623 }
593 624
594-func (d *Daemon) startWindowGoroutines(sess *session, win *window) {625+func (d *Daemon) startTabGoroutines(sess *session, tb *tab) {
595 d.sessWg.Add(2)626 d.sessWg.Add(2)
596- go d.ptyReader(sess, win)627+ go d.ptyReader(sess, tb)
597- go d.scheduler(sess, win)628+ go d.scheduler(sess, tb)
598 }629 }
599 630
600 // attachClient makes ac the session's current client, displacing any prior one631 // attachClient makes ac the session's current client, displacing any prior one
@@ -606,13 +637,16 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
606 rend: renderer.New(renderer.Capabilities{}),637 rend: renderer.New(renderer.Capabilities{}),
607 size: sz,638 size: sz,
608 }639 }
609- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})640+ ac.setSession(sess)
641+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
610 sess.mu.Lock()642 sess.mu.Lock()
611 old := sess.client643 old := sess.client
612 sess.client = ac644 sess.client = ac
613 sess.mu.Unlock()645 sess.mu.Unlock()
614 646
615 if old != nil {647 if old != nil {
648+ d.unregisterPreview(old)
649+ old.setSession(nil)
616 // Async + bounded: a dead or wedged old client must not stall the new650 // Async + bounded: a dead or wedged old client must not stall the new
617 // client's handshake.651 // client's handshake.
618 d.notifyDetachedAsync(old, ports.ReasonDetach)652 d.notifyDetachedAsync(old, ports.ReasonDetach)
@@ -621,18 +655,18 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size)
621 }655 }
622 656
623 // firstPaint guarantees the freshly attached client sees the full screen: if657 // firstPaint guarantees the freshly attached client sees the full screen: if
624-// the window size differs from the client's it resizes first (which paints a658+// the tab size differs from the client's it resizes first (which paints a
625 // full redraw), otherwise it forces a full paint against the fresh renderer.659 // full redraw), otherwise it forces a full paint against the fresh renderer.
626 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {660 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
627- win := sess.activeWindow()661+ tb := sess.activeTab()
628- if win == nil {662+ if tb == nil {
629 return663 return
630 }664 }
631- win.mu.Lock()665+ tb.mu.Lock()
632- wsz := win.size666+ wsz := tb.size
633- win.mu.Unlock()667+ tb.mu.Unlock()
634 668
635- if clientSize.Valid() && wsz != windowSize(clientSize) {669+ if clientSize.Valid() && wsz != tabSize(clientSize) {
636 d.resize(sess, ac, clientSize)670 d.resize(sess, ac, clientSize)
637 return671 return
638 }672 }
@@ -643,11 +677,19 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
643 // until detach, EOF, or a transport error.677 // until detach, EOF, or a transport error.
644 func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) {678 func (d *Daemon) runConnLoop(sess *session, ac *attachedClient) {
645 for {679 for {
680+ sess = ac.currentSession()
681+ if sess == nil {
682+ return
683+ }
646 f, err := ac.tr.Recv()684 f, err := ac.tr.Recv()
647 if err != nil {685 if err != nil {
648 d.clientGone(sess, ac, false)686 d.clientGone(sess, ac, false)
649 return687 return
650 }688 }
689+ sess = ac.currentSession()
690+ if sess == nil {
691+ return
692+ }
651 switch f.Type {693 switch f.Type {
652 case ports.MsgInput:694 case ports.MsgInput:
653 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {695 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
@@ -676,6 +718,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
676 if !sess.detachIfCurrent(ac) {718 if !sess.detachIfCurrent(ac) {
677 return // already displaced by a newer client; nothing to do719 return // already displaced by a newer client; nothing to do
678 }720 }
721+ d.unregisterPreview(ac)
679 if explicit {722 if explicit {
680 // Synchronous so the ack is delivered before the transport closes723 // Synchronous so the ack is delivered before the transport closes
681 // (the client is actively awaiting it), but deadline-bounded so a724 // (the client is actively awaiting it), but deadline-bounded so a
@@ -696,24 +739,29 @@ func (s *session) detachIfCurrent(ac *attachedClient) bool {
696 defer s.mu.Unlock()739 defer s.mu.Unlock()
697 if s.client == ac {740 if s.client == ac {
698 s.client = nil741 s.client = nil
742+ ac.setSession(nil)
699 return true743 return true
700 }744 }
701 return false745 return false
702 }746 }
703 747
704-func (s *session) activeWindow() *window {748+func (s *session) activeTab() *tab {
705 s.mu.Lock()749 s.mu.Lock()
706 defer s.mu.Unlock()750 defer s.mu.Unlock()
707- if s.active < 0 || s.active >= len(s.windows) {751+ if s.active < 0 || s.active >= len(s.tabs) {
708 return nil752 return nil
709 }753 }
710- return s.windows[s.active]754+ return s.tabs[s.active]
711 }755 }
712 756
713 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {757 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
714 ac.mouseScan.Scan(data,758 ac.mouseScan.Scan(data,
715 func(ev mouse.Event) { d.handleMouse(sess, ac, ev) },759 func(ev mouse.Event) { d.handleMouse(sess, ac, ev) },
716 func(b []byte) {760 func(b []byte) {
761+ if ac.pickerActive() {
762+ d.handlePickerInput(sess, ac, b)
763+ return
764+ }
717 if ac.copyModeActive() {765 if ac.copyModeActive() {
718 d.handleCopyInput(sess, ac, b)766 d.handleCopyInput(sess, ac, b)
719 return767 return
@@ -724,8 +772,8 @@ func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
724 }772 }
725 773
726 func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) {774 func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) {
727- win := sess.activeWindow()775+ tb := sess.activeTab()
728- if win == nil {776+ if tb == nil {
729 return777 return
730 }778 }
731 779
@@ -739,53 +787,53 @@ func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event)
739 return787 return
740 }788 }
741 789
742- win.mu.Lock()790+ tb.mu.Lock()
743- childRows := win.screen.Frame.Height791+ childRows := tb.screen.Frame.Height
744- mouseMode, mouseSGR := win.screen.MouseMode()792+ mouseMode, mouseSGR := tb.screen.MouseMode()
745- altScreen := win.screen.AltScreenActive()793+ altScreen := tb.screen.AltScreenActive()
746- win.mu.Unlock()794+ tb.mu.Unlock()
747 795
748 if mouseMode != 0 {796 if mouseMode != 0 {
749 if !mouseSGR || ev.Row >= childRows {797 if !mouseSGR || ev.Row >= childRows {
750 return798 return
751 }799 }
752- daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward(ev.Raw)800+ daemonKeyHandler{d: d, ac: ac}.Forward(ev.Raw)
753 return801 return
754 }802 }
755 803
756 switch ev.Button {804 switch ev.Button {
757 case mouse.WheelUp:805 case mouse.WheelUp:
758 if altScreen {806 if altScreen {
759- daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))807+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A"))
760 return808 return
761 }809 }
762 d.enterCopyMode(sess, ac)810 d.enterCopyMode(sess, ac)
763 d.copyWheel(sess, ac, -3)811 d.copyWheel(sess, ac, -3)
764 case mouse.WheelDown:812 case mouse.WheelDown:
765 if altScreen {813 if altScreen {
766- daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))814+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B"))
767 }815 }
768 }816 }
769 }817 }
770 818
771 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {819 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
772- win := sess.activeWindow()820+ tb := sess.activeTab()
773- if win == nil {821+ if tb == nil {
774 return822 return
775 }823 }
776 824
777- win.mu.Lock()825+ tb.mu.Lock()
778 ac.copyMu.Lock()826 ac.copyMu.Lock()
779 if ac.copyMode == nil {827 if ac.copyMode == nil {
780 ac.copyMu.Unlock()828 ac.copyMu.Unlock()
781- win.mu.Unlock()829+ tb.mu.Unlock()
782 return830 return
783 }831 }
784- snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)832+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
785 if delta > 0 && ac.copyMode.AtBottom(snap) {833 if delta > 0 && ac.copyMode.AtBottom(snap) {
786 ac.copyMode = nil834 ac.copyMode = nil
787 ac.copyMu.Unlock()835 ac.copyMu.Unlock()
788- win.mu.Unlock()836+ tb.mu.Unlock()
789 d.paint(sess, ac, true)837 d.paint(sess, ac, true)
790 return838 return
791 }839 }
@@ -795,19 +843,19 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
795 ac.copyMode = nil843 ac.copyMode = nil
796 }844 }
797 ac.copyMu.Unlock()845 ac.copyMu.Unlock()
798- win.mu.Unlock()846+ tb.mu.Unlock()
799 847
800 d.paint(sess, ac, true)848 d.paint(sess, ac, true)
801 }849 }
802 850
803 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {851 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
804- win := sess.activeWindow()852+ tb := sess.activeTab()
805- if win == nil {853+ if tb == nil {
806 return854 return
807 }855 }
808- win.mu.Lock()856+ tb.mu.Lock()
809- snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)857+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
810- win.mu.Unlock()858+ tb.mu.Unlock()
811 ac.copyMu.Lock()859 ac.copyMu.Lock()
812 ac.copyMode = scopy.NewMode(snap)860 ac.copyMode = scopy.NewMode(snap)
813 ac.copyMu.Unlock()861 ac.copyMu.Unlock()
@@ -815,17 +863,17 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
815 }863 }
816 864
817 func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) {865 func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) {
818- win := sess.activeWindow()866+ tb := sess.activeTab()
819- if win == nil {867+ if tb == nil {
820 return868 return
821 }869 }
822 870
823- win.mu.Lock()871+ tb.mu.Lock()
824 ac.copyMu.Lock()872 ac.copyMu.Lock()
825 if ac.copyMode == nil {873 if ac.copyMode == nil {
826 ac.copyPending = nil874 ac.copyPending = nil
827 ac.copyMu.Unlock()875 ac.copyMu.Unlock()
828- win.mu.Unlock()876+ tb.mu.Unlock()
829 return877 return
830 }878 }
831 if len(ac.copyPending) > 0 {879 if len(ac.copyPending) > 0 {
@@ -835,7 +883,7 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
835 data = combined883 data = combined
836 ac.copyPending = nil884 ac.copyPending = nil
837 }885 }
838- snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)886+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
839 changed := false887 changed := false
840 copyOut := false888 copyOut := false
841 exit := false889 exit := false
@@ -884,7 +932,7 @@ func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte)
884 ac.copyMode = nil932 ac.copyMode = nil
885 }933 }
886 ac.copyMu.Unlock()934 ac.copyMu.Unlock()
887- win.mu.Unlock()935+ tb.mu.Unlock()
888 936
889 if copyOut && text != "" {937 if copyOut && text != "" {
890 for _, chunk := range scopy.OSC52(text) {938 for _, chunk := range scopy.OSC52(text) {
@@ -932,64 +980,323 @@ func isCopyEscapePrefix(data []byte) bool {
932 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')980 len(data) == 3 && data[0] == 0x1b && data[1] == '[' && (data[2] == '5' || data[2] == '6')
933 }981 }
934 982
983+func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
984+ views, curTab := d.pickerViews(sess)
985+ ac.pickerMu.Lock()
986+ ac.picker = picker.New(views, sess.id, curTab)
987+ ac.pickerPending = nil
988+ ac.pickerPreview = nil
989+ ac.pickerMu.Unlock()
990+ d.registerPreviewForSelection(ac)
991+ d.paint(sess, ac, true)
992+}
993+
994+func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
995+ d.mu.Lock()
996+ sessions := make([]*session, 0, len(d.sessions))
997+ for _, s := range d.sessions {
998+ sessions = append(sessions, s)
999+ }
1000+ d.mu.Unlock()
1001+ sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
1002+
1003+ views := make([]picker.SessionView, 0, len(sessions))
1004+ curTab := 0
1005+ for _, s := range sessions {
1006+ s.mu.Lock()
1007+ view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
1008+ for i := range s.tabs {
1009+ view.Tabs[i] = strconv.Itoa(i + 1)
1010+ }
1011+ if s == cur {
1012+ curTab = s.active
1013+ }
1014+ s.mu.Unlock()
1015+ views = append(views, view)
1016+ }
1017+ return views, curTab
1018+}
1019+
1020+func (d *Daemon) handlePickerInput(sess *session, ac *attachedClient, data []byte) {
1021+ ac.pickerMu.Lock()
1022+ if ac.picker == nil {
1023+ ac.pickerPending = nil
1024+ ac.pickerMu.Unlock()
1025+ return
1026+ }
1027+ if len(ac.pickerPending) > 0 {
1028+ combined := make([]byte, 0, len(ac.pickerPending)+len(data))
1029+ combined = append(combined, ac.pickerPending...)
1030+ combined = append(combined, data...)
1031+ data = combined
1032+ ac.pickerPending = nil
1033+ }
1034+ changed := false
1035+ exit := false
1036+ switchTarget := false
1037+ for i := 0; i < len(data); i++ {
1038+ switch data[i] {
1039+ case 'j':
1040+ ac.picker.Down()
1041+ changed = true
1042+ case 'k':
1043+ ac.picker.Up()
1044+ changed = true
1045+ case '\r', '\n':
1046+ switchTarget = true
1047+ exit = true
1048+ case 'q', 0x03, 0x1b:
1049+ if data[i] == 0x1b {
1050+ tail := data[i:]
1051+ consumed, ok := routePickerEscape(ac.picker, tail)
1052+ if ok {
1053+ i += consumed - 1
1054+ changed = true
1055+ continue
1056+ }
1057+ if isPickerEscapePrefix(tail) {
1058+ ac.pickerPending = append(ac.pickerPending[:0], tail...)
1059+ break
1060+ }
1061+ }
1062+ exit = true
1063+ }
1064+ }
1065+ var target picker.Target
1066+ var ok bool
1067+ if switchTarget {
1068+ target, ok = ac.picker.Selected()
1069+ }
1070+ ac.pickerMu.Unlock()
1071+
1072+ if changed {
1073+ d.registerPreviewForSelection(ac)
1074+ }
1075+ if exit {
1076+ d.closePicker(ac)
1077+ }
1078+ if switchTarget && ok {
1079+ d.switchToTarget(sess, ac, target)
1080+ return
1081+ }
1082+ if exit || changed {
1083+ d.paint(sess, ac, true)
1084+ }
1085+}
1086+
1087+func routePickerEscape(m *picker.Model, data []byte) (int, bool) {
1088+ if len(data) >= 3 && data[1] == '[' {
1089+ switch data[2] {
1090+ case 'A':
1091+ m.Up()
1092+ return 3, true
1093+ case 'B':
1094+ m.Down()
1095+ return 3, true
1096+ }
1097+ }
1098+ return 0, false
1099+}
1100+
1101+func isPickerEscapePrefix(data []byte) bool {
1102+ return len(data) == 2 && data[0] == 0x1b && data[1] == '['
1103+}
1104+
1105+func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
1106+ ac.pickerMu.Lock()
1107+ var target picker.Target
1108+ var ok bool
1109+ if ac.picker != nil {
1110+ target, ok = ac.picker.Selected()
1111+ }
1112+ ac.pickerMu.Unlock()
1113+
1114+ var next *tab
1115+ if ok {
1116+ next = d.tabByTarget(target)
1117+ }
1118+
1119+ ac.pickerMu.Lock()
1120+ old := ac.pickerPreview
1121+ if ac.picker == nil {
1122+ next = nil
1123+ }
1124+ ac.pickerPreview = next
1125+ ac.pickerMu.Unlock()
1126+
1127+ if old != nil && old != next {
1128+ old.mu.Lock()
1129+ if old.previewClient == ac {
1130+ old.previewClient = nil
1131+ }
1132+ old.mu.Unlock()
1133+ }
1134+ if next != nil {
1135+ next.mu.Lock()
1136+ next.previewClient = ac
1137+ next.mu.Unlock()
1138+
1139+ ac.pickerMu.Lock()
1140+ keep := ac.pickerPreview == next
1141+ ac.pickerMu.Unlock()
1142+ if !keep {
1143+ next.mu.Lock()
1144+ if next.previewClient == ac {
1145+ next.previewClient = nil
1146+ }
1147+ next.mu.Unlock()
1148+ }
1149+ }
1150+}
1151+
1152+func (d *Daemon) unregisterPreview(ac *attachedClient) {
1153+ ac.pickerMu.Lock()
1154+ old := ac.pickerPreview
1155+ ac.pickerPreview = nil
1156+ ac.pickerMu.Unlock()
1157+
1158+ if old != nil {
1159+ old.mu.Lock()
1160+ if old.previewClient == ac {
1161+ old.previewClient = nil
1162+ }
1163+ old.mu.Unlock()
1164+ }
1165+}
1166+
1167+func (d *Daemon) closePicker(ac *attachedClient) {
1168+ ac.pickerMu.Lock()
1169+ ac.picker = nil
1170+ ac.pickerPending = nil
1171+ ac.pickerMu.Unlock()
1172+ d.unregisterPreview(ac)
1173+}
1174+
1175+func (d *Daemon) tabByTarget(target picker.Target) *tab {
1176+ d.mu.Lock()
1177+ sess := d.sessions[target.Session]
1178+ d.mu.Unlock()
1179+ if sess == nil {
1180+ return nil
1181+ }
1182+ sess.mu.Lock()
1183+ defer sess.mu.Unlock()
1184+ if target.TabIndex < 0 || target.TabIndex >= len(sess.tabs) {
1185+ return nil
1186+ }
1187+ return sess.tabs[target.TabIndex]
1188+}
1189+
1190+func (d *Daemon) sessionByID(id domain.SessionID) *session {
1191+ d.mu.Lock()
1192+ defer d.mu.Unlock()
1193+ return d.sessions[id]
1194+}
1195+
1196+func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker.Target) {
1197+ targetSess := d.sessionByID(target.Session)
1198+ if targetSess == nil {
1199+ d.paint(sess, ac, true)
1200+ return
1201+ }
1202+ if targetSess == sess {
1203+ if sess.switchTab(target.TabIndex) {
1204+ d.paint(sess, ac, true)
1205+ } else {
1206+ d.paint(sess, ac, true)
1207+ }
1208+ return
1209+ }
1210+ if !sess.detachIfCurrent(ac) {
1211+ return
1212+ }
1213+ targetSess.stealClient(d, ac, target.TabIndex)
1214+ ac.setSession(targetSess)
1215+ d.firstPaint(targetSess, ac, ac.size)
1216+}
1217+
1218+func (s *session) stealClient(d *Daemon, ac *attachedClient, idx int) {
1219+ s.mu.Lock()
1220+ old := s.client
1221+ s.client = ac
1222+ if idx >= 0 && idx < len(s.tabs) {
1223+ s.active = idx
1224+ }
1225+ s.mu.Unlock()
1226+ if old != nil && old != ac {
1227+ d.unregisterPreview(old)
1228+ old.setSession(nil)
1229+ d.notifyDetachedAsync(old, ports.ReasonDetach)
1230+ }
1231+}
1232+
935 type daemonKeyHandler struct {1233 type daemonKeyHandler struct {
936- d *Daemon1234+ d *Daemon
937- sess *session1235+ ac *attachedClient
938- ac *attachedClient
939 }1236 }
940 1237
941 func (h daemonKeyHandler) Forward(data []byte) {1238 func (h daemonKeyHandler) Forward(data []byte) {
942- win := h.sess.activeWindow()1239+ sess := h.ac.currentSession()
943- if win == nil {1240+ if sess == nil {
944 return1241 return
945 }1242 }
946- if _, err := win.pty.Write(data); err != nil {1243+ tb := sess.activeTab()
947- h.d.log.Error("pty write failed", "err", err, "session", h.sess.name)1244+ if tb == nil {
1245+ return
1246+ }
1247+ if _, err := tb.pty.Write(data); err != nil {
1248+ h.d.log.Error("pty write failed", "err", err, "session", sess.name)
948 }1249 }
949 }1250 }
950 1251
951 func (h daemonKeyHandler) Action(action keys.Action) {1252 func (h daemonKeyHandler) Action(action keys.Action) {
1253+ sess := h.ac.currentSession()
1254+ if sess == nil {
1255+ return
1256+ }
952 switch action {1257 switch action {
953- case keys.ActionCreateWindow:1258+ case keys.ActionCreateTab:
954- if err := h.d.createWindow(h.sess, h.ac.size); err != nil {1259+ if err := h.d.createTab(sess, h.ac.size); err != nil {
955- h.d.log.Error("create window failed", "err", err, "session", h.sess.name)1260+ h.d.log.Error("create tab failed", "err", err, "session", sess.name)
956 return1261 return
957 }1262 }
958- h.d.paint(h.sess, h.ac, true)1263+ h.d.paint(sess, h.ac, true)
959- case keys.ActionNextWindow:1264+ case keys.ActionNextTab:
960- if h.sess.switchRelative(1) {1265+ if sess.switchRelative(1) {
961- h.d.paint(h.sess, h.ac, true)1266+ h.d.paint(sess, h.ac, true)
962 }1267 }
963- case keys.ActionPreviousWindow:1268+ case keys.ActionPreviousTab:
964- if h.sess.switchRelative(-1) {1269+ if sess.switchRelative(-1) {
965- h.d.paint(h.sess, h.ac, true)1270+ h.d.paint(sess, h.ac, true)
966 }1271 }
967 case keys.ActionDetach:1272 case keys.ActionDetach:
968- h.d.clientGone(h.sess, h.ac, true)1273+ h.d.clientGone(sess, h.ac, true)
969- case keys.ActionCloseWindow:1274+ case keys.ActionCloseTab:
970- win := h.sess.activeWindow()1275+ tb := sess.activeTab()
971- if win != nil {1276+ if tb != nil {
972- h.d.closeWindow(h.sess, win, true)1277+ h.d.closeTab(sess, tb, true)
973 }1278 }
974 case keys.ActionCopyMode:1279 case keys.ActionCopyMode:
975- h.d.enterCopyMode(h.sess, h.ac)1280+ h.d.enterCopyMode(sess, h.ac)
976 case keys.ActionRenameSession:1281 case keys.ActionRenameSession:
977- h.sess.promoteEphemeral()1282+ sess.promoteEphemeral()
978- h.d.paint(h.sess, h.ac, true)1283+ h.d.paint(sess, h.ac, true)
979- case keys.ActionSwitchWindow1, keys.ActionSwitchWindow2, keys.ActionSwitchWindow3,1284+ case keys.ActionOpenPicker:
980- keys.ActionSwitchWindow4, keys.ActionSwitchWindow5, keys.ActionSwitchWindow6,1285+ h.d.enterPicker(sess, h.ac)
981- keys.ActionSwitchWindow7, keys.ActionSwitchWindow8, keys.ActionSwitchWindow9:1286+ case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
982- idx := int(action - keys.ActionSwitchWindow1)1287+ keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
983- if h.sess.switchWindow(idx) {1288+ keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
984- h.d.paint(h.sess, h.ac, true)1289+ idx := int(action - keys.ActionSwitchTab1)
1290+ if sess.switchTab(idx) {
1291+ h.d.paint(sess, h.ac, true)
985 }1292 }
986 }1293 }
987 }1294 }
988 1295
989-func (s *session) switchWindow(idx int) bool {1296+func (s *session) switchTab(idx int) bool {
990 s.mu.Lock()1297 s.mu.Lock()
991 defer s.mu.Unlock()1298 defer s.mu.Unlock()
992- if idx < 0 || idx >= len(s.windows) || idx == s.active {1299+ if idx < 0 || idx >= len(s.tabs) || idx == s.active {
993 return false1300 return false
994 }1301 }
995 s.active = idx1302 s.active = idx
@@ -999,10 +1306,10 @@ func (s *session) switchWindow(idx int) bool {
999 func (s *session) switchRelative(delta int) bool {1306 func (s *session) switchRelative(delta int) bool {
1000 s.mu.Lock()1307 s.mu.Lock()
1001 defer s.mu.Unlock()1308 defer s.mu.Unlock()
1002- if len(s.windows) < 2 {1309+ if len(s.tabs) < 2 {
1003 return false1310 return false
1004 }1311 }
1005- s.active = (s.active + delta + len(s.windows)) % len(s.windows)1312+ s.active = (s.active + delta + len(s.tabs)) % len(s.tabs)
1006 return true1313 return true
1007 }1314 }
1008 1315
@@ -1014,11 +1321,11 @@ func (s *session) promoteEphemeral() {
1014 s.ephemeral = false1321 s.ephemeral = false
1015 }1322 }
1016 1323
1017-func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) {1324+func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
1018 sess.mu.Lock()1325 sess.mu.Lock()
1019 idx := -11326 idx := -1
1020- for i, w := range sess.windows {1327+ for i, w := range sess.tabs {
1021- if w == win {1328+ if w == tb {
1022 idx = i1329 idx = i
1023 break1330 break
1024 }1331 }
@@ -1027,24 +1334,24 @@ func (d *Daemon) closeWindow(sess *session, win *window, repaint bool) {
1027 sess.mu.Unlock()1334 sess.mu.Unlock()
1028 return1335 return
1029 }1336 }
1030- if len(sess.windows) == 1 {1337+ if len(sess.tabs) == 1 {
1031 sess.mu.Unlock()1338 sess.mu.Unlock()
1032 d.killSession(sess, ports.ReasonSessionKilled)1339 d.killSession(sess, ports.ReasonSessionKilled)
1033 return1340 return
1034 }1341 }
1035- sess.windows = append(sess.windows[:idx], sess.windows[idx+1:]...)1342+ sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
1036- if sess.active >= len(sess.windows) {1343+ if sess.active >= len(sess.tabs) {
1037- sess.active = len(sess.windows) - 11344+ sess.active = len(sess.tabs) - 1
1038 } else if idx < sess.active {1345 } else if idx < sess.active {
1039 sess.active--1346 sess.active--
1040 }1347 }
1041 ac := sess.client1348 ac := sess.client
1042 sess.mu.Unlock()1349 sess.mu.Unlock()
1043 1350
1044- if win.cancel != nil {1351+ if tb.cancel != nil {
1045- win.cancel()1352+ tb.cancel()
1046 }1353 }
1047- _ = win.pty.Close()1354+ _ = tb.pty.Close()
1048 if repaint && ac != nil {1355 if repaint && ac != nil {
1049 d.paint(sess, ac, true)1356 d.paint(sess, ac, true)
1050 }1357 }
@@ -1060,57 +1367,57 @@ func signal(ch chan struct{}) {
1060 }1367 }
1061 }1368 }
1062 1369
1063-func (d *Daemon) ptyReader(sess *session, win *window) {1370+func (d *Daemon) ptyReader(sess *session, tb *tab) {
1064 defer d.sessWg.Done()1371 defer d.sessWg.Done()
1065 buf := make([]byte, ptyReadBufSize)1372 buf := make([]byte, ptyReadBufSize)
1066 var resp []byte1373 var resp []byte
1067- win.mu.Lock()1374+ tb.mu.Lock()
1068- win.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }1375+ tb.screen.OnResponse = func(b []byte) { resp = append(resp, b...) }
1069- win.mu.Unlock()1376+ tb.mu.Unlock()
1070 for {1377 for {
1071- n, err := win.pty.Read(buf)1378+ n, err := tb.pty.Read(buf)
1072 if n > 0 {1379 if n > 0 {
1073 data := buf[:n]1380 data := buf[:n]
1074- win.mu.Lock()1381+ tb.mu.Lock()
1075- wasSyncing := win.screen.SyncUpdateActive()1382+ wasSyncing := tb.screen.SyncUpdateActive()
1076- win.screen.Write(data)1383+ tb.screen.Write(data)
1077- isSyncing := win.screen.SyncUpdateActive()1384+ isSyncing := tb.screen.SyncUpdateActive()
1078- win.mu.Unlock()1385+ tb.mu.Unlock()
1079 if len(resp) > 0 {1386 if len(resp) > 0 {
1080- if _, writeErr := win.pty.Write(resp); writeErr != nil {1387+ if _, writeErr := tb.pty.Write(resp); writeErr != nil {
1081 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)1388 d.log.Warn("pty response write failed", "err", writeErr, "session", sess.name)
1082 }1389 }
1083 resp = resp[:0]1390 resp = resp[:0]
1084 }1391 }
1085 if wasSyncing != isSyncing {1392 if wasSyncing != isSyncing {
1086- win.mu.Lock()1393+ tb.mu.Lock()
1087- win.syncGen++1394+ tb.syncGen++
1088- gen := win.syncGen1395+ gen := tb.syncGen
1089- win.mu.Unlock()1396+ tb.mu.Unlock()
1090 if isSyncing {1397 if isSyncing {
1091- go d.syncWatchdog(win, gen)1398+ go d.syncWatchdog(tb, gen)
1092 }1399 }
1093 }1400 }
1094 if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {1401 if (wasSyncing && !isSyncing) || (!isSyncing && syncUpdateEndIn(data)) {
1095- signal(win.flush)1402+ signal(tb.flush)
1096 continue1403 continue
1097 }1404 }
1098 if isSyncing {1405 if isSyncing {
1099 continue1406 continue
1100 }1407 }
1101- signal(win.dirty)1408+ signal(tb.dirty)
1102 }1409 }
1103 if err != nil {1410 if err != nil {
1104- d.closeWindow(sess, win, true)1411+ d.closeTab(sess, tb, true)
1105 return1412 return
1106 }1413 }
1107 }1414 }
1108 }1415 }
1109 1416
1110-// scheduler debounces dirty signals. The first dirty opens a short window;1417+// scheduler debounces dirty signals. The first dirty opens a short tab;
1111-// sustained floods progressively widen that window, while isolated updates1418+// sustained floods progressively widen that tab, while isolated updates
1112 // return to the minimum delay for interactive latency.1419 // return to the minimum delay for interactive latency.
1113-func (d *Daemon) scheduler(sess *session, win *window) {1420+func (d *Daemon) scheduler(sess *session, tb *tab) {
1114 defer d.sessWg.Done()1421 defer d.sessWg.Done()
1115 delay := minDebounceInterval1422 delay := minDebounceInterval
1116 lastRender := d.clock.Now()1423 lastRender := d.clock.Now()
@@ -1119,13 +1426,13 @@ outer:
1119 select {1426 select {
1120 case <-sess.ctx.Done():1427 case <-sess.ctx.Done():
1121 return1428 return
1122- case <-windowDone(win):1429+ case <-tabDone(tb):
1123 return1430 return
1124- case <-win.flush:1431+ case <-tb.flush:
1125- d.render(sess, win)1432+ d.render(sess, tb)
1126 lastRender = d.clock.Now()1433 lastRender = d.clock.Now()
1127 continue1434 continue
1128- case <-win.dirty:1435+ case <-tb.dirty:
1129 if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {1436 if d.clock.Now().Sub(lastRender) >= maxDebounceInterval {
1130 delay = minDebounceInterval1437 delay = minDebounceInterval
1131 }1438 }
@@ -1139,27 +1446,27 @@ outer:
1139 case <-sess.ctx.Done():1446 case <-sess.ctx.Done():
1140 timer.Stop()1447 timer.Stop()
1141 return1448 return
1142- case <-windowDone(win):1449+ case <-tabDone(tb):
1143 timer.Stop()1450 timer.Stop()
1144 return1451 return
1145- case <-win.flush:1452+ case <-tb.flush:
1146 if !timer.Stop() {1453 if !timer.Stop() {
1147 select {1454 select {
1148 case <-timer.C():1455 case <-timer.C():
1149 default:1456 default:
1150 }1457 }
1151 }1458 }
1152- d.render(sess, win)1459+ d.render(sess, tb)
1153 lastRender = d.clock.Now()1460 lastRender = d.clock.Now()
1154 continue outer1461 continue outer
1155- case <-win.dirty:1462+ case <-tb.dirty:
1156 coalesced++1463 coalesced++
1157 case <-timer.C():1464 case <-timer.C():
1158 break absorb1465 break absorb
1159 }1466 }
1160 }1467 }
1161 delay = nextDebounceDelay(delay, coalesced)1468 delay = nextDebounceDelay(delay, coalesced)
1162- d.render(sess, win)1469+ d.render(sess, tb)
1163 lastRender = d.clock.Now()1470 lastRender = d.clock.Now()
1164 }1471 }
1165 }1472 }
@@ -1178,30 +1485,30 @@ func nextDebounceDelay(delay time.Duration, coalesced int) time.Duration {
1178 return delay1485 return delay
1179 }1486 }
1180 1487
1181-func (d *Daemon) syncWatchdog(win *window, gen uint64) {1488+func (d *Daemon) syncWatchdog(tb *tab, gen uint64) {
1182 timer := d.clock.NewTimer(maxSyncUpdateDuration)1489 timer := d.clock.NewTimer(maxSyncUpdateDuration)
1183 select {1490 select {
1184- case <-windowDone(win):1491+ case <-tabDone(tb):
1185 timer.Stop()1492 timer.Stop()
1186 return1493 return
1187 case <-timer.C():1494 case <-timer.C():
1188 }1495 }
1189 1496
1190- win.mu.Lock()1497+ tb.mu.Lock()
1191- if win.syncGen != gen || !win.screen.SyncUpdateActive() {1498+ if tb.syncGen != gen || !tb.screen.SyncUpdateActive() {
1192- win.mu.Unlock()1499+ tb.mu.Unlock()
1193 return1500 return
1194 }1501 }
1195- win.screen.ForceSyncEnd()1502+ tb.screen.ForceSyncEnd()
1196- win.mu.Unlock()1503+ tb.mu.Unlock()
1197- signal(win.flush)1504+ signal(tb.flush)
1198 }1505 }
1199 1506
1200-func windowDone(win *window) <-chan struct{} {1507+func tabDone(tb *tab) <-chan struct{} {
1201- if win.ctx == nil {1508+ if tb.ctx == nil {
1202 return nil1509 return nil
1203 }1510 }
1204- return win.ctx.Done()1511+ return tb.ctx.Done()
1205 }1512 }
1206 1513
1207 func syncUpdateEndIn(data []byte) bool {1514 func syncUpdateEndIn(data []byte) bool {
@@ -1210,60 +1517,92 @@ func syncUpdateEndIn(data []byte) bool {
1210 1517
1211 // render paints the current client, or (when detached) just clears accumulated1518 // render paints the current client, or (when detached) just clears accumulated
1212 // damage so it never grows unbounded while headless.1519 // damage so it never grows unbounded while headless.
1213-func (d *Daemon) render(sess *session, win *window) {1520+func (d *Daemon) render(sess *session, tb *tab) {
1214- win.mu.Lock()1521+ tb.mu.Lock()
1215- if win.screen.SyncUpdateActive() {1522+ if tb.screen.SyncUpdateActive() {
1216- win.mu.Unlock()1523+ tb.mu.Unlock()
1217 return1524 return
1218 }1525 }
1219- win.mu.Unlock()1526+ tb.mu.Unlock()
1527+
1528+ tb.mu.Lock()
1529+ previewer := tb.previewClient
1530+ tb.mu.Unlock()
1220 1531
1221 sess.mu.Lock()1532 sess.mu.Lock()
1222 ac := sess.client1533 ac := sess.client
1223- active := sess.active >= 0 && sess.active < len(sess.windows) && sess.windows[sess.active] == win1534+ active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb
1224 sess.mu.Unlock()1535 sess.mu.Unlock()
1225 1536
1537+ if previewer != nil {
1538+ if previewSess := previewer.currentSession(); previewSess != nil {
1539+ d.paint(previewSess, previewer, false)
1540+ }
1541+ if ac != nil && active && ac != previewer {
1542+ d.paint(sess, ac, false)
1543+ return
1544+ }
1545+ tb.mu.Lock()
1546+ tb.screen.ClearDamage()
1547+ tb.mu.Unlock()
1548+ return
1549+ }
1550+
1226 if ac == nil || !active {1551 if ac == nil || !active {
1227- win.mu.Lock()1552+ tb.mu.Lock()
1228- win.screen.ClearDamage()1553+ tb.screen.ClearDamage()
1229- win.mu.Unlock()1554+ tb.mu.Unlock()
1230 return1555 return
1231 }1556 }
1232 d.paint(sess, ac, false)1557 d.paint(sess, ac, false)
1233 }1558 }
1234 1559
1235-// paint draws the composed client frame (active window plus status bar) and1560+// paint draws the composed client frame (active tab plus status bar) and
1236 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations1561 // sends the resulting bytes. The renderer shadow is reset on explicit invalidations
1237 // such as switch/create/close/rename/resize so the repaint is complete.1562 // such as switch/create/close/rename/resize so the repaint is complete.
1238 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {1563 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1239- win := sess.activeWindow()1564+ tb := sess.activeTab()
1240- if win == nil {1565+ if tb == nil {
1241 return1566 return
1242 }1567 }
1243 1568
1244 ac.sendMu.Lock()1569 ac.sendMu.Lock()
1245- win.mu.Lock()
1246 ac.copyMu.Lock()1570 ac.copyMu.Lock()
1247 copyActive := ac.copyMode != nil1571 copyActive := ac.copyMode != nil
1248- if reset || copyActive {1572+ copyMode := ac.copyMode
1573+ ac.copyMu.Unlock()
1574+ ac.pickerMu.Lock()
1575+ pickerActive := ac.picker != nil
1576+ pickerModel := ac.picker
1577+ previewTab := ac.pickerPreview
1578+ preview := snapshotPickerPreview(previewTab)
1579+
1580+ tb.mu.Lock()
1581+ if reset || copyActive || pickerActive {
1249 ac.rend.Reset()1582 ac.rend.Reset()
1250 }1583 }
1251- if reset {1584+ if reset || pickerActive {
1252 ac.lastCursor.valid = false1585 ac.lastCursor.valid = false
1253 }1586 }
1254- frame, damage := composeClientFrame(sess, win, reset)1587+ frame, damage := composeClientFrame(sess, tb, reset)
1255 if copyActive {1588 if copyActive {
1256- frame, damage = composeCopyClientFrame(ac.copyMode, win)1589+ frame, damage = composeCopyClientFrame(copyMode, tb)
1257 }1590 }
1258- desiredCursor := desiredCursorOut(win.screen, copyActive)1591+ if pickerActive {
1259- ac.copyMu.Unlock()1592+ if previewTab == tb {
1593+ preview = pickerPreviewFromLockedTab(tb)
1594+ }
1595+ frame, damage = composePickerClientFrame(pickerModel, preview, frame)
1596+ }
1597+ ac.pickerMu.Unlock()
1598+ desiredCursor := desiredCursorOut(tb.screen, copyActive || pickerActive)
1260 data, err := ac.rend.Draw(frame, damage)1599 data, err := ac.rend.Draw(frame, damage)
1261 var cursorTail []byte1600 var cursorTail []byte
1262 if err == nil {1601 if err == nil {
1263 cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)1602 cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
1264 }1603 }
1265- win.screen.ClearDamage()1604+ tb.screen.ClearDamage()
1266- win.mu.Unlock()1605+ tb.mu.Unlock()
1267 1606
1268 var serr error1607 var serr error
1269 if err == nil {1608 if err == nil {
@@ -1283,7 +1622,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
1283 }1622 }
1284 }1623 }
1285 1624
1286-// resize applies a client size change to every window using rows-1 for the PTY1625+// resize applies a client size change to every tab using rows-1 for the PTY
1287 // and VT screen, then resets the renderer shadow and repaints the composed1626 // and VT screen, then resets the renderer shadow and repaints the composed
1288 // client-sized frame (including the status row).1627 // client-sized frame (including the status row).
1289 func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {1628 func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut {
@@ -1323,27 +1662,55 @@ func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte
1323 return b1662 return b
1324 }1663 }
1325 1664
1326-func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) {1665+func composeClientFrame(sess *session, tb *tab, full bool) (renderer.Frame, []renderer.Damage) {
1327- width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height1666+ width, screenRows := tb.screen.Frame.Width, tb.screen.Frame.Height
1328 frame := renderer.NewFrame(width, screenRows+1)1667 frame := renderer.NewFrame(width, screenRows+1)
1329 for y := range screenRows {1668 for y := range screenRows {
1330- copy(frame.Row(y), win.screen.Frame.Row(y))1669+ copy(frame.Row(y), tb.screen.Frame.Row(y))
1331 }1670 }
1332 drawStatus(frame.Row(screenRows), sess)1671 drawStatus(frame.Row(screenRows), sess)
1333 if full {1672 if full {
1334 return frame, []renderer.Damage{renderer.FullRedraw()}1673 return frame, []renderer.Damage{renderer.FullRedraw()}
1335 }1674 }
1336- damage := append([]renderer.Damage(nil), win.screen.Damage()...)1675+ damage := append([]renderer.Damage(nil), tb.screen.Damage()...)
1337 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1})1676 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1})
1338 return frame, damage1677 return frame, damage
1339 }1678 }
1340 1679
1341-func composeCopyClientFrame(mode *scopy.Mode, win *window) (renderer.Frame, []renderer.Damage) {1680+func composeCopyClientFrame(mode *scopy.Mode, tb *tab) (renderer.Frame, []renderer.Damage) {
1342- snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)1681+ snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame)
1343 frame := mode.Render(snap)1682 frame := mode.Render(snap)
1344 return frame, []renderer.Damage{renderer.FullRedraw()}1683 return frame, []renderer.Damage{renderer.FullRedraw()}
1345 }1684 }
1346 1685
1686+func composePickerClientFrame(model *picker.Model, preview picker.Preview, base renderer.Frame) (renderer.Frame, []renderer.Damage) {
1687+ inner := pickerModal.Composite(base, renderer.DefaultStyle())
1688+ modalFrame := model.Render(domain.Size{Cols: inner.Width, Rows: inner.Height}, preview)
1689+ for y := range min(inner.Height, modalFrame.Height) {
1690+ for x := range min(inner.Width, modalFrame.Width) {
1691+ base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
1692+ }
1693+ }
1694+ return base, []renderer.Damage{renderer.FullRedraw()}
1695+}
1696+
1697+func snapshotPickerPreview(tb *tab) picker.Preview {
1698+ if tb == nil {
1699+ return picker.Preview{}
1700+ }
1701+ tb.mu.Lock()
1702+ defer tb.mu.Unlock()
1703+ return pickerPreviewFromLockedTab(tb)
1704+}
1705+
1706+func pickerPreviewFromLockedTab(tb *tab) picker.Preview {
1707+ rows := make([][]renderer.Cell, tb.screen.Frame.Height)
1708+ for y := range rows {
1709+ rows[y] = append([]renderer.Cell(nil), tb.screen.Frame.Row(y)...)
1710+ }
1711+ return picker.Preview{Rows: rows, Width: tb.screen.Frame.Width, Height: tb.screen.Frame.Height}
1712+}
1713+
1347 func drawStatus(row []renderer.Cell, sess *session) {1714 func drawStatus(row []renderer.Cell, sess *session) {
1348 for i := range row {1715 for i := range row {
1349 row[i] = renderer.BlankCell()1716 row[i] = renderer.BlankCell()
@@ -1351,7 +1718,7 @@ func drawStatus(row []renderer.Cell, sess *session) {
1351 status := sess.statusSegments()1718 status := sess.statusSegments()
1352 x := 01719 x := 0
1353 writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle())1720 writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle())
1354- for _, w := range status.windows {1721+ for _, w := range status.tabs {
1355 style := renderer.DefaultStyle()1722 style := renderer.DefaultStyle()
1356 if w.active {1723 if w.active {
1357 style.Inverse = true1724 style.Inverse = true
@@ -1362,10 +1729,10 @@ func drawStatus(row []renderer.Cell, sess *session) {
1362 1729
1363 type statusSnapshot struct {1730 type statusSnapshot struct {
1364 session string1731 session string
1365- windows []statusWindow1732+ tabs []statusTab
1366 }1733 }
1367 1734
1368-type statusWindow struct {1735+type statusTab struct {
1369 name string1736 name string
1370 active bool1737 active bool
1371 }1738 }
@@ -1373,10 +1740,10 @@ type statusWindow struct {
1373 func (s *session) statusSegments() statusSnapshot {1740 func (s *session) statusSegments() statusSnapshot {
1374 s.mu.Lock()1741 s.mu.Lock()
1375 defer s.mu.Unlock()1742 defer s.mu.Unlock()
1376- snap := statusSnapshot{session: s.name, windows: make([]statusWindow, len(s.windows))}1743+ snap := statusSnapshot{session: s.name, tabs: make([]statusTab, len(s.tabs))}
1377- for i := range s.windows {1744+ for i := range s.tabs {
1378 name := strconv.Itoa(i + 1)1745 name := strconv.Itoa(i + 1)
1379- snap.windows[i] = statusWindow{name: name, active: i == s.active}1746+ snap.tabs[i] = statusTab{name: name, active: i == s.active}
1380 }1747 }
1381 return snap1748 return snap
1382 }1749 }
@@ -1395,24 +1762,24 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
1395 if !sz.Valid() {1762 if !sz.Valid() {
1396 return1763 return
1397 }1764 }
1398- winSize := windowSize(sz)1765+ tbSize := tabSize(sz)
1399 ac.size = sz1766 ac.size = sz
1400 1767
1401 sess.mu.Lock()1768 sess.mu.Lock()
1402- windows := append([]*window(nil), sess.windows...)1769+ tabs := append([]*tab(nil), sess.tabs...)
1403 sess.mu.Unlock()1770 sess.mu.Unlock()
1404- if len(windows) == 0 {1771+ if len(tabs) == 0 {
1405 return1772 return
1406 }1773 }
1407 1774
1408- for _, win := range windows {1775+ for _, tb := range tabs {
1409- if err := win.pty.Resize(winSize); err != nil {1776+ if err := tb.pty.Resize(tbSize); err != nil {
1410 d.log.Warn("pty resize failed", "err", err, "session", sess.name)1777 d.log.Warn("pty resize failed", "err", err, "session", sess.name)
1411 }1778 }
1412- win.mu.Lock()1779+ tb.mu.Lock()
1413- win.screen.Resize(winSize.Cols, winSize.Rows)1780+ tb.screen.Resize(tbSize.Cols, tbSize.Rows)
1414- win.size = winSize1781+ tb.size = tbSize
1415- win.mu.Unlock()1782+ tb.mu.Unlock()
1416 }1783 }
1417 d.paint(sess, ac, true)1784 d.paint(sess, ac, true)
1418 }1785 }
@@ -1422,6 +1789,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
1422 // its client's); a named session keeps running headless.1789 // its client's); a named session keeps running headless.
1423 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {1790 func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
1424 if sess.detachIfCurrent(ac) {1791 if sess.detachIfCurrent(ac) {
1792+ d.unregisterPreview(ac)
1425 _ = ac.tr.Close()1793 _ = ac.tr.Close()
1426 d.log.Warn("detached client after send error", "session", sess.name)1794 d.log.Warn("detached client after send error", "session", sess.name)
1427 if sess.ephemeral {1795 if sess.ephemeral {
@@ -1458,13 +1826,17 @@ func (d *Daemon) killSession(sess *session, reason uint8) {
1458 ac := sess.client1826 ac := sess.client
1459 sess.client = nil1827 sess.client = nil
1460 sess.mu.Unlock()1828 sess.mu.Unlock()
1829+ if ac != nil {
1830+ d.unregisterPreview(ac)
1831+ ac.setSession(nil)
1832+ }
1461 1833
1462 sess.cancel()1834 sess.cancel()
1463 sess.mu.Lock()1835 sess.mu.Lock()
1464- windows := append([]*window(nil), sess.windows...)1836+ tabs := append([]*tab(nil), sess.tabs...)
1465 sess.mu.Unlock()1837 sess.mu.Unlock()
1466- for _, win := range windows {1838+ for _, tb := range tabs {
1467- _ = win.pty.Close()1839+ _ = tb.pty.Close()
1468 }1840 }
1469 if empty {1841 if empty {
1470 d.doneOnce.Do(func() { close(d.done) })1842 d.doneOnce.Do(func() { close(d.done) })
M internal/usecase/daemon/daemon_test.go +316 -93
@@ -247,13 +247,14 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
247247 tr, sends := newCapturingTransport(t)
248248 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
249249 sctx, cancel := context.WithCancel(d.serveCtx)
250- windows := make([]*window, 0, len(ptys))
250+ tabs := make([]*tab, 0, len(ptys))
251251 for _, p := range ptys {
252252 wctx, wcancel := context.WithCancel(sctx)
253- windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel})
253+ tabs = append(tabs, &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel})
254254 }
255- sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac}
256- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})
255+ sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}
256+ ac.setSession(sess)
257+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
257258 d.sessions[sess.id] = sess
258259 t.Cleanup(cancel)
259260 return d, sess, ac, sends
@@ -265,11 +266,12 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
265266 errBoom := errors.New("boom")
266267 p := portsmocks.NewMockPTY(t)
267268 p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
268- win := &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}
269- sess := &session{id: "manual", name: "work", windows: []*window{win}}
269+ win := &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}
270+ sess := &session{id: "manual", name: "work", tabs: []*tab{win}}
270271 ac := &attachedClient{}
272+ ac.setSession(sess)
271273
272- daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("input"))
274+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input"))
273275
274276 got := logs.String()
275277 if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") {
@@ -277,7 +279,7 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
277279 }
278280 }
279281
280-func newManualWindowSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
282+func newManualTabSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
281283 t.Helper()
282284 ptys := make([]ports.PTY, 0, n)
283285 releases := make([]func(), 0, n)
@@ -290,7 +292,7 @@ func newManualWindowSession(t *testing.T, n int) (*Daemon, *session, *attachedCl
290292 return d, sess, ac, sends, releases
291293 }
292294
293-func activeWindowIndex(sess *session) int {
295+func activeTabIndex(sess *session) int {
294296 sess.mu.Lock()
295297 defer sess.mu.Unlock()
296298 return sess.active
@@ -308,8 +310,8 @@ func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) {
308310 writes := make(chan []byte, 1)
309311 p, _ := newBlockingPTYWithWrites(t, writes)
310312 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
311- sess.windows[0].scrollback = scopy.NewScrollback(4)
312- sess.windows[0].screen.Write([]byte("live"))
313+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
314+ sess.tabs[0].screen.Write([]byte("live"))
313315
314316 d.handleInput(sess, ac, []byte("\x1bu"))
315317
@@ -333,10 +335,10 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
333335 writes := make(chan []byte, 1)
334336 p, _ := newBlockingPTYWithWrites(t, writes)
335337 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
336- sess.windows[0].scrollback = scopy.NewScrollback(4)
337- sess.windows[0].scrollback.Append(testRow("old1 "))
338- sess.windows[0].scrollback.Append(testRow("old2 "))
339- sess.windows[0].screen.Write([]byte("live"))
338+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
339+ sess.tabs[0].scrollback.Append(testRow("old1 "))
340+ sess.tabs[0].scrollback.Append(testRow("old2 "))
341+ sess.tabs[0].screen.Write([]byte("live"))
340342
341343 d.enterCopyMode(sess, ac)
342344 awaitFrame(t, sends, ports.MsgOutput)
@@ -400,7 +402,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
400402 if sess == nil {
401403 return false
402404 }
403- win := sess.activeWindow()
405+ win := sess.activeTab()
404406 if win == nil {
405407 return false
406408 }
@@ -440,8 +442,8 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
440442 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
441443 p, _ := newBlockingPTY(t)
442444 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
443- sess.windows[0].scrollback = scopy.NewScrollback(4)
444- sess.windows[0].screen.Write([]byte("live"))
445+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
446+ sess.tabs[0].screen.Write([]byte("live"))
445447
446448 d.enterCopyMode(sess, ac)
447449 awaitFrame(t, sends, ports.MsgOutput)
@@ -473,10 +475,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
473475 t.Run(tc.name, func(t *testing.T) {
474476 p, _ := newBlockingPTY(t)
475477 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
476- sess.windows[0].scrollback = scopy.NewScrollback(4)
477- sess.windows[0].scrollback.Append(testRow("old1 "))
478- sess.windows[0].scrollback.Append(testRow("old2 "))
479- sess.windows[0].screen.Write([]byte("live"))
478+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
479+ sess.tabs[0].scrollback.Append(testRow("old1 "))
480+ sess.tabs[0].scrollback.Append(testRow("old2 "))
481+ sess.tabs[0].screen.Write([]byte("live"))
480482
481483 d.enterCopyMode(sess, ac)
482484 awaitFrame(t, sends, ports.MsgOutput)
@@ -504,8 +506,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
504506 t.Run(tc.name, func(t *testing.T) {
505507 p, _ := newBlockingPTY(t)
506508 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
507- sess.windows[0].scrollback = scopy.NewScrollback(4)
508- sess.windows[0].screen.Write([]byte("live"))
509+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
510+ sess.tabs[0].screen.Write([]byte("live"))
509511
510512 d.enterCopyMode(sess, ac)
511513 awaitFrame(t, sends, ports.MsgOutput)
@@ -530,8 +532,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
530532 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
531533 p, _ := newBlockingPTY(t)
532534 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
533- sess.windows[0].scrollback = scopy.NewScrollback(4)
534- sess.windows[0].screen.Write([]byte("live"))
535+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
536+ sess.tabs[0].screen.Write([]byte("live"))
535537
536538 done := make(chan struct{})
537539 var drain sync.WaitGroup
@@ -560,10 +562,10 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
560562 drain.Wait()
561563 }
562564
563-func windowCount(sess *session) int {
565+func tabCount(sess *session) int {
564566 sess.mu.Lock()
565567 defer sess.mu.Unlock()
566- return len(sess.windows)
568+ return len(sess.tabs)
567569 }
568570
569571 // --- handshake --------------------------------------------------------------
@@ -671,7 +673,7 @@ func TestEphemeralNumberingReuse(t *testing.T) {
671673
672674 // --- attach replace ---------------------------------------------------------
673675
674-func TestAltDigitSwitchesBetweenThreeWindows(t *testing.T) {
676+func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
675677 writes1 := make(chan []byte, 1)
676678 writes2 := make(chan []byte, 1)
677679 writes3 := make(chan []byte, 1)
@@ -698,7 +700,7 @@ func TestAltDigitSwitchesBetweenThreeWindows(t *testing.T) {
698700
699701 require.Eventually(t, func() bool {
700702 sessions := listSessions(t, d)
701- return len(sessions.Sessions) == 1 && sessions.Sessions[0].Windows == 3
703+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3
702704 }, 2*time.Second, 5*time.Millisecond)
703705 require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)
704706 require.Equal(t, []byte("A"), <-writes1)
@@ -715,7 +717,7 @@ func TestAltDigitSwitchesBetweenThreeWindows(t *testing.T) {
715717 d.sessWg.Wait()
716718 }
717719
718-func TestAltCCreatesSecondActiveWindow(t *testing.T) {
720+func TestAltCCreatesSecondActiveTab(t *testing.T) {
719721 p1, releasePTY1 := newBlockingPTY(t)
720722 p2, releasePTY2 := newBlockingPTY(t)
721723 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
@@ -731,7 +733,7 @@ func TestAltCCreatesSecondActiveWindow(t *testing.T) {
731733
732734 require.Eventually(t, func() bool {
733735 sessions := listSessions(t, d)
734- return len(sessions.Sessions) == 1 && sessions.Sessions[0].Windows == 2
736+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2
735737 }, 2*time.Second, 5*time.Millisecond)
736738
737739 releaseConn()
@@ -741,7 +743,7 @@ func TestAltCCreatesSecondActiveWindow(t *testing.T) {
741743 d.sessWg.Wait()
742744 }
743745
744-func TestAltNextPreviousSwitchActiveWindow(t *testing.T) {
746+func TestAltNextPreviousSwitchActiveTab(t *testing.T) {
745747 cases := []struct {
746748 name string
747749 start int
@@ -756,7 +758,7 @@ func TestAltNextPreviousSwitchActiveWindow(t *testing.T) {
756758
757759 for _, tc := range cases {
758760 t.Run(tc.name, func(t *testing.T) {
759- d, sess, ac, _, releases := newManualWindowSession(t, 3)
761+ d, sess, ac, _, releases := newManualTabSession(t, 3)
760762 defer func() {
761763 for _, release := range releases {
762764 release()
@@ -766,12 +768,12 @@ func TestAltNextPreviousSwitchActiveWindow(t *testing.T) {
766768
767769 d.handleInput(sess, ac, tc.input)
768770
769- require.Equal(t, tc.wantIndex, activeWindowIndex(sess))
771+ require.Equal(t, tc.wantIndex, activeTabIndex(sess))
770772 })
771773 }
772774 }
773775
774-func TestAltXClosesActiveWindowAndSelectsRemaining(t *testing.T) {
776+func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
775777 writes := make(chan []byte, 1)
776778 p1, releasePTY1 := newBlockingPTY(t)
777779 p2, releasePTY2 := newBlockingPTY(t)
@@ -782,8 +784,8 @@ func TestAltXClosesActiveWindowAndSelectsRemaining(t *testing.T) {
782784 d.handleInput(sess, ac, []byte("\x1bx"))
783785
784786 require.Equal(t, 1, sessionCount(d))
785- require.Len(t, sess.windows, 2)
786- require.Equal(t, 1, activeWindowIndex(sess), "closing middle window selects the next remaining window")
787+ require.Len(t, sess.tabs, 2)
788+ require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab")
787789 d.handleInput(sess, ac, []byte("Z"))
788790 require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
789791 require.Equal(t, []byte("Z"), <-writes)
@@ -802,7 +804,7 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
802804 return done
803805 }
804806
805-func TestAltXClosesNonFinalWindowScheduler(t *testing.T) {
807+func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
806808 p1, releasePTY1 := newBlockingPTY(t)
807809 p2, releasePTY2 := newBlockingPTY(t)
808810 clk := &signalClock{called: make(chan struct{})}
@@ -812,8 +814,8 @@ func TestAltXClosesNonFinalWindowScheduler(t *testing.T) {
812814 defer releasePTY2()
813815
814816 d.sessWg.Add(1)
815- go d.scheduler(sess, sess.windows[1])
816- sess.windows[1].dirty <- struct{}{}
817+ go d.scheduler(sess, sess.tabs[1])
818+ sess.tabs[1].dirty <- struct{}{}
817819 <-clk.called
818820
819821 d.handleInput(sess, ac, []byte("\x1b2"))
@@ -822,12 +824,12 @@ func TestAltXClosesNonFinalWindowScheduler(t *testing.T) {
822824 select {
823825 case <-waitGroupDone(&d.sessWg):
824826 case <-time.After(2 * time.Second):
825- t.Fatal("scheduler for removed window did not exit")
827+ t.Fatal("scheduler for removed tab did not exit")
826828 }
827829 require.Equal(t, 1, sessionCount(d))
828830 }
829831
830-func TestPTYEOFClosesNonFinalWindowScheduler(t *testing.T) {
832+func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) {
831833 p1, releasePTY1 := newBlockingPTY(t)
832834 p2, releasePTY2 := newBlockingPTY(t)
833835 clk := &signalClock{called: make(chan struct{})}
@@ -837,22 +839,22 @@ func TestPTYEOFClosesNonFinalWindowScheduler(t *testing.T) {
837839 defer releasePTY2()
838840
839841 d.sessWg.Add(2)
840- go d.scheduler(sess, sess.windows[1])
841- go d.ptyReader(sess, sess.windows[1])
842- sess.windows[1].dirty <- struct{}{}
842+ go d.scheduler(sess, sess.tabs[1])
843+ go d.ptyReader(sess, sess.tabs[1])
844+ sess.tabs[1].dirty <- struct{}{}
843845 <-clk.called
844846 releasePTY2()
845847
846848 select {
847849 case <-waitGroupDone(&d.sessWg):
848850 case <-time.After(2 * time.Second):
849- t.Fatal("scheduler for EOF-removed window did not exit")
851+ t.Fatal("scheduler for EOF-removed tab did not exit")
850852 }
851853 require.Equal(t, 1, sessionCount(d))
852854 }
853855
854-func TestAltXClosesFinalWindowAndDetaches(t *testing.T) {
855- d, sess, ac, sends, releases := newManualWindowSession(t, 1)
856+func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
857+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
856858 defer releases[0]()
857859
858860 d.handleInput(sess, ac, []byte("\x1bx"))
@@ -864,21 +866,21 @@ func TestAltXClosesFinalWindowAndDetaches(t *testing.T) {
864866 require.Equal(t, ports.ReasonSessionKilled, det.Reason)
865867 }
866868
867-func TestPTYEOFClosesActiveNonFinalWindowAndRepaintsRemaining(t *testing.T) {
869+func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
868870 p1, releasePTY1 := newBlockingPTY(t)
869871 p2, releasePTY2 := newBlockingPTY(t)
870872 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
871873 defer releasePTY2()
872874 sess.active = 0
873- sess.windows[1].screen.Write([]byte("remaining"))
875+ sess.tabs[1].screen.Write([]byte("remaining"))
874876
875877 d.sessWg.Add(1)
876- go d.ptyReader(sess, sess.windows[0])
878+ go d.ptyReader(sess, sess.tabs[0])
877879 releasePTY1()
878880
879- require.Eventually(t, func() bool { return windowCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
881+ require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
880882 require.Equal(t, 1, sessionCount(d))
881- require.Equal(t, 0, activeWindowIndex(sess))
883+ require.Equal(t, 0, activeTabIndex(sess))
882884 f := awaitFrame(t, sends, ports.MsgOutput)
883885 out, err := ports.UnmarshalOutput(f.Payload)
884886 require.NoError(t, err)
@@ -890,21 +892,21 @@ func TestPTYEOFClosesActiveNonFinalWindowAndRepaintsRemaining(t *testing.T) {
890892 d.sessWg.Wait()
891893 }
892894
893-func TestPTYEOFClosesInactiveNonFinalWindowAndRepaintsStatus(t *testing.T) {
895+func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
894896 p1, releasePTY1 := newBlockingPTY(t)
895897 p2, releasePTY2 := newBlockingPTY(t)
896898 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
897899 defer releasePTY1()
898900 sess.active = 0
899- sess.windows[0].screen.Write([]byte("active"))
901+ sess.tabs[0].screen.Write([]byte("active"))
900902
901903 d.sessWg.Add(1)
902- go d.ptyReader(sess, sess.windows[1])
904+ go d.ptyReader(sess, sess.tabs[1])
903905 releasePTY2()
904906
905- require.Eventually(t, func() bool { return windowCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
907+ require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
906908 require.Equal(t, 1, sessionCount(d))
907- require.Equal(t, 0, activeWindowIndex(sess))
909+ require.Equal(t, 0, activeTabIndex(sess))
908910 f := awaitFrame(t, sends, ports.MsgOutput)
909911 out, err := ports.UnmarshalOutput(f.Payload)
910912 require.NoError(t, err)
@@ -916,11 +918,11 @@ func TestPTYEOFClosesInactiveNonFinalWindowAndRepaintsStatus(t *testing.T) {
916918 d.sessWg.Wait()
917919 }
918920
919-func TestPTYEOFFinalWindowKillsSessionAndDetaches(t *testing.T) {
920- d, sess, _, sends, releases := newManualWindowSession(t, 1)
921+func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
922+ d, sess, _, sends, releases := newManualTabSession(t, 1)
921923
922924 d.sessWg.Add(1)
923- go d.ptyReader(sess, sess.windows[0])
925+ go d.ptyReader(sess, sess.tabs[0])
924926 releases[0]()
925927
926928 require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond)
@@ -933,7 +935,7 @@ func TestPTYEOFFinalWindowKillsSessionAndDetaches(t *testing.T) {
933935 }
934936
935937 func TestAltDDetachesCurrentClient(t *testing.T) {
936- d, sess, ac, sends, releases := newManualWindowSession(t, 1)
938+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
937939 defer releases[0]()
938940
939941 d.handleInput(sess, ac, []byte("\x1bd"))
@@ -946,7 +948,7 @@ func TestAltDDetachesCurrentClient(t *testing.T) {
946948 }
947949
948950 func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
949- d, sess, ac, sends, releases := newManualWindowSession(t, 1)
951+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
950952 defer releases[0]()
951953 sess.ephemeral = true
952954 sess.name = "0"
@@ -958,6 +960,217 @@ func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
958960 awaitFrame(t, sends, ports.MsgOutput)
959961 }
960962
963+func TestAltTOpensPickerModalAndDoesNotForward(t *testing.T) {
964+ writes := make(chan []byte, 1)
965+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
966+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
967+ defer releasePTY()
968+
969+ d.handleInput(sess, ac, []byte("\x1bt"))
970+
971+ require.True(t, ac.pickerActive())
972+ select {
973+ case got := <-writes:
974+ t.Fatalf("picker binding forwarded to PTY: %q", got)
975+ default:
976+ }
977+ out := awaitFrame(t, sends, ports.MsgOutput)
978+ msg, err := ports.UnmarshalOutput(out.Payload)
979+ require.NoError(t, err)
980+ data := string(msg.Data)
981+ require.Contains(t, data, "┌")
982+ require.Contains(t, data, "Sessions")
983+}
984+
985+func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
986+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
987+ defer func() {
988+ for _, release := range releases {
989+ release()
990+ }
991+ }()
992+
993+ d.handleInput(sess, ac, []byte("\x1bt"))
994+ awaitFrame(t, sends, ports.MsgOutput)
995+ d.handleInput(sess, ac, []byte("j"))
996+ awaitFrame(t, sends, ports.MsgOutput)
997+ d.handleInput(sess, ac, []byte("\r"))
998+
999+ require.Equal(t, 1, activeTabIndex(sess))
1000+ awaitFrame(t, sends, ports.MsgOutput)
1001+ d.handleInput(sess, ac, []byte("\x1bt"))
1002+ awaitFrame(t, sends, ports.MsgOutput)
1003+ d.handleInput(sess, ac, []byte("\x1b"))
1004+ require.False(t, ac.pickerActive())
1005+ awaitFrame(t, sends, ports.MsgOutput)
1006+}
1007+
1008+func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
1009+ p1, releasePTY1 := newBlockingPTY(t)
1010+ p2, releasePTY2 := newBlockingPTY(t)
1011+ defer releasePTY1()
1012+ defer releasePTY2()
1013+ d := newTestDaemon(t, nil, stubClock{})
1014+ tr1, sends1 := newCapturingTransport(t)
1015+ tr2, sends2 := newCapturingTransport(t)
1016+ ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1017+ ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1018+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
1019+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
1020+ defer cancel1()
1021+ defer cancel2()
1022+ sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
1023+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
1024+ ac1.setSession(sess1)
1025+ ac2.setSession(sess2)
1026+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
1027+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
1028+ d.sessions[sess1.id] = sess1
1029+ d.sessions[sess2.id] = sess2
1030+
1031+ d.handleInput(sess1, ac1, []byte("\x1bt"))
1032+ awaitFrame(t, sends1, ports.MsgOutput)
1033+ d.handleInput(sess1, ac1, []byte("j"))
1034+ awaitFrame(t, sends1, ports.MsgOutput)
1035+ d.handleInput(sess1, ac1, []byte("\r"))
1036+
1037+ require.Same(t, sess2, ac1.currentSession())
1038+ require.Same(t, ac1, sess2.client)
1039+ require.Nil(t, sess1.client)
1040+ require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")
1041+ det := awaitFrame(t, sends2, ports.MsgDetached)
1042+ dm, err := ports.UnmarshalDetached(det.Payload)
1043+ require.NoError(t, err)
1044+ require.Equal(t, ports.ReasonDetach, dm.Reason)
1045+ awaitFrame(t, sends1, ports.MsgOutput)
1046+}
1047+
1048+func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
1049+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
1050+ defer func() {
1051+ for _, release := range releases {
1052+ release()
1053+ }
1054+ }()
1055+
1056+ d.handleInput(sess, ac, []byte("\x1bt"))
1057+ awaitFrame(t, sends, ports.MsgOutput)
1058+ d.handleInput(sess, ac, []byte("j"))
1059+ awaitFrame(t, sends, ports.MsgOutput)
1060+
1061+ previewTab := sess.tabs[1]
1062+ previewTab.mu.Lock()
1063+ previewTab.screen.Write([]byte("inactive-preview-live"))
1064+ previewTab.mu.Unlock()
1065+ d.render(sess, previewTab)
1066+
1067+ previewOut := awaitFrame(t, sends, ports.MsgOutput)
1068+ previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
1069+ require.NoError(t, err)
1070+ require.Contains(t, string(previewMsg.Data), "inactive-preview-live")
1071+}
1072+
1073+func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
1074+ p1, releasePTY1 := newBlockingPTY(t)
1075+ p2, releasePTY2 := newBlockingPTY(t)
1076+ defer releasePTY1()
1077+ defer releasePTY2()
1078+ d := newTestDaemon(t, nil, stubClock{})
1079+ tr1, sends1 := newCapturingTransport(t)
1080+ tr2, _ := newCapturingTransport(t)
1081+ ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1082+ ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1083+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
1084+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
1085+ defer cancel1()
1086+ defer cancel2()
1087+ sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
1088+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
1089+ ac1.setSession(sess1)
1090+ ac2.setSession(sess2)
1091+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
1092+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
1093+ d.sessions[sess1.id] = sess1
1094+ d.sessions[sess2.id] = sess2
1095+
1096+ d.handleInput(sess1, ac1, []byte("\x1bt"))
1097+ awaitFrame(t, sends1, ports.MsgOutput)
1098+ d.handleInput(sess1, ac1, []byte("j"))
1099+ awaitFrame(t, sends1, ports.MsgOutput)
1100+
1101+ previewTab := sess2.tabs[0]
1102+ previewTab.mu.Lock()
1103+ previewTab.screen.Write([]byte("cross-session-preview-live"))
1104+ previewTab.mu.Unlock()
1105+ d.render(sess2, previewTab)
1106+
1107+ previewOut := awaitFrame(t, sends1, ports.MsgOutput)
1108+ previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
1109+ require.NoError(t, err)
1110+ require.Contains(t, string(previewMsg.Data), "cross-session-preview-live")
1111+}
1112+
1113+func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
1114+ d, sess, ac, sends, releases := newManualTabSession(t, 3)
1115+ defer func() {
1116+ for _, release := range releases {
1117+ release()
1118+ }
1119+ }()
1120+
1121+ done := make(chan struct{})
1122+ var drain sync.WaitGroup
1123+ drain.Go(func() {
1124+ for {
1125+ select {
1126+ case <-sends:
1127+ case <-done:
1128+ return
1129+ }
1130+ }
1131+ })
1132+
1133+ var wg sync.WaitGroup
1134+ for i := range 100 {
1135+ wg.Go(func() {
1136+ d.enterPicker(sess, ac)
1137+ })
1138+ wg.Go(func() {
1139+ d.handlePickerInput(sess, ac, []byte("j"))
1140+ d.handlePickerInput(sess, ac, []byte("k"))
1141+ })
1142+ wg.Go(func() {
1143+ d.closePicker(ac)
1144+ })
1145+ tb := sess.tabs[i%len(sess.tabs)]
1146+ wg.Go(func() {
1147+ tb.mu.Lock()
1148+ tb.screen.Write([]byte("render-race"))
1149+ tb.mu.Unlock()
1150+ d.render(sess, tb)
1151+ })
1152+ }
1153+ wg.Wait()
1154+ close(done)
1155+ drain.Wait()
1156+}
1157+
1158+func TestPickerResizeRecomposesModal(t *testing.T) {
1159+ p, releasePTY := newBlockingPTY(t)
1160+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1161+ defer releasePTY()
1162+
1163+ d.handleInput(sess, ac, []byte("\x1bt"))
1164+ awaitFrame(t, sends, ports.MsgOutput)
1165+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
1166+
1167+ out := awaitFrame(t, sends, ports.MsgOutput)
1168+ msg, err := ports.UnmarshalOutput(out.Payload)
1169+ require.NoError(t, err)
1170+ require.Contains(t, string(msg.Data), "┌")
1171+ require.Contains(t, string(msg.Data), "Sessions")
1172+}
1173+
9611174 func TestStatusCompositionGolden(t *testing.T) {
9621175 p1, releasePTY1 := newBlockingPTY(t)
9631176 p2, releasePTY2 := newBlockingPTY(t)
@@ -967,7 +1180,7 @@ func TestStatusCompositionGolden(t *testing.T) {
9671180 sess.active = 1
9681181 sess.name = "work"
9691182
970- win := sess.activeWindow()
1183+ win := sess.activeTab()
9711184 win.screen = vt.NewScreen(12, 2)
9721185 win.size = domain.Size{Cols: 12, Rows: 2}
9731186 win.screen.Write([]byte("hello"))
@@ -981,7 +1194,7 @@ func TestStatusCompositionGolden(t *testing.T) {
9811194 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
9821195 for i, c := range frame.Row(2) {
9831196 if i >= len(" work 1 ") && i < len(" work 1 2 ") {
984- require.True(t, c.Style.Inverse, "active window segment cell %d should be inverse", i)
1197+ require.True(t, c.Style.Inverse, "active tab segment cell %d should be inverse", i)
9851198 }
9861199 }
9871200 }
@@ -1009,7 +1222,7 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
10091222 out, err := ports.UnmarshalOutput(f.Payload)
10101223 require.NoError(t, err)
10111224 require.Contains(t, string(out.Data), "work")
1012- require.Contains(t, string(out.Data), ";7m", "active status window should be inverse-highlighted")
1225+ require.Contains(t, string(out.Data), ";7m", "active status tab should be inverse-highlighted")
10131226 }
10141227
10151228 releaseConn()
@@ -1147,9 +1360,10 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
11471360
11481361 d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
11491362 sctx, cancel := context.WithCancel(context.Background())
1150- win := &window{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
1363+ win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
11511364 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1152- sess := &session{id: "s", name: "s", windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac}
1365+ sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1366+ ac.setSession(sess)
11531367
11541368 win.mu.Lock()
11551369 win.screen.Write([]byte("hi"))
@@ -1176,7 +1390,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
11761390
11771391 cancel()
11781392 d.sessWg.Wait()
1179- require.Equal(t, int32(1), outputs.Load(), "N dirties in one window must render exactly once")
1393+ require.Equal(t, int32(1), outputs.Load(), "N dirties in one tab must render exactly once")
11801394 }
11811395
11821396 func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) {
@@ -1199,7 +1413,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
11991413
12001414 p := portsmocks.NewMockPTY(t)
12011415 var screenWidthAtResize int
1202- win := &window{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
1416+ win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
12031417 p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error {
12041418 // The screen must not yet be resized when the PTY is: proves order.
12051419 screenWidthAtResize = win.screen.Frame.Width
@@ -1218,7 +1432,8 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
12181432
12191433 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
12201434 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1221- sess := &session{id: "s", name: "s", windows: []*window{win}, client: ac}
1435+ sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
1436+ ac.setSession(sess)
12221437
12231438 d.resize(sess, ac, newSize)
12241439
@@ -1509,10 +1724,11 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
15091724 tr.EXPECT().Close().Return(nil).Maybe()
15101725
15111726 d := newTestDaemon(t, nil, stubClock{})
1512- win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
1727+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
15131728 sctx, cancel := context.WithCancel(context.Background())
15141729 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1515- sess := &session{id: "e", name: "0", ephemeral: true, windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac}
1730+ sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1731+ ac.setSession(sess)
15161732 d.sessions[sess.id] = sess
15171733
15181734 win.mu.Lock()
@@ -1550,9 +1766,10 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T)
15501766
15511767 d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
15521768 sctx, cancel := context.WithCancel(context.Background())
1553- win := &window{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1769+ win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
15541770 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1555- sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac}
1771+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1772+ ac.setSession(sess)
15561773
15571774 win.mu.Lock()
15581775 win.screen.Write([]byte("before"))
@@ -1609,8 +1826,10 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
16091826
16101827 tr, sends := newCapturingTransport(t)
16111828 sctx, cancel := context.WithCancel(context.Background())
1612- win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1613- sess := &session{id: "query", name: "query", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}}
1829+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1830+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1831+ sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1832+ ac.setSession(sess)
16141833 d.sessions[sess.id] = sess
16151834 d.sessWg.Add(1)
16161835 d.ptyReader(sess, win)
@@ -1643,8 +1862,8 @@ func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testin
16431862 p.EXPECT().Close().Return(nil).Maybe()
16441863
16451864 sctx, cancel := context.WithCancel(context.Background())
1646- win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1647- sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel}
1865+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1866+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
16481867 d.sessions[sess.id] = sess
16491868 d.sessWg.Add(1)
16501869 d.ptyReader(sess, win)
@@ -1676,8 +1895,8 @@ func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.
16761895 p.EXPECT().Close().Return(nil).Maybe()
16771896
16781897 sctx, cancel := context.WithCancel(context.Background())
1679- win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1680- sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel}
1898+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1899+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
16811900 d.sessions[sess.id] = sess
16821901 d.sessWg.Add(1)
16831902 d.ptyReader(sess, win)
@@ -1712,8 +1931,10 @@ func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
17121931
17131932 tr, sends := newCapturingTransport(t)
17141933 sctx, cancel := context.WithCancel(context.Background())
1715- win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1716- sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}}
1934+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1935+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1936+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1937+ ac.setSession(sess)
17171938 d.sessions[sess.id] = sess
17181939 d.sessWg.Add(2)
17191940 go d.scheduler(sess, win)
@@ -1754,8 +1975,10 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
17541975
17551976 tr, sends := newCapturingTransport(t)
17561977 sctx, cancel := context.WithCancel(context.Background())
1757- win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1758- sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}}
1978+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1979+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1980+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1981+ ac.setSession(sess)
17591982 d.sessions[sess.id] = sess
17601983 d.sessWg.Add(2)
17611984 go d.scheduler(sess, win)
@@ -1794,7 +2017,7 @@ func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) {
17942017 writes := make(chan []byte, 4)
17952018 p, _ := newBlockingPTYWithWrites(t, writes)
17962019 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1797- win := sess.windows[0]
2020+ win := sess.tabs[0]
17982021 win.screen.Write([]byte("live"))
17992022
18002023 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
@@ -1821,7 +2044,7 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
18212044 writes := make(chan []byte, 2)
18222045 p, _ := newBlockingPTYWithWrites(t, writes)
18232046 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1824- sess.windows[0].screen.Write([]byte("\x1b[?1049h"))
2047+ sess.tabs[0].screen.Write([]byte("\x1b[?1049h"))
18252048
18262049 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
18272050 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
@@ -1843,7 +2066,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
18432066 default:
18442067 }
18452068
1846- sess.windows[0].screen.Write([]byte("\x1b[?1000h"))
2069+ sess.tabs[0].screen.Write([]byte("\x1b[?1000h"))
18472070 raw := []byte("\x1b[<0;2;3M")
18482071 d.handleInput(sess, ac, raw)
18492072 select {
@@ -1852,7 +2075,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
18522075 default:
18532076 }
18542077
1855- sess.windows[0].screen.Write([]byte("\x1b[?1006h"))
2078+ sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
18562079 d.handleInput(sess, ac, raw)
18572080 require.Equal(t, raw, <-writes)
18582081
@@ -1868,7 +2091,7 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
18682091 writes := make(chan []byte, 2)
18692092 p, _ := newBlockingPTYWithWrites(t, writes)
18702093 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1871- sess.windows[0].screen.Write([]byte("live"))
2094+ sess.tabs[0].screen.Write([]byte("live"))
18722095
18732096 d.handleInput(sess, ac, []byte("\x1b[<64;"))
18742097 d.handleInput(sess, ac, []byte("1;1Mq"))
@@ -1886,7 +2109,7 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
18862109 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
18872110 p, _ := newBlockingPTY(t)
18882111 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1889- win := sess.windows[0]
2112+ win := sess.tabs[0]
18902113 win.screen.Write([]byte("A"))
18912114
18922115 d.paint(sess, ac, true)
@@ -1909,7 +2132,7 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
19092132 func TestCursorTailHidesInCopyMode(t *testing.T) {
19102133 p, _ := newBlockingPTY(t)
19112134 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1912- sess.windows[0].screen.Write([]byte("live"))
2135+ sess.tabs[0].screen.Write([]byte("live"))
19132136
19142137 d.enterCopyMode(sess, ac)
19152138 data := mustOutputData(t, sends)
@@ -247,13 +247,14 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
247 tr, sends := newCapturingTransport(t)247 tr, sends := newCapturingTransport(t)
248 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}248 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
249 sctx, cancel := context.WithCancel(d.serveCtx)249 sctx, cancel := context.WithCancel(d.serveCtx)
250- windows := make([]*window, 0, len(ptys))250+ tabs := make([]*tab, 0, len(ptys))
251 for _, p := range ptys {251 for _, p := range ptys {
252 wctx, wcancel := context.WithCancel(sctx)252 wctx, wcancel := context.WithCancel(sctx)
253- windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel})253+ tabs = append(tabs, &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: wctx, cancel: wcancel})
254 }254 }
255- sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac}255+ sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: tabs, client: ac}
256- ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})256+ ac.setSession(sess)
257+ ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
257 d.sessions[sess.id] = sess258 d.sessions[sess.id] = sess
258 t.Cleanup(cancel)259 t.Cleanup(cancel)
259 return d, sess, ac, sends260 return d, sess, ac, sends
@@ -265,11 +266,12 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
265 errBoom := errors.New("boom")266 errBoom := errors.New("boom")
266 p := portsmocks.NewMockPTY(t)267 p := portsmocks.NewMockPTY(t)
267 p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()268 p.EXPECT().Write([]byte("input")).Return(0, errBoom).Once()
268- win := &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}269+ win := &tab{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}}
269- sess := &session{id: "manual", name: "work", windows: []*window{win}}270+ sess := &session{id: "manual", name: "work", tabs: []*tab{win}}
270 ac := &attachedClient{}271 ac := &attachedClient{}
272+ ac.setSession(sess)
271 273
272- daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("input"))274+ daemonKeyHandler{d: d, ac: ac}.Forward([]byte("input"))
273 275
274 got := logs.String()276 got := logs.String()
275 if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") {277 if !strings.Contains(got, "pty write failed") || !strings.Contains(got, "boom") || !strings.Contains(got, "work") {
@@ -277,7 +279,7 @@ func TestPTYWriteErrorIsLogged(t *testing.T) {
277 }279 }
278 }280 }
279 281
280-func newManualWindowSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {282+func newManualTabSession(t *testing.T, n int) (*Daemon, *session, *attachedClient, chan ports.Frame, []func()) {
281 t.Helper()283 t.Helper()
282 ptys := make([]ports.PTY, 0, n)284 ptys := make([]ports.PTY, 0, n)
283 releases := make([]func(), 0, n)285 releases := make([]func(), 0, n)
@@ -290,7 +292,7 @@ func newManualWindowSession(t *testing.T, n int) (*Daemon, *session, *attachedCl
290 return d, sess, ac, sends, releases292 return d, sess, ac, sends, releases
291 }293 }
292 294
293-func activeWindowIndex(sess *session) int {295+func activeTabIndex(sess *session) int {
294 sess.mu.Lock()296 sess.mu.Lock()
295 defer sess.mu.Unlock()297 defer sess.mu.Unlock()
296 return sess.active298 return sess.active
@@ -308,8 +310,8 @@ func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) {
308 writes := make(chan []byte, 1)310 writes := make(chan []byte, 1)
309 p, _ := newBlockingPTYWithWrites(t, writes)311 p, _ := newBlockingPTYWithWrites(t, writes)
310 d, sess, ac, sends := newManualSessionWithPTYs(t, p)312 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
311- sess.windows[0].scrollback = scopy.NewScrollback(4)313+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
312- sess.windows[0].screen.Write([]byte("live"))314+ sess.tabs[0].screen.Write([]byte("live"))
313 315
314 d.handleInput(sess, ac, []byte("\x1bu"))316 d.handleInput(sess, ac, []byte("\x1bu"))
315 317
@@ -333,10 +335,10 @@ func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
333 writes := make(chan []byte, 1)335 writes := make(chan []byte, 1)
334 p, _ := newBlockingPTYWithWrites(t, writes)336 p, _ := newBlockingPTYWithWrites(t, writes)
335 d, sess, ac, sends := newManualSessionWithPTYs(t, p)337 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
336- sess.windows[0].scrollback = scopy.NewScrollback(4)338+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
337- sess.windows[0].scrollback.Append(testRow("old1 "))339+ sess.tabs[0].scrollback.Append(testRow("old1 "))
338- sess.windows[0].scrollback.Append(testRow("old2 "))340+ sess.tabs[0].scrollback.Append(testRow("old2 "))
339- sess.windows[0].screen.Write([]byte("live"))341+ sess.tabs[0].screen.Write([]byte("live"))
340 342
341 d.enterCopyMode(sess, ac)343 d.enterCopyMode(sess, ac)
342 awaitFrame(t, sends, ports.MsgOutput)344 awaitFrame(t, sends, ports.MsgOutput)
@@ -400,7 +402,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
400 if sess == nil {402 if sess == nil {
401 return false403 return false
402 }404 }
403- win := sess.activeWindow()405+ win := sess.activeTab()
404 if win == nil {406 if win == nil {
405 return false407 return false
406 }408 }
@@ -440,8 +442,8 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
440 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {442 func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
441 p, _ := newBlockingPTY(t)443 p, _ := newBlockingPTY(t)
442 d, sess, ac, sends := newManualSessionWithPTYs(t, p)444 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
443- sess.windows[0].scrollback = scopy.NewScrollback(4)445+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
444- sess.windows[0].screen.Write([]byte("live"))446+ sess.tabs[0].screen.Write([]byte("live"))
445 447
446 d.enterCopyMode(sess, ac)448 d.enterCopyMode(sess, ac)
447 awaitFrame(t, sends, ports.MsgOutput)449 awaitFrame(t, sends, ports.MsgOutput)
@@ -473,10 +475,10 @@ func TestCopyModeSplitArrowDoesNotExit(t *testing.T) {
473 t.Run(tc.name, func(t *testing.T) {475 t.Run(tc.name, func(t *testing.T) {
474 p, _ := newBlockingPTY(t)476 p, _ := newBlockingPTY(t)
475 d, sess, ac, sends := newManualSessionWithPTYs(t, p)477 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
476- sess.windows[0].scrollback = scopy.NewScrollback(4)478+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
477- sess.windows[0].scrollback.Append(testRow("old1 "))479+ sess.tabs[0].scrollback.Append(testRow("old1 "))
478- sess.windows[0].scrollback.Append(testRow("old2 "))480+ sess.tabs[0].scrollback.Append(testRow("old2 "))
479- sess.windows[0].screen.Write([]byte("live"))481+ sess.tabs[0].screen.Write([]byte("live"))
480 482
481 d.enterCopyMode(sess, ac)483 d.enterCopyMode(sess, ac)
482 awaitFrame(t, sends, ports.MsgOutput)484 awaitFrame(t, sends, ports.MsgOutput)
@@ -504,8 +506,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
504 t.Run(tc.name, func(t *testing.T) {506 t.Run(tc.name, func(t *testing.T) {
505 p, _ := newBlockingPTY(t)507 p, _ := newBlockingPTY(t)
506 d, sess, ac, sends := newManualSessionWithPTYs(t, p)508 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
507- sess.windows[0].scrollback = scopy.NewScrollback(4)509+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
508- sess.windows[0].screen.Write([]byte("live"))510+ sess.tabs[0].screen.Write([]byte("live"))
509 511
510 d.enterCopyMode(sess, ac)512 d.enterCopyMode(sess, ac)
511 awaitFrame(t, sends, ports.MsgOutput)513 awaitFrame(t, sends, ports.MsgOutput)
@@ -530,8 +532,8 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
530 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {532 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
531 p, _ := newBlockingPTY(t)533 p, _ := newBlockingPTY(t)
532 d, sess, ac, sends := newManualSessionWithPTYs(t, p)534 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
533- sess.windows[0].scrollback = scopy.NewScrollback(4)535+ sess.tabs[0].scrollback = scopy.NewScrollback(4)
534- sess.windows[0].screen.Write([]byte("live"))536+ sess.tabs[0].screen.Write([]byte("live"))
535 537
536 done := make(chan struct{})538 done := make(chan struct{})
537 var drain sync.WaitGroup539 var drain sync.WaitGroup
@@ -560,10 +562,10 @@ func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
560 drain.Wait()562 drain.Wait()
561 }563 }
562 564
563-func windowCount(sess *session) int {565+func tabCount(sess *session) int {
564 sess.mu.Lock()566 sess.mu.Lock()
565 defer sess.mu.Unlock()567 defer sess.mu.Unlock()
566- return len(sess.windows)568+ return len(sess.tabs)
567 }569 }
568 570
569 // --- handshake --------------------------------------------------------------571 // --- handshake --------------------------------------------------------------
@@ -671,7 +673,7 @@ func TestEphemeralNumberingReuse(t *testing.T) {
671 673
672 // --- attach replace ---------------------------------------------------------674 // --- attach replace ---------------------------------------------------------
673 675
674-func TestAltDigitSwitchesBetweenThreeWindows(t *testing.T) {676+func TestAltDigitSwitchesBetweenThreeTabs(t *testing.T) {
675 writes1 := make(chan []byte, 1)677 writes1 := make(chan []byte, 1)
676 writes2 := make(chan []byte, 1)678 writes2 := make(chan []byte, 1)
677 writes3 := make(chan []byte, 1)679 writes3 := make(chan []byte, 1)
@@ -698,7 +700,7 @@ func TestAltDigitSwitchesBetweenThreeWindows(t *testing.T) {
698 700
699 require.Eventually(t, func() bool {701 require.Eventually(t, func() bool {
700 sessions := listSessions(t, d)702 sessions := listSessions(t, d)
701- return len(sessions.Sessions) == 1 && sessions.Sessions[0].Windows == 3703+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 3
702 }, 2*time.Second, 5*time.Millisecond)704 }, 2*time.Second, 5*time.Millisecond)
703 require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)705 require.Eventually(t, func() bool { return len(writes1) == 1 }, 2*time.Second, 5*time.Millisecond)
704 require.Equal(t, []byte("A"), <-writes1)706 require.Equal(t, []byte("A"), <-writes1)
@@ -715,7 +717,7 @@ func TestAltDigitSwitchesBetweenThreeWindows(t *testing.T) {
715 d.sessWg.Wait()717 d.sessWg.Wait()
716 }718 }
717 719
718-func TestAltCCreatesSecondActiveWindow(t *testing.T) {720+func TestAltCCreatesSecondActiveTab(t *testing.T) {
719 p1, releasePTY1 := newBlockingPTY(t)721 p1, releasePTY1 := newBlockingPTY(t)
720 p2, releasePTY2 := newBlockingPTY(t)722 p2, releasePTY2 := newBlockingPTY(t)
721 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})723 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
@@ -731,7 +733,7 @@ func TestAltCCreatesSecondActiveWindow(t *testing.T) {
731 733
732 require.Eventually(t, func() bool {734 require.Eventually(t, func() bool {
733 sessions := listSessions(t, d)735 sessions := listSessions(t, d)
734- return len(sessions.Sessions) == 1 && sessions.Sessions[0].Windows == 2736+ return len(sessions.Sessions) == 1 && sessions.Sessions[0].Tabs == 2
735 }, 2*time.Second, 5*time.Millisecond)737 }, 2*time.Second, 5*time.Millisecond)
736 738
737 releaseConn()739 releaseConn()
@@ -741,7 +743,7 @@ func TestAltCCreatesSecondActiveWindow(t *testing.T) {
741 d.sessWg.Wait()743 d.sessWg.Wait()
742 }744 }
743 745
744-func TestAltNextPreviousSwitchActiveWindow(t *testing.T) {746+func TestAltNextPreviousSwitchActiveTab(t *testing.T) {
745 cases := []struct {747 cases := []struct {
746 name string748 name string
747 start int749 start int
@@ -756,7 +758,7 @@ func TestAltNextPreviousSwitchActiveWindow(t *testing.T) {
756 758
757 for _, tc := range cases {759 for _, tc := range cases {
758 t.Run(tc.name, func(t *testing.T) {760 t.Run(tc.name, func(t *testing.T) {
759- d, sess, ac, _, releases := newManualWindowSession(t, 3)761+ d, sess, ac, _, releases := newManualTabSession(t, 3)
760 defer func() {762 defer func() {
761 for _, release := range releases {763 for _, release := range releases {
762 release()764 release()
@@ -766,12 +768,12 @@ func TestAltNextPreviousSwitchActiveWindow(t *testing.T) {
766 768
767 d.handleInput(sess, ac, tc.input)769 d.handleInput(sess, ac, tc.input)
768 770
769- require.Equal(t, tc.wantIndex, activeWindowIndex(sess))771+ require.Equal(t, tc.wantIndex, activeTabIndex(sess))
770 })772 })
771 }773 }
772 }774 }
773 775
774-func TestAltXClosesActiveWindowAndSelectsRemaining(t *testing.T) {776+func TestAltXClosesActiveTabAndSelectsRemaining(t *testing.T) {
775 writes := make(chan []byte, 1)777 writes := make(chan []byte, 1)
776 p1, releasePTY1 := newBlockingPTY(t)778 p1, releasePTY1 := newBlockingPTY(t)
777 p2, releasePTY2 := newBlockingPTY(t)779 p2, releasePTY2 := newBlockingPTY(t)
@@ -782,8 +784,8 @@ func TestAltXClosesActiveWindowAndSelectsRemaining(t *testing.T) {
782 d.handleInput(sess, ac, []byte("\x1bx"))784 d.handleInput(sess, ac, []byte("\x1bx"))
783 785
784 require.Equal(t, 1, sessionCount(d))786 require.Equal(t, 1, sessionCount(d))
785- require.Len(t, sess.windows, 2)787+ require.Len(t, sess.tabs, 2)
786- require.Equal(t, 1, activeWindowIndex(sess), "closing middle window selects the next remaining window")788+ require.Equal(t, 1, activeTabIndex(sess), "closing middle tab selects the next remaining tab")
787 d.handleInput(sess, ac, []byte("Z"))789 d.handleInput(sess, ac, []byte("Z"))
788 require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)790 require.Eventually(t, func() bool { return len(writes) == 1 }, 2*time.Second, 5*time.Millisecond)
789 require.Equal(t, []byte("Z"), <-writes)791 require.Equal(t, []byte("Z"), <-writes)
@@ -802,7 +804,7 @@ func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
802 return done804 return done
803 }805 }
804 806
805-func TestAltXClosesNonFinalWindowScheduler(t *testing.T) {807+func TestAltXClosesNonFinalTabScheduler(t *testing.T) {
806 p1, releasePTY1 := newBlockingPTY(t)808 p1, releasePTY1 := newBlockingPTY(t)
807 p2, releasePTY2 := newBlockingPTY(t)809 p2, releasePTY2 := newBlockingPTY(t)
808 clk := &signalClock{called: make(chan struct{})}810 clk := &signalClock{called: make(chan struct{})}
@@ -812,8 +814,8 @@ func TestAltXClosesNonFinalWindowScheduler(t *testing.T) {
812 defer releasePTY2()814 defer releasePTY2()
813 815
814 d.sessWg.Add(1)816 d.sessWg.Add(1)
815- go d.scheduler(sess, sess.windows[1])817+ go d.scheduler(sess, sess.tabs[1])
816- sess.windows[1].dirty <- struct{}{}818+ sess.tabs[1].dirty <- struct{}{}
817 <-clk.called819 <-clk.called
818 820
819 d.handleInput(sess, ac, []byte("\x1b2"))821 d.handleInput(sess, ac, []byte("\x1b2"))
@@ -822,12 +824,12 @@ func TestAltXClosesNonFinalWindowScheduler(t *testing.T) {
822 select {824 select {
823 case <-waitGroupDone(&d.sessWg):825 case <-waitGroupDone(&d.sessWg):
824 case <-time.After(2 * time.Second):826 case <-time.After(2 * time.Second):
825- t.Fatal("scheduler for removed window did not exit")827+ t.Fatal("scheduler for removed tab did not exit")
826 }828 }
827 require.Equal(t, 1, sessionCount(d))829 require.Equal(t, 1, sessionCount(d))
828 }830 }
829 831
830-func TestPTYEOFClosesNonFinalWindowScheduler(t *testing.T) {832+func TestPTYEOFClosesNonFinalTabScheduler(t *testing.T) {
831 p1, releasePTY1 := newBlockingPTY(t)833 p1, releasePTY1 := newBlockingPTY(t)
832 p2, releasePTY2 := newBlockingPTY(t)834 p2, releasePTY2 := newBlockingPTY(t)
833 clk := &signalClock{called: make(chan struct{})}835 clk := &signalClock{called: make(chan struct{})}
@@ -837,22 +839,22 @@ func TestPTYEOFClosesNonFinalWindowScheduler(t *testing.T) {
837 defer releasePTY2()839 defer releasePTY2()
838 840
839 d.sessWg.Add(2)841 d.sessWg.Add(2)
840- go d.scheduler(sess, sess.windows[1])842+ go d.scheduler(sess, sess.tabs[1])
841- go d.ptyReader(sess, sess.windows[1])843+ go d.ptyReader(sess, sess.tabs[1])
842- sess.windows[1].dirty <- struct{}{}844+ sess.tabs[1].dirty <- struct{}{}
843 <-clk.called845 <-clk.called
844 releasePTY2()846 releasePTY2()
845 847
846 select {848 select {
847 case <-waitGroupDone(&d.sessWg):849 case <-waitGroupDone(&d.sessWg):
848 case <-time.After(2 * time.Second):850 case <-time.After(2 * time.Second):
849- t.Fatal("scheduler for EOF-removed window did not exit")851+ t.Fatal("scheduler for EOF-removed tab did not exit")
850 }852 }
851 require.Equal(t, 1, sessionCount(d))853 require.Equal(t, 1, sessionCount(d))
852 }854 }
853 855
854-func TestAltXClosesFinalWindowAndDetaches(t *testing.T) {856+func TestAltXClosesFinalTabAndDetaches(t *testing.T) {
855- d, sess, ac, sends, releases := newManualWindowSession(t, 1)857+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
856 defer releases[0]()858 defer releases[0]()
857 859
858 d.handleInput(sess, ac, []byte("\x1bx"))860 d.handleInput(sess, ac, []byte("\x1bx"))
@@ -864,21 +866,21 @@ func TestAltXClosesFinalWindowAndDetaches(t *testing.T) {
864 require.Equal(t, ports.ReasonSessionKilled, det.Reason)866 require.Equal(t, ports.ReasonSessionKilled, det.Reason)
865 }867 }
866 868
867-func TestPTYEOFClosesActiveNonFinalWindowAndRepaintsRemaining(t *testing.T) {869+func TestPTYEOFClosesActiveNonFinalTabAndRepaintsRemaining(t *testing.T) {
868 p1, releasePTY1 := newBlockingPTY(t)870 p1, releasePTY1 := newBlockingPTY(t)
869 p2, releasePTY2 := newBlockingPTY(t)871 p2, releasePTY2 := newBlockingPTY(t)
870 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)872 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
871 defer releasePTY2()873 defer releasePTY2()
872 sess.active = 0874 sess.active = 0
873- sess.windows[1].screen.Write([]byte("remaining"))875+ sess.tabs[1].screen.Write([]byte("remaining"))
874 876
875 d.sessWg.Add(1)877 d.sessWg.Add(1)
876- go d.ptyReader(sess, sess.windows[0])878+ go d.ptyReader(sess, sess.tabs[0])
877 releasePTY1()879 releasePTY1()
878 880
879- require.Eventually(t, func() bool { return windowCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)881+ require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
880 require.Equal(t, 1, sessionCount(d))882 require.Equal(t, 1, sessionCount(d))
881- require.Equal(t, 0, activeWindowIndex(sess))883+ require.Equal(t, 0, activeTabIndex(sess))
882 f := awaitFrame(t, sends, ports.MsgOutput)884 f := awaitFrame(t, sends, ports.MsgOutput)
883 out, err := ports.UnmarshalOutput(f.Payload)885 out, err := ports.UnmarshalOutput(f.Payload)
884 require.NoError(t, err)886 require.NoError(t, err)
@@ -890,21 +892,21 @@ func TestPTYEOFClosesActiveNonFinalWindowAndRepaintsRemaining(t *testing.T) {
890 d.sessWg.Wait()892 d.sessWg.Wait()
891 }893 }
892 894
893-func TestPTYEOFClosesInactiveNonFinalWindowAndRepaintsStatus(t *testing.T) {895+func TestPTYEOFClosesInactiveNonFinalTabAndRepaintsStatus(t *testing.T) {
894 p1, releasePTY1 := newBlockingPTY(t)896 p1, releasePTY1 := newBlockingPTY(t)
895 p2, releasePTY2 := newBlockingPTY(t)897 p2, releasePTY2 := newBlockingPTY(t)
896 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)898 d, sess, _, sends := newManualSessionWithPTYs(t, p1, p2)
897 defer releasePTY1()899 defer releasePTY1()
898 sess.active = 0900 sess.active = 0
899- sess.windows[0].screen.Write([]byte("active"))901+ sess.tabs[0].screen.Write([]byte("active"))
900 902
901 d.sessWg.Add(1)903 d.sessWg.Add(1)
902- go d.ptyReader(sess, sess.windows[1])904+ go d.ptyReader(sess, sess.tabs[1])
903 releasePTY2()905 releasePTY2()
904 906
905- require.Eventually(t, func() bool { return windowCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)907+ require.Eventually(t, func() bool { return tabCount(sess) == 1 }, 2*time.Second, 5*time.Millisecond)
906 require.Equal(t, 1, sessionCount(d))908 require.Equal(t, 1, sessionCount(d))
907- require.Equal(t, 0, activeWindowIndex(sess))909+ require.Equal(t, 0, activeTabIndex(sess))
908 f := awaitFrame(t, sends, ports.MsgOutput)910 f := awaitFrame(t, sends, ports.MsgOutput)
909 out, err := ports.UnmarshalOutput(f.Payload)911 out, err := ports.UnmarshalOutput(f.Payload)
910 require.NoError(t, err)912 require.NoError(t, err)
@@ -916,11 +918,11 @@ func TestPTYEOFClosesInactiveNonFinalWindowAndRepaintsStatus(t *testing.T) {
916 d.sessWg.Wait()918 d.sessWg.Wait()
917 }919 }
918 920
919-func TestPTYEOFFinalWindowKillsSessionAndDetaches(t *testing.T) {921+func TestPTYEOFFinalTabKillsSessionAndDetaches(t *testing.T) {
920- d, sess, _, sends, releases := newManualWindowSession(t, 1)922+ d, sess, _, sends, releases := newManualTabSession(t, 1)
921 923
922 d.sessWg.Add(1)924 d.sessWg.Add(1)
923- go d.ptyReader(sess, sess.windows[0])925+ go d.ptyReader(sess, sess.tabs[0])
924 releases[0]()926 releases[0]()
925 927
926 require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond)928 require.Eventually(t, func() bool { return sessionCount(d) == 0 }, 2*time.Second, 5*time.Millisecond)
@@ -933,7 +935,7 @@ func TestPTYEOFFinalWindowKillsSessionAndDetaches(t *testing.T) {
933 }935 }
934 936
935 func TestAltDDetachesCurrentClient(t *testing.T) {937 func TestAltDDetachesCurrentClient(t *testing.T) {
936- d, sess, ac, sends, releases := newManualWindowSession(t, 1)938+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
937 defer releases[0]()939 defer releases[0]()
938 940
939 d.handleInput(sess, ac, []byte("\x1bd"))941 d.handleInput(sess, ac, []byte("\x1bd"))
@@ -946,7 +948,7 @@ func TestAltDDetachesCurrentClient(t *testing.T) {
946 }948 }
947 949
948 func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {950 func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
949- d, sess, ac, sends, releases := newManualWindowSession(t, 1)951+ d, sess, ac, sends, releases := newManualTabSession(t, 1)
950 defer releases[0]()952 defer releases[0]()
951 sess.ephemeral = true953 sess.ephemeral = true
952 sess.name = "0"954 sess.name = "0"
@@ -958,6 +960,217 @@ func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
958 awaitFrame(t, sends, ports.MsgOutput)960 awaitFrame(t, sends, ports.MsgOutput)
959 }961 }
960 962
963+func TestAltTOpensPickerModalAndDoesNotForward(t *testing.T) {
964+ writes := make(chan []byte, 1)
965+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
966+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
967+ defer releasePTY()
968+
969+ d.handleInput(sess, ac, []byte("\x1bt"))
970+
971+ require.True(t, ac.pickerActive())
972+ select {
973+ case got := <-writes:
974+ t.Fatalf("picker binding forwarded to PTY: %q", got)
975+ default:
976+ }
977+ out := awaitFrame(t, sends, ports.MsgOutput)
978+ msg, err := ports.UnmarshalOutput(out.Payload)
979+ require.NoError(t, err)
980+ data := string(msg.Data)
981+ require.Contains(t, data, "┌")
982+ require.Contains(t, data, "Sessions")
983+}
984+
985+func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
986+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
987+ defer func() {
988+ for _, release := range releases {
989+ release()
990+ }
991+ }()
992+
993+ d.handleInput(sess, ac, []byte("\x1bt"))
994+ awaitFrame(t, sends, ports.MsgOutput)
995+ d.handleInput(sess, ac, []byte("j"))
996+ awaitFrame(t, sends, ports.MsgOutput)
997+ d.handleInput(sess, ac, []byte("\r"))
998+
999+ require.Equal(t, 1, activeTabIndex(sess))
1000+ awaitFrame(t, sends, ports.MsgOutput)
1001+ d.handleInput(sess, ac, []byte("\x1bt"))
1002+ awaitFrame(t, sends, ports.MsgOutput)
1003+ d.handleInput(sess, ac, []byte("\x1b"))
1004+ require.False(t, ac.pickerActive())
1005+ awaitFrame(t, sends, ports.MsgOutput)
1006+}
1007+
1008+func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
1009+ p1, releasePTY1 := newBlockingPTY(t)
1010+ p2, releasePTY2 := newBlockingPTY(t)
1011+ defer releasePTY1()
1012+ defer releasePTY2()
1013+ d := newTestDaemon(t, nil, stubClock{})
1014+ tr1, sends1 := newCapturingTransport(t)
1015+ tr2, sends2 := newCapturingTransport(t)
1016+ ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1017+ ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1018+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
1019+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
1020+ defer cancel1()
1021+ defer cancel2()
1022+ sess1 := &session{id: "s1", name: "alpha", ephemeral: true, ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
1023+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
1024+ ac1.setSession(sess1)
1025+ ac2.setSession(sess2)
1026+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
1027+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
1028+ d.sessions[sess1.id] = sess1
1029+ d.sessions[sess2.id] = sess2
1030+
1031+ d.handleInput(sess1, ac1, []byte("\x1bt"))
1032+ awaitFrame(t, sends1, ports.MsgOutput)
1033+ d.handleInput(sess1, ac1, []byte("j"))
1034+ awaitFrame(t, sends1, ports.MsgOutput)
1035+ d.handleInput(sess1, ac1, []byte("\r"))
1036+
1037+ require.Same(t, sess2, ac1.currentSession())
1038+ require.Same(t, ac1, sess2.client)
1039+ require.Nil(t, sess1.client)
1040+ require.Equal(t, 2, sessionCount(d), "old ephemeral session remains alive after picker switch")
1041+ det := awaitFrame(t, sends2, ports.MsgDetached)
1042+ dm, err := ports.UnmarshalDetached(det.Payload)
1043+ require.NoError(t, err)
1044+ require.Equal(t, ports.ReasonDetach, dm.Reason)
1045+ awaitFrame(t, sends1, ports.MsgOutput)
1046+}
1047+
1048+func TestPickerLivePreviewRepaintsInactiveTab(t *testing.T) {
1049+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
1050+ defer func() {
1051+ for _, release := range releases {
1052+ release()
1053+ }
1054+ }()
1055+
1056+ d.handleInput(sess, ac, []byte("\x1bt"))
1057+ awaitFrame(t, sends, ports.MsgOutput)
1058+ d.handleInput(sess, ac, []byte("j"))
1059+ awaitFrame(t, sends, ports.MsgOutput)
1060+
1061+ previewTab := sess.tabs[1]
1062+ previewTab.mu.Lock()
1063+ previewTab.screen.Write([]byte("inactive-preview-live"))
1064+ previewTab.mu.Unlock()
1065+ d.render(sess, previewTab)
1066+
1067+ previewOut := awaitFrame(t, sends, ports.MsgOutput)
1068+ previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
1069+ require.NoError(t, err)
1070+ require.Contains(t, string(previewMsg.Data), "inactive-preview-live")
1071+}
1072+
1073+func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
1074+ p1, releasePTY1 := newBlockingPTY(t)
1075+ p2, releasePTY2 := newBlockingPTY(t)
1076+ defer releasePTY1()
1077+ defer releasePTY2()
1078+ d := newTestDaemon(t, nil, stubClock{})
1079+ tr1, sends1 := newCapturingTransport(t)
1080+ tr2, _ := newCapturingTransport(t)
1081+ ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1082+ ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}}
1083+ sctx1, cancel1 := context.WithCancel(d.serveCtx)
1084+ sctx2, cancel2 := context.WithCancel(d.serveCtx)
1085+ defer cancel1()
1086+ defer cancel2()
1087+ sess1 := &session{id: "s1", name: "alpha", ctx: sctx1, cancel: cancel1, tabs: []*tab{{pty: p1, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx1, cancel: cancel1}}, client: ac1}
1088+ sess2 := &session{id: "s2", name: "beta", ctx: sctx2, cancel: cancel2, tabs: []*tab{{pty: p2, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}, ctx: sctx2, cancel: cancel2}}, client: ac2}
1089+ ac1.setSession(sess1)
1090+ ac2.setSession(sess2)
1091+ ac1.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac1})
1092+ ac2.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac2})
1093+ d.sessions[sess1.id] = sess1
1094+ d.sessions[sess2.id] = sess2
1095+
1096+ d.handleInput(sess1, ac1, []byte("\x1bt"))
1097+ awaitFrame(t, sends1, ports.MsgOutput)
1098+ d.handleInput(sess1, ac1, []byte("j"))
1099+ awaitFrame(t, sends1, ports.MsgOutput)
1100+
1101+ previewTab := sess2.tabs[0]
1102+ previewTab.mu.Lock()
1103+ previewTab.screen.Write([]byte("cross-session-preview-live"))
1104+ previewTab.mu.Unlock()
1105+ d.render(sess2, previewTab)
1106+
1107+ previewOut := awaitFrame(t, sends1, ports.MsgOutput)
1108+ previewMsg, err := ports.UnmarshalOutput(previewOut.Payload)
1109+ require.NoError(t, err)
1110+ require.Contains(t, string(previewMsg.Data), "cross-session-preview-live")
1111+}
1112+
1113+func TestPickerOpenCloseNavigationConcurrentWithRenderRace(t *testing.T) {
1114+ d, sess, ac, sends, releases := newManualTabSession(t, 3)
1115+ defer func() {
1116+ for _, release := range releases {
1117+ release()
1118+ }
1119+ }()
1120+
1121+ done := make(chan struct{})
1122+ var drain sync.WaitGroup
1123+ drain.Go(func() {
1124+ for {
1125+ select {
1126+ case <-sends:
1127+ case <-done:
1128+ return
1129+ }
1130+ }
1131+ })
1132+
1133+ var wg sync.WaitGroup
1134+ for i := range 100 {
1135+ wg.Go(func() {
1136+ d.enterPicker(sess, ac)
1137+ })
1138+ wg.Go(func() {
1139+ d.handlePickerInput(sess, ac, []byte("j"))
1140+ d.handlePickerInput(sess, ac, []byte("k"))
1141+ })
1142+ wg.Go(func() {
1143+ d.closePicker(ac)
1144+ })
1145+ tb := sess.tabs[i%len(sess.tabs)]
1146+ wg.Go(func() {
1147+ tb.mu.Lock()
1148+ tb.screen.Write([]byte("render-race"))
1149+ tb.mu.Unlock()
1150+ d.render(sess, tb)
1151+ })
1152+ }
1153+ wg.Wait()
1154+ close(done)
1155+ drain.Wait()
1156+}
1157+
1158+func TestPickerResizeRecomposesModal(t *testing.T) {
1159+ p, releasePTY := newBlockingPTY(t)
1160+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1161+ defer releasePTY()
1162+
1163+ d.handleInput(sess, ac, []byte("\x1bt"))
1164+ awaitFrame(t, sends, ports.MsgOutput)
1165+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
1166+
1167+ out := awaitFrame(t, sends, ports.MsgOutput)
1168+ msg, err := ports.UnmarshalOutput(out.Payload)
1169+ require.NoError(t, err)
1170+ require.Contains(t, string(msg.Data), "┌")
1171+ require.Contains(t, string(msg.Data), "Sessions")
1172+}
1173+
961 func TestStatusCompositionGolden(t *testing.T) {1174 func TestStatusCompositionGolden(t *testing.T) {
962 p1, releasePTY1 := newBlockingPTY(t)1175 p1, releasePTY1 := newBlockingPTY(t)
963 p2, releasePTY2 := newBlockingPTY(t)1176 p2, releasePTY2 := newBlockingPTY(t)
@@ -967,7 +1180,7 @@ func TestStatusCompositionGolden(t *testing.T) {
967 sess.active = 11180 sess.active = 1
968 sess.name = "work"1181 sess.name = "work"
969 1182
970- win := sess.activeWindow()1183+ win := sess.activeTab()
971 win.screen = vt.NewScreen(12, 2)1184 win.screen = vt.NewScreen(12, 2)
972 win.size = domain.Size{Cols: 12, Rows: 2}1185 win.size = domain.Size{Cols: 12, Rows: 2}
973 win.screen.Write([]byte("hello"))1186 win.screen.Write([]byte("hello"))
@@ -981,7 +1194,7 @@ func TestStatusCompositionGolden(t *testing.T) {
981 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)1194 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
982 for i, c := range frame.Row(2) {1195 for i, c := range frame.Row(2) {
983 if i >= len(" work 1 ") && i < len(" work 1 2 ") {1196 if i >= len(" work 1 ") && i < len(" work 1 2 ") {
984- require.True(t, c.Style.Inverse, "active window segment cell %d should be inverse", i)1197+ require.True(t, c.Style.Inverse, "active tab segment cell %d should be inverse", i)
985 }1198 }
986 }1199 }
987 }1200 }
@@ -1009,7 +1222,7 @@ func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
1009 out, err := ports.UnmarshalOutput(f.Payload)1222 out, err := ports.UnmarshalOutput(f.Payload)
1010 require.NoError(t, err)1223 require.NoError(t, err)
1011 require.Contains(t, string(out.Data), "work")1224 require.Contains(t, string(out.Data), "work")
1012- require.Contains(t, string(out.Data), ";7m", "active status window should be inverse-highlighted")1225+ require.Contains(t, string(out.Data), ";7m", "active status tab should be inverse-highlighted")
1013 }1226 }
1014 1227
1015 releaseConn()1228 releaseConn()
@@ -1147,9 +1360,10 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
1147 1360
1148 d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))1361 d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
1149 sctx, cancel := context.WithCancel(context.Background())1362 sctx, cancel := context.WithCancel(context.Background())
1150- win := &window{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}1363+ win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
1151 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}1364 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1152- sess := &session{id: "s", name: "s", windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac}1365+ sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1366+ ac.setSession(sess)
1153 1367
1154 win.mu.Lock()1368 win.mu.Lock()
1155 win.screen.Write([]byte("hi"))1369 win.screen.Write([]byte("hi"))
@@ -1176,7 +1390,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) {
1176 1390
1177 cancel()1391 cancel()
1178 d.sessWg.Wait()1392 d.sessWg.Wait()
1179- require.Equal(t, int32(1), outputs.Load(), "N dirties in one window must render exactly once")1393+ require.Equal(t, int32(1), outputs.Load(), "N dirties in one tab must render exactly once")
1180 }1394 }
1181 1395
1182 func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) {1396 func TestSchedulerAdaptiveDebounceFloodAndIdle(t *testing.T) {
@@ -1199,7 +1413,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
1199 1413
1200 p := portsmocks.NewMockPTY(t)1414 p := portsmocks.NewMockPTY(t)
1201 var screenWidthAtResize int1415 var screenWidthAtResize int
1202- win := &window{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}1416+ win := &tab{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
1203 p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error {1417 p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error {
1204 // The screen must not yet be resized when the PTY is: proves order.1418 // The screen must not yet be resized when the PTY is: proves order.
1205 screenWidthAtResize = win.screen.Frame.Width1419 screenWidthAtResize = win.screen.Frame.Width
@@ -1218,7 +1432,8 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
1218 1432
1219 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))1433 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
1220 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}1434 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1221- sess := &session{id: "s", name: "s", windows: []*window{win}, client: ac}1435+ sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
1436+ ac.setSession(sess)
1222 1437
1223 d.resize(sess, ac, newSize)1438 d.resize(sess, ac, newSize)
1224 1439
@@ -1509,10 +1724,11 @@ func TestSendErrorKillsEphemeral(t *testing.T) {
1509 tr.EXPECT().Close().Return(nil).Maybe()1724 tr.EXPECT().Close().Return(nil).Maybe()
1510 1725
1511 d := newTestDaemon(t, nil, stubClock{})1726 d := newTestDaemon(t, nil, stubClock{})
1512- win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}1727+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}}
1513 sctx, cancel := context.WithCancel(context.Background())1728 sctx, cancel := context.WithCancel(context.Background())
1514 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}1729 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1515- sess := &session{id: "e", name: "0", ephemeral: true, windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac}1730+ sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1731+ ac.setSession(sess)
1516 d.sessions[sess.id] = sess1732 d.sessions[sess.id] = sess
1517 1733
1518 win.mu.Lock()1734 win.mu.Lock()
@@ -1550,9 +1766,10 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T)
1550 1766
1551 d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))1767 d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
1552 sctx, cancel := context.WithCancel(context.Background())1768 sctx, cancel := context.WithCancel(context.Background())
1553- win := &window{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}1769+ win := &tab{screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1554 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}1770 ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1555- sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: ac}1771+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1772+ ac.setSession(sess)
1556 1773
1557 win.mu.Lock()1774 win.mu.Lock()
1558 win.screen.Write([]byte("before"))1775 win.screen.Write([]byte("before"))
@@ -1609,8 +1826,10 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
1609 1826
1610 tr, sends := newCapturingTransport(t)1827 tr, sends := newCapturingTransport(t)
1611 sctx, cancel := context.WithCancel(context.Background())1828 sctx, cancel := context.WithCancel(context.Background())
1612- win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}1829+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1613- sess := &session{id: "query", name: "query", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}}1830+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1831+ sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1832+ ac.setSession(sess)
1614 d.sessions[sess.id] = sess1833 d.sessions[sess.id] = sess
1615 d.sessWg.Add(1)1834 d.sessWg.Add(1)
1616 d.ptyReader(sess, win)1835 d.ptyReader(sess, win)
@@ -1643,8 +1862,8 @@ func TestPTYReaderSameReadSynchronizedUpdateStartEndFlushesImmediately(t *testin
1643 p.EXPECT().Close().Return(nil).Maybe()1862 p.EXPECT().Close().Return(nil).Maybe()
1644 1863
1645 sctx, cancel := context.WithCancel(context.Background())1864 sctx, cancel := context.WithCancel(context.Background())
1646- win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}1865+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1647- sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel}1866+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
1648 d.sessions[sess.id] = sess1867 d.sessions[sess.id] = sess
1649 d.sessWg.Add(1)1868 d.sessWg.Add(1)
1650 d.ptyReader(sess, win)1869 d.ptyReader(sess, win)
@@ -1676,8 +1895,8 @@ func TestPTYReaderDefersDirtyDuringSynchronizedUpdateAndFlushesAtEnd(t *testing.
1676 p.EXPECT().Close().Return(nil).Maybe()1895 p.EXPECT().Close().Return(nil).Maybe()
1677 1896
1678 sctx, cancel := context.WithCancel(context.Background())1897 sctx, cancel := context.WithCancel(context.Background())
1679- win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}1898+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1680- sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel}1899+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel}
1681 d.sessions[sess.id] = sess1900 d.sessions[sess.id] = sess
1682 d.sessWg.Add(1)1901 d.sessWg.Add(1)
1683 d.ptyReader(sess, win)1902 d.ptyReader(sess, win)
@@ -1712,8 +1931,10 @@ func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
1712 1931
1713 tr, sends := newCapturingTransport(t)1932 tr, sends := newCapturingTransport(t)
1714 sctx, cancel := context.WithCancel(context.Background())1933 sctx, cancel := context.WithCancel(context.Background())
1715- win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}1934+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1716- sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}}1935+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1936+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1937+ ac.setSession(sess)
1717 d.sessions[sess.id] = sess1938 d.sessions[sess.id] = sess
1718 d.sessWg.Add(2)1939 d.sessWg.Add(2)
1719 go d.scheduler(sess, win)1940 go d.scheduler(sess, win)
@@ -1754,8 +1975,10 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
1754 1975
1755 tr, sends := newCapturingTransport(t)1976 tr, sends := newCapturingTransport(t)
1756 sctx, cancel := context.WithCancel(context.Background())1977 sctx, cancel := context.WithCancel(context.Background())
1757- win := &window{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}1978+ win := &tab{pty: p, screen: vt.NewScreen(20, 5), dirty: make(chan struct{}, 1), flush: make(chan struct{}, 1), size: domain.Size{Cols: 20, Rows: 5}, ctx: sctx, cancel: cancel}
1758- sess := &session{id: "sync", name: "sync", windows: []*window{win}, ctx: sctx, cancel: cancel, client: &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}}1979+ ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1980+ sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
1981+ ac.setSession(sess)
1759 d.sessions[sess.id] = sess1982 d.sessions[sess.id] = sess
1760 d.sessWg.Add(2)1983 d.sessWg.Add(2)
1761 go d.scheduler(sess, win)1984 go d.scheduler(sess, win)
@@ -1794,7 +2017,7 @@ func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) {
1794 writes := make(chan []byte, 4)2017 writes := make(chan []byte, 4)
1795 p, _ := newBlockingPTYWithWrites(t, writes)2018 p, _ := newBlockingPTYWithWrites(t, writes)
1796 d, sess, ac, sends := newManualSessionWithPTYs(t, p)2019 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1797- win := sess.windows[0]2020+ win := sess.tabs[0]
1798 win.screen.Write([]byte("live"))2021 win.screen.Write([]byte("live"))
1799 2022
1800 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))2023 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
@@ -1821,7 +2044,7 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) {
1821 writes := make(chan []byte, 2)2044 writes := make(chan []byte, 2)
1822 p, _ := newBlockingPTYWithWrites(t, writes)2045 p, _ := newBlockingPTYWithWrites(t, writes)
1823 d, sess, ac, _ := newManualSessionWithPTYs(t, p)2046 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
1824- sess.windows[0].screen.Write([]byte("\x1b[?1049h"))2047+ sess.tabs[0].screen.Write([]byte("\x1b[?1049h"))
1825 2048
1826 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))2049 d.handleInput(sess, ac, []byte("\x1b[<64;1;1M"))
1827 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))2050 d.handleInput(sess, ac, []byte("\x1b[<65;1;1M"))
@@ -1843,7 +2066,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
1843 default:2066 default:
1844 }2067 }
1845 2068
1846- sess.windows[0].screen.Write([]byte("\x1b[?1000h"))2069+ sess.tabs[0].screen.Write([]byte("\x1b[?1000h"))
1847 raw := []byte("\x1b[<0;2;3M")2070 raw := []byte("\x1b[<0;2;3M")
1848 d.handleInput(sess, ac, raw)2071 d.handleInput(sess, ac, raw)
1849 select {2072 select {
@@ -1852,7 +2075,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) {
1852 default:2075 default:
1853 }2076 }
1854 2077
1855- sess.windows[0].screen.Write([]byte("\x1b[?1006h"))2078+ sess.tabs[0].screen.Write([]byte("\x1b[?1006h"))
1856 d.handleInput(sess, ac, raw)2079 d.handleInput(sess, ac, raw)
1857 require.Equal(t, raw, <-writes)2080 require.Equal(t, raw, <-writes)
1858 2081
@@ -1868,7 +2091,7 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
1868 writes := make(chan []byte, 2)2091 writes := make(chan []byte, 2)
1869 p, _ := newBlockingPTYWithWrites(t, writes)2092 p, _ := newBlockingPTYWithWrites(t, writes)
1870 d, sess, ac, sends := newManualSessionWithPTYs(t, p)2093 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1871- sess.windows[0].screen.Write([]byte("live"))2094+ sess.tabs[0].screen.Write([]byte("live"))
1872 2095
1873 d.handleInput(sess, ac, []byte("\x1b[<64;"))2096 d.handleInput(sess, ac, []byte("\x1b[<64;"))
1874 d.handleInput(sess, ac, []byte("1;1Mq"))2097 d.handleInput(sess, ac, []byte("1;1Mq"))
@@ -1886,7 +2109,7 @@ func TestMouseSplitReportPreservesOrder(t *testing.T) {
1886 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {2109 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
1887 p, _ := newBlockingPTY(t)2110 p, _ := newBlockingPTY(t)
1888 d, sess, ac, sends := newManualSessionWithPTYs(t, p)2111 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1889- win := sess.windows[0]2112+ win := sess.tabs[0]
1890 win.screen.Write([]byte("A"))2113 win.screen.Write([]byte("A"))
1891 2114
1892 d.paint(sess, ac, true)2115 d.paint(sess, ac, true)
@@ -1909,7 +2132,7 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
1909 func TestCursorTailHidesInCopyMode(t *testing.T) {2132 func TestCursorTailHidesInCopyMode(t *testing.T) {
1910 p, _ := newBlockingPTY(t)2133 p, _ := newBlockingPTY(t)
1911 d, sess, ac, sends := newManualSessionWithPTYs(t, p)2134 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
1912- sess.windows[0].screen.Write([]byte("live"))2135+ sess.tabs[0].screen.Write([]byte("live"))
1913 2136
1914 d.enterCopyMode(sess, ac)2137 d.enterCopyMode(sess, ac)
1915 data := mustOutputData(t, sends)2138 data := mustOutputData(t, sends)
M internal/usecase/daemon/scrollback_test.go +3 -3
@@ -6,7 +6,7 @@ import (
66 "github.com/bnema/vev/internal/domain"
77 )
88
9-func TestNewWindowInitializesScrollback(t *testing.T) {
9+func TestNewTabInitializesScrollback(t *testing.T) {
1010 tests := []struct {
1111 name string
1212 write []byte
@@ -14,7 +14,7 @@ func TestNewWindowInitializesScrollback(t *testing.T) {
1414 wantRow string
1515 }{
1616 {
17- name: "window owns scrollback wired to vt eviction callback",
17+ name: "tab owns scrollback wired to vt eviction callback",
1818 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
1919 wantLen: 1,
2020 wantRow: "AAAA",
@@ -23,7 +23,7 @@ func TestNewWindowInitializesScrollback(t *testing.T) {
2323
2424 for _, tt := range tests {
2525 t.Run(tt.name, func(t *testing.T) {
26- win := newWindow(nil, domain.Size{Cols: 4, Rows: 3})
26+ win := newTab(nil, domain.Size{Cols: 4, Rows: 3})
2727 if win.scrollback == nil {
2828 t.Fatal("scrollback is nil")
2929 }
@@ -6,7 +6,7 @@ import (
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 )7 )
8 8
9-func TestNewWindowInitializesScrollback(t *testing.T) {9+func TestNewTabInitializesScrollback(t *testing.T) {
10 tests := []struct {10 tests := []struct {
11 name string11 name string
12 write []byte12 write []byte
@@ -14,7 +14,7 @@ func TestNewWindowInitializesScrollback(t *testing.T) {
14 wantRow string14 wantRow string
15 }{15 }{
16 {16 {
17- name: "window owns scrollback wired to vt eviction callback",17+ name: "tab owns scrollback wired to vt eviction callback",
18 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),18 write: []byte("AAAA\r\nBBBB\r\nCCCC\r\n"),
19 wantLen: 1,19 wantLen: 1,
20 wantRow: "AAAA",20 wantRow: "AAAA",
@@ -23,7 +23,7 @@ func TestNewWindowInitializesScrollback(t *testing.T) {
23 23
24 for _, tt := range tests {24 for _, tt := range tests {
25 t.Run(tt.name, func(t *testing.T) {25 t.Run(tt.name, func(t *testing.T) {
26- win := newWindow(nil, domain.Size{Cols: 4, Rows: 3})26+ win := newTab(nil, domain.Size{Cols: 4, Rows: 3})
27 if win.scrollback == nil {27 if win.scrollback == nil {
28 t.Fatal("scrollback is nil")28 t.Fatal("scrollback is nil")
29 }29 }
M internal/usecase/keys/router.go +21 -18
@@ -18,22 +18,23 @@ const (
1818 type Action int
1919
2020 const (
21- ActionCreateWindow Action = iota
22- ActionNextWindow
23- ActionPreviousWindow
21+ ActionCreateTab Action = iota
22+ ActionNextTab
23+ ActionPreviousTab
2424 ActionDetach
25- ActionCloseWindow
25+ ActionCloseTab
2626 ActionCopyMode
2727 ActionRenameSession
28- ActionSwitchWindow1
29- ActionSwitchWindow2
30- ActionSwitchWindow3
31- ActionSwitchWindow4
32- ActionSwitchWindow5
33- ActionSwitchWindow6
34- ActionSwitchWindow7
35- ActionSwitchWindow8
36- ActionSwitchWindow9
28+ ActionOpenPicker
29+ ActionSwitchTab1
30+ ActionSwitchTab2
31+ ActionSwitchTab3
32+ ActionSwitchTab4
33+ ActionSwitchTab5
34+ ActionSwitchTab6
35+ ActionSwitchTab7
36+ ActionSwitchTab8
37+ ActionSwitchTab9
3738 )
3839
3940 // Handler receives router outputs. Forward is called only for bytes that should
@@ -170,21 +171,23 @@ func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
170171 func binding(b byte) (Action, bool) {
171172 switch b {
172173 case 'c':
173- return ActionCreateWindow, true
174+ return ActionCreateTab, true
174175 case 'n':
175- return ActionNextWindow, true
176+ return ActionNextTab, true
176177 case 'p':
177- return ActionPreviousWindow, true
178+ return ActionPreviousTab, true
178179 case 'd':
179180 return ActionDetach, true
180181 case 'x':
181- return ActionCloseWindow, true
182+ return ActionCloseTab, true
182183 case 'u':
183184 return ActionCopyMode, true
184185 case 'r':
185186 return ActionRenameSession, true
187+ case 't':
188+ return ActionOpenPicker, true
186189 case '1', '2', '3', '4', '5', '6', '7', '8', '9':
187- return ActionSwitchWindow1 + Action(b-'1'), true
190+ return ActionSwitchTab1 + Action(b-'1'), true
188191 default:
189192 return 0, false
190193 }
@@ -18,22 +18,23 @@ const (
18 type Action int18 type Action int
19 19
20 const (20 const (
21- ActionCreateWindow Action = iota21+ ActionCreateTab Action = iota
22- ActionNextWindow22+ ActionNextTab
23- ActionPreviousWindow23+ ActionPreviousTab
24 ActionDetach24 ActionDetach
25- ActionCloseWindow25+ ActionCloseTab
26 ActionCopyMode26 ActionCopyMode
27 ActionRenameSession27 ActionRenameSession
28- ActionSwitchWindow128+ ActionOpenPicker
29- ActionSwitchWindow229+ ActionSwitchTab1
30- ActionSwitchWindow330+ ActionSwitchTab2
31- ActionSwitchWindow431+ ActionSwitchTab3
32- ActionSwitchWindow532+ ActionSwitchTab4
33- ActionSwitchWindow633+ ActionSwitchTab5
34- ActionSwitchWindow734+ ActionSwitchTab6
35- ActionSwitchWindow835+ ActionSwitchTab7
36- ActionSwitchWindow936+ ActionSwitchTab8
37+ ActionSwitchTab9
37 )38 )
38 39
39 // Handler receives router outputs. Forward is called only for bytes that should40 // Handler receives router outputs. Forward is called only for bytes that should
@@ -170,21 +171,23 @@ func passThroughPrefix(b byte) bool { return b == '[' || b == 'O' }
170 func binding(b byte) (Action, bool) {171 func binding(b byte) (Action, bool) {
171 switch b {172 switch b {
172 case 'c':173 case 'c':
173- return ActionCreateWindow, true174+ return ActionCreateTab, true
174 case 'n':175 case 'n':
175- return ActionNextWindow, true176+ return ActionNextTab, true
176 case 'p':177 case 'p':
177- return ActionPreviousWindow, true178+ return ActionPreviousTab, true
178 case 'd':179 case 'd':
179 return ActionDetach, true180 return ActionDetach, true
180 case 'x':181 case 'x':
181- return ActionCloseWindow, true182+ return ActionCloseTab, true
182 case 'u':183 case 'u':
183 return ActionCopyMode, true184 return ActionCopyMode, true
184 case 'r':185 case 'r':
185 return ActionRenameSession, true186 return ActionRenameSession, true
187+ case 't':
188+ return ActionOpenPicker, true
186 case '1', '2', '3', '4', '5', '6', '7', '8', '9':189 case '1', '2', '3', '4', '5', '6', '7', '8', '9':
187- return ActionSwitchWindow1 + Action(b-'1'), true190+ return ActionSwitchTab1 + Action(b-'1'), true
188 default:191 default:
189 return 0, false192 return 0, false
190 }193 }
M internal/usecase/keys/router_test.go +35 -11
@@ -51,13 +51,14 @@ func TestRouterInterceptsSameReadAltBindings(t *testing.T) {
5151 in []byte
5252 want Action
5353 }{
54- {"Alt+c", []byte{ESC, 'c'}, ActionCreateWindow},
55- {"Alt+n", []byte{ESC, 'n'}, ActionNextWindow},
56- {"Alt+p", []byte{ESC, 'p'}, ActionPreviousWindow},
54+ {"Alt+c", []byte{ESC, 'c'}, ActionCreateTab},
55+ {"Alt+n", []byte{ESC, 'n'}, ActionNextTab},
56+ {"Alt+p", []byte{ESC, 'p'}, ActionPreviousTab},
5757 {"Alt+d", []byte{ESC, 'd'}, ActionDetach},
58- {"Alt+x", []byte{ESC, 'x'}, ActionCloseWindow},
58+ {"Alt+x", []byte{ESC, 'x'}, ActionCloseTab},
5959 {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode},
6060 {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession},
61+ {"Alt+t", []byte{ESC, 't'}, ActionOpenPicker},
6162 }
6263 for _, tc := range cases {
6364 t.Run(tc.name, func(t *testing.T) {
@@ -79,9 +80,9 @@ func TestRouterInterceptsAltDigits(t *testing.T) {
7980 r.Route([]byte{ESC, b})
8081 }
8182 require.Equal(t, []Action{
82- ActionSwitchWindow1, ActionSwitchWindow2, ActionSwitchWindow3,
83- ActionSwitchWindow4, ActionSwitchWindow5, ActionSwitchWindow6,
84- ActionSwitchWindow7, ActionSwitchWindow8, ActionSwitchWindow9,
83+ ActionSwitchTab1, ActionSwitchTab2, ActionSwitchTab3,
84+ ActionSwitchTab4, ActionSwitchTab5, ActionSwitchTab6,
85+ ActionSwitchTab7, ActionSwitchTab8, ActionSwitchTab9,
8586 }, h.actions)
8687 require.Empty(t, h.forwards)
8788 }
@@ -90,7 +91,7 @@ func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {
9091 clk := &fakeClock{}
9192 h := &captureHandler{}
9293 NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'})
93- require.Equal(t, []Action{ActionCreateWindow}, h.actions)
94+ require.Equal(t, []Action{ActionCreateTab}, h.actions)
9495 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)
9596 }
9697
@@ -117,10 +118,33 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
117118
118119 r.Route([]byte{'n'})
119120 require.True(t, clk.timers[0].stopped)
120- require.Equal(t, []Action{ActionNextWindow}, h.actions)
121+ require.Equal(t, []Action{ActionNextTab}, h.actions)
121122 require.Empty(t, h.forwards)
122123 }
123124
125+func TestRouterRetainsSplitESCAndInterceptsPickerBinding(t *testing.T) {
126+ clk := &fakeClock{}
127+ h := &captureHandler{}
128+ r := NewRouter(clk, h)
129+ r.Route([]byte{ESC})
130+ require.Len(t, clk.timers, 1)
131+ require.Equal(t, ESCDelay, clk.timers[0].d)
132+ require.Empty(t, h.forwards)
133+
134+ r.Route([]byte{'t'})
135+ require.True(t, clk.timers[0].stopped)
136+ require.Equal(t, []Action{ActionOpenPicker}, h.actions)
137+ require.Empty(t, h.forwards)
138+}
139+
140+func TestRouterForwardsBareT(t *testing.T) {
141+ clk := &fakeClock{}
142+ h := &captureHandler{}
143+ NewRouter(clk, h).Route([]byte{'t'})
144+ require.Empty(t, h.actions)
145+ require.Equal(t, [][]byte{[]byte("t")}, h.forwards)
146+}
147+
124148 func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
125149 cases := []struct {
126150 name string
@@ -159,7 +183,7 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
159183
160184 require.True(t, clk.timers[0].stopped)
161185 require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards)
162- require.Equal(t, []Action{ActionCreateWindow}, h.actions)
186+ require.Equal(t, []Action{ActionCreateTab}, h.actions)
163187 }
164188
165189 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
@@ -174,7 +198,7 @@ func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
174198 r.Route([]byte{'n'})
175199 require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond)
176200 require.True(t, clk.timers[0].stopped)
177- require.Equal(t, []Action{ActionNextWindow}, h.actions)
201+ require.Equal(t, []Action{ActionNextTab}, h.actions)
178202 require.Empty(t, h.forwards)
179203 }
180204
@@ -51,13 +51,14 @@ func TestRouterInterceptsSameReadAltBindings(t *testing.T) {
51 in []byte51 in []byte
52 want Action52 want Action
53 }{53 }{
54- {"Alt+c", []byte{ESC, 'c'}, ActionCreateWindow},54+ {"Alt+c", []byte{ESC, 'c'}, ActionCreateTab},
55- {"Alt+n", []byte{ESC, 'n'}, ActionNextWindow},55+ {"Alt+n", []byte{ESC, 'n'}, ActionNextTab},
56- {"Alt+p", []byte{ESC, 'p'}, ActionPreviousWindow},56+ {"Alt+p", []byte{ESC, 'p'}, ActionPreviousTab},
57 {"Alt+d", []byte{ESC, 'd'}, ActionDetach},57 {"Alt+d", []byte{ESC, 'd'}, ActionDetach},
58- {"Alt+x", []byte{ESC, 'x'}, ActionCloseWindow},58+ {"Alt+x", []byte{ESC, 'x'}, ActionCloseTab},
59 {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode},59 {"Alt+u", []byte{ESC, 'u'}, ActionCopyMode},
60 {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession},60 {"Alt+r", []byte{ESC, 'r'}, ActionRenameSession},
61+ {"Alt+t", []byte{ESC, 't'}, ActionOpenPicker},
61 }62 }
62 for _, tc := range cases {63 for _, tc := range cases {
63 t.Run(tc.name, func(t *testing.T) {64 t.Run(tc.name, func(t *testing.T) {
@@ -79,9 +80,9 @@ func TestRouterInterceptsAltDigits(t *testing.T) {
79 r.Route([]byte{ESC, b})80 r.Route([]byte{ESC, b})
80 }81 }
81 require.Equal(t, []Action{82 require.Equal(t, []Action{
82- ActionSwitchWindow1, ActionSwitchWindow2, ActionSwitchWindow3,83+ ActionSwitchTab1, ActionSwitchTab2, ActionSwitchTab3,
83- ActionSwitchWindow4, ActionSwitchWindow5, ActionSwitchWindow6,84+ ActionSwitchTab4, ActionSwitchTab5, ActionSwitchTab6,
84- ActionSwitchWindow7, ActionSwitchWindow8, ActionSwitchWindow9,85+ ActionSwitchTab7, ActionSwitchTab8, ActionSwitchTab9,
85 }, h.actions)86 }, h.actions)
86 require.Empty(t, h.forwards)87 require.Empty(t, h.forwards)
87 }88 }
@@ -90,7 +91,7 @@ func TestRouterForwardsOnlyNonInterceptedBytes(t *testing.T) {
90 clk := &fakeClock{}91 clk := &fakeClock{}
91 h := &captureHandler{}92 h := &captureHandler{}
92 NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'})93 NewRouter(clk, h).Route([]byte{'a', ESC, 'c', 'b'})
93- require.Equal(t, []Action{ActionCreateWindow}, h.actions)94+ require.Equal(t, []Action{ActionCreateTab}, h.actions)
94 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)95 require.Equal(t, [][]byte{[]byte("a"), []byte("b")}, h.forwards)
95 }96 }
96 97
@@ -117,10 +118,33 @@ func TestRouterRetainsSplitESCAndInterceptsNextBoundByte(t *testing.T) {
117 118
118 r.Route([]byte{'n'})119 r.Route([]byte{'n'})
119 require.True(t, clk.timers[0].stopped)120 require.True(t, clk.timers[0].stopped)
120- require.Equal(t, []Action{ActionNextWindow}, h.actions)121+ require.Equal(t, []Action{ActionNextTab}, h.actions)
121 require.Empty(t, h.forwards)122 require.Empty(t, h.forwards)
122 }123 }
123 124
125+func TestRouterRetainsSplitESCAndInterceptsPickerBinding(t *testing.T) {
126+ clk := &fakeClock{}
127+ h := &captureHandler{}
128+ r := NewRouter(clk, h)
129+ r.Route([]byte{ESC})
130+ require.Len(t, clk.timers, 1)
131+ require.Equal(t, ESCDelay, clk.timers[0].d)
132+ require.Empty(t, h.forwards)
133+
134+ r.Route([]byte{'t'})
135+ require.True(t, clk.timers[0].stopped)
136+ require.Equal(t, []Action{ActionOpenPicker}, h.actions)
137+ require.Empty(t, h.forwards)
138+}
139+
140+func TestRouterForwardsBareT(t *testing.T) {
141+ clk := &fakeClock{}
142+ h := &captureHandler{}
143+ NewRouter(clk, h).Route([]byte{'t'})
144+ require.Empty(t, h.actions)
145+ require.Equal(t, [][]byte{[]byte("t")}, h.forwards)
146+}
147+
124 func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {148 func TestRouterForwardsCSIAfterRetainedESCAcrossReads(t *testing.T) {
125 cases := []struct {149 cases := []struct {
126 name string150 name string
@@ -159,7 +183,7 @@ func TestRouterRoutesBytesAfterRetainedEscapePrefix(t *testing.T) {
159 183
160 require.True(t, clk.timers[0].stopped)184 require.True(t, clk.timers[0].stopped)
161 require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards)185 require.Equal(t, [][]byte{{ESC, '['}, []byte("A")}, h.forwards)
162- require.Equal(t, []Action{ActionCreateWindow}, h.actions)186+ require.Equal(t, []Action{ActionCreateTab}, h.actions)
163 }187 }
164 188
165 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {189 func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
@@ -174,7 +198,7 @@ func TestRouterCancelsPendingESCWaiterWhenNextReadConsumesESC(t *testing.T) {
174 r.Route([]byte{'n'})198 r.Route([]byte{'n'})
175 require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond)199 require.Eventually(t, func() bool { return retainESCWaiters() == before }, time.Second, time.Millisecond)
176 require.True(t, clk.timers[0].stopped)200 require.True(t, clk.timers[0].stopped)
177- require.Equal(t, []Action{ActionNextWindow}, h.actions)201+ require.Equal(t, []Action{ActionNextTab}, h.actions)
178 require.Empty(t, h.forwards)202 require.Empty(t, h.forwards)
179 }203 }
180 204
A internal/usecase/picker/model.go +229 -0
@@ -0,0 +1,229 @@
1+package picker
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+const (
9+ MinPreviewWidth = 24
10+ MinListWidth = 16
11+ MinPaneHeight = 4
12+ MinStackHeight = 12
13+)
14+
15+type SessionView struct {
16+ ID domain.SessionID
17+ Name string
18+ Tabs []string
19+ Active int
20+}
21+
22+type Target struct {
23+ Session domain.SessionID
24+ TabIndex int
25+}
26+
27+type Preview struct {
28+ Rows [][]renderer.Cell
29+ Width int
30+ Height int
31+}
32+
33+type Layout struct {
34+ List domain.Rect
35+ Preview domain.Rect
36+}
37+
38+type Model struct {
39+ rows []row
40+ selected int
41+}
42+
43+type row struct {
44+ header bool
45+ label string
46+ session domain.SessionID
47+ tabIndex int
48+}
49+
50+func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
51+ m := &Model{selected: -1}
52+ activeSelection := -1
53+ for _, session := range sessions {
54+ m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, tabIndex: -1})
55+ active := session.Active
56+ if active < 0 || active >= len(session.Tabs) {
57+ active = 0
58+ }
59+ for i, tab := range session.Tabs {
60+ idx := len(m.rows)
61+ m.rows = append(m.rows, row{label: tab, session: session.ID, tabIndex: i})
62+ if session.ID == cur && i == curTab {
63+ m.selected = idx
64+ }
65+ if activeSelection < 0 && i == active {
66+ activeSelection = idx
67+ }
68+ }
69+ }
70+ if m.selected < 0 {
71+ m.selected = activeSelection
72+ }
73+ if m.selected < 0 {
74+ m.selected = m.firstLeaf()
75+ }
76+ if m.selected < 0 && len(m.rows) > 0 {
77+ m.selected = 0
78+ }
79+ return m
80+}
81+
82+func ChooseLayout(inner domain.Size) Layout {
83+ if inner.Cols <= 0 || inner.Rows <= 0 {
84+ return Layout{}
85+ }
86+ if inner.Cols >= 40 && inner.Rows >= MinPaneHeight {
87+ listWidth := max(MinListWidth, inner.Cols*30/100)
88+ previewWidth := inner.Cols - listWidth - 1
89+ return Layout{
90+ List: domain.Rect{Width: listWidth, Height: inner.Rows},
91+ Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},
92+ }
93+ }
94+ if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth {
95+ listHeight := max(MinPaneHeight, inner.Rows*40/100)
96+ previewHeight := inner.Rows - listHeight - 1
97+ return Layout{
98+ List: domain.Rect{Width: inner.Cols, Height: listHeight},
99+ Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight},
100+ }
101+ }
102+ return Layout{List: domain.Rect{Width: inner.Cols, Height: inner.Rows}}
103+}
104+
105+func (m *Model) Up() {
106+ m.move(-1)
107+}
108+
109+func (m *Model) Down() {
110+ m.move(1)
111+}
112+
113+func (m *Model) Selected() (Target, bool) {
114+ if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
115+ return Target{}, false
116+ }
117+ r := m.rows[m.selected]
118+ if r.header {
119+ return Target{}, false
120+ }
121+ return Target{Session: r.session, TabIndex: r.tabIndex}, true
122+}
123+
124+func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame {
125+ frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
126+ layout := ChooseLayout(inner)
127+ m.renderList(frame, layout.List)
128+ blitPreview(frame, layout.Preview, preview)
129+ return frame
130+}
131+
132+func (m *Model) move(delta int) {
133+ if m == nil || len(m.rows) == 0 {
134+ return
135+ }
136+ if m.selected < 0 || m.selected >= len(m.rows) || m.rows[m.selected].header {
137+ m.selected = m.firstLeaf()
138+ return
139+ }
140+ for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
141+ if !m.rows[i].header {
142+ m.selected = i
143+ return
144+ }
145+ }
146+}
147+
148+func (m *Model) firstLeaf() int {
149+ for i, r := range m.rows {
150+ if !r.header {
151+ return i
152+ }
153+ }
154+ return -1
155+}
156+
157+func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) {
158+ if m == nil || rect.Width <= 0 || rect.Height <= 0 {
159+ return
160+ }
161+ for y := range min(rect.Height, frame.Height-rect.Y) {
162+ idx := y
163+ if idx >= len(m.rows) {
164+ break
165+ }
166+ r := m.rows[idx]
167+ style := renderer.DefaultStyle()
168+ if idx == m.selected {
169+ style.Inverse = true
170+ }
171+ label := r.label
172+ if !r.header {
173+ label = " " + label
174+ }
175+ drawText(frame, rect.X, rect.Y+y, rect.Width, label, style)
176+ }
177+}
178+
179+func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) {
180+ if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 {
181+ return
182+ }
183+ limit := min(width, frame.Width-x)
184+ if limit <= 0 {
185+ return
186+ }
187+ row := frame.Row(y)
188+ for i := range limit {
189+ row[x+i] = renderer.Cell{Rune: ' ', Style: style}
190+ }
191+ col := 0
192+ for _, r := range text {
193+ if col >= limit {
194+ break
195+ }
196+ row[x+col] = renderer.Cell{Rune: r, Style: style}
197+ col++
198+ }
199+}
200+
201+func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) {
202+ if rect.Width <= 0 || rect.Height <= 0 {
203+ return
204+ }
205+ w := min(rect.Width, frame.Width-rect.X)
206+ h := min(rect.Height, frame.Height-rect.Y)
207+ if w <= 0 || h <= 0 {
208+ return
209+ }
210+ for y := range h {
211+ if y >= preview.Height || y >= len(preview.Rows) {
212+ continue
213+ }
214+ src := preview.Rows[y]
215+ for x := range w {
216+ if x >= preview.Width || x >= len(src) {
217+ continue
218+ }
219+ cell := src[x]
220+ if cell.Continuation && x == 0 {
221+ continue
222+ }
223+ if !cell.Continuation && x == w-1 && x+1 < len(src) && src[x+1].Continuation {
224+ continue
225+ }
226+ frame.Set(rect.X+x, rect.Y+y, cell)
227+ }
228+ }
229+}
@@ -0,0 +1,229 @@
1+package picker
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+const (
9+ MinPreviewWidth = 24
10+ MinListWidth = 16
11+ MinPaneHeight = 4
12+ MinStackHeight = 12
13+)
14+
15+type SessionView struct {
16+ ID domain.SessionID
17+ Name string
18+ Tabs []string
19+ Active int
20+}
21+
22+type Target struct {
23+ Session domain.SessionID
24+ TabIndex int
25+}
26+
27+type Preview struct {
28+ Rows [][]renderer.Cell
29+ Width int
30+ Height int
31+}
32+
33+type Layout struct {
34+ List domain.Rect
35+ Preview domain.Rect
36+}
37+
38+type Model struct {
39+ rows []row
40+ selected int
41+}
42+
43+type row struct {
44+ header bool
45+ label string
46+ session domain.SessionID
47+ tabIndex int
48+}
49+
50+func New(sessions []SessionView, cur domain.SessionID, curTab int) *Model {
51+ m := &Model{selected: -1}
52+ activeSelection := -1
53+ for _, session := range sessions {
54+ m.rows = append(m.rows, row{header: true, label: session.Name, session: session.ID, tabIndex: -1})
55+ active := session.Active
56+ if active < 0 || active >= len(session.Tabs) {
57+ active = 0
58+ }
59+ for i, tab := range session.Tabs {
60+ idx := len(m.rows)
61+ m.rows = append(m.rows, row{label: tab, session: session.ID, tabIndex: i})
62+ if session.ID == cur && i == curTab {
63+ m.selected = idx
64+ }
65+ if activeSelection < 0 && i == active {
66+ activeSelection = idx
67+ }
68+ }
69+ }
70+ if m.selected < 0 {
71+ m.selected = activeSelection
72+ }
73+ if m.selected < 0 {
74+ m.selected = m.firstLeaf()
75+ }
76+ if m.selected < 0 && len(m.rows) > 0 {
77+ m.selected = 0
78+ }
79+ return m
80+}
81+
82+func ChooseLayout(inner domain.Size) Layout {
83+ if inner.Cols <= 0 || inner.Rows <= 0 {
84+ return Layout{}
85+ }
86+ if inner.Cols >= 40 && inner.Rows >= MinPaneHeight {
87+ listWidth := max(MinListWidth, inner.Cols*30/100)
88+ previewWidth := inner.Cols - listWidth - 1
89+ return Layout{
90+ List: domain.Rect{Width: listWidth, Height: inner.Rows},
91+ Preview: domain.Rect{X: listWidth + 1, Width: previewWidth, Height: inner.Rows},
92+ }
93+ }
94+ if inner.Rows >= MinStackHeight && inner.Cols >= MinPreviewWidth {
95+ listHeight := max(MinPaneHeight, inner.Rows*40/100)
96+ previewHeight := inner.Rows - listHeight - 1
97+ return Layout{
98+ List: domain.Rect{Width: inner.Cols, Height: listHeight},
99+ Preview: domain.Rect{Y: listHeight + 1, Width: inner.Cols, Height: previewHeight},
100+ }
101+ }
102+ return Layout{List: domain.Rect{Width: inner.Cols, Height: inner.Rows}}
103+}
104+
105+func (m *Model) Up() {
106+ m.move(-1)
107+}
108+
109+func (m *Model) Down() {
110+ m.move(1)
111+}
112+
113+func (m *Model) Selected() (Target, bool) {
114+ if m == nil || m.selected < 0 || m.selected >= len(m.rows) {
115+ return Target{}, false
116+ }
117+ r := m.rows[m.selected]
118+ if r.header {
119+ return Target{}, false
120+ }
121+ return Target{Session: r.session, TabIndex: r.tabIndex}, true
122+}
123+
124+func (m *Model) Render(inner domain.Size, preview Preview) renderer.Frame {
125+ frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
126+ layout := ChooseLayout(inner)
127+ m.renderList(frame, layout.List)
128+ blitPreview(frame, layout.Preview, preview)
129+ return frame
130+}
131+
132+func (m *Model) move(delta int) {
133+ if m == nil || len(m.rows) == 0 {
134+ return
135+ }
136+ if m.selected < 0 || m.selected >= len(m.rows) || m.rows[m.selected].header {
137+ m.selected = m.firstLeaf()
138+ return
139+ }
140+ for i := m.selected + delta; i >= 0 && i < len(m.rows); i += delta {
141+ if !m.rows[i].header {
142+ m.selected = i
143+ return
144+ }
145+ }
146+}
147+
148+func (m *Model) firstLeaf() int {
149+ for i, r := range m.rows {
150+ if !r.header {
151+ return i
152+ }
153+ }
154+ return -1
155+}
156+
157+func (m *Model) renderList(frame renderer.Frame, rect domain.Rect) {
158+ if m == nil || rect.Width <= 0 || rect.Height <= 0 {
159+ return
160+ }
161+ for y := range min(rect.Height, frame.Height-rect.Y) {
162+ idx := y
163+ if idx >= len(m.rows) {
164+ break
165+ }
166+ r := m.rows[idx]
167+ style := renderer.DefaultStyle()
168+ if idx == m.selected {
169+ style.Inverse = true
170+ }
171+ label := r.label
172+ if !r.header {
173+ label = " " + label
174+ }
175+ drawText(frame, rect.X, rect.Y+y, rect.Width, label, style)
176+ }
177+}
178+
179+func drawText(frame renderer.Frame, x, y, width int, text string, style renderer.Style) {
180+ if y < 0 || y >= frame.Height || x >= frame.Width || width <= 0 {
181+ return
182+ }
183+ limit := min(width, frame.Width-x)
184+ if limit <= 0 {
185+ return
186+ }
187+ row := frame.Row(y)
188+ for i := range limit {
189+ row[x+i] = renderer.Cell{Rune: ' ', Style: style}
190+ }
191+ col := 0
192+ for _, r := range text {
193+ if col >= limit {
194+ break
195+ }
196+ row[x+col] = renderer.Cell{Rune: r, Style: style}
197+ col++
198+ }
199+}
200+
201+func blitPreview(frame renderer.Frame, rect domain.Rect, preview Preview) {
202+ if rect.Width <= 0 || rect.Height <= 0 {
203+ return
204+ }
205+ w := min(rect.Width, frame.Width-rect.X)
206+ h := min(rect.Height, frame.Height-rect.Y)
207+ if w <= 0 || h <= 0 {
208+ return
209+ }
210+ for y := range h {
211+ if y >= preview.Height || y >= len(preview.Rows) {
212+ continue
213+ }
214+ src := preview.Rows[y]
215+ for x := range w {
216+ if x >= preview.Width || x >= len(src) {
217+ continue
218+ }
219+ cell := src[x]
220+ if cell.Continuation && x == 0 {
221+ continue
222+ }
223+ if !cell.Continuation && x == w-1 && x+1 < len(src) && src[x+1].Continuation {
224+ continue
225+ }
226+ frame.Set(rect.X+x, rect.Y+y, cell)
227+ }
228+ }
229+}
A internal/usecase/picker/model_test.go +128 -0
@@ -0,0 +1,128 @@
1+package picker
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
12+ m := New([]SessionView{
13+ {ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 0},
14+ {ID: "s2", Name: "two", Tabs: []string{"api"}, Active: 0},
15+ }, "s1", 1)
16+
17+ got, ok := m.Selected()
18+ require.True(t, ok)
19+ require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
20+ frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{})
21+ require.Equal(t, 'o', frame.At(0, 0).Rune)
22+ require.Equal(t, ' ', frame.At(0, 2).Rune)
23+ require.Equal(t, 'l', frame.At(2, 2).Rune)
24+ require.True(t, frame.At(0, 2).Style.Inverse)
25+}
26+
27+func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {
28+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 1}}, "missing", 0)
29+ got, ok := m.Selected()
30+ require.True(t, ok)
31+ require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
32+
33+ m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell"}, Active: 4}}, "missing", 0)
34+ got, ok = m.Selected()
35+ require.True(t, ok)
36+ require.Equal(t, Target{Session: "s1", TabIndex: 0}, got)
37+}
38+
39+func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {
40+ m := New([]SessionView{
41+ {ID: "s1", Name: "one", Tabs: []string{"a", "b"}, Active: 0},
42+ {ID: "s2", Name: "two", Tabs: []string{"c"}, Active: 0},
43+ }, "s1", 0)
44+
45+ m.Up()
46+ got, ok := m.Selected()
47+ require.True(t, ok)
48+ require.Equal(t, Target{Session: "s1", TabIndex: 0}, got, "up at first tab clamps")
49+ m.Down()
50+ got, ok = m.Selected()
51+ require.True(t, ok)
52+ require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
53+ m.Down()
54+ got, ok = m.Selected()
55+ require.True(t, ok)
56+ require.Equal(t, Target{Session: "s2", TabIndex: 0}, got, "down skips second session header")
57+ m.Down()
58+ got, ok = m.Selected()
59+ require.True(t, ok)
60+ require.Equal(t, Target{Session: "s2", TabIndex: 0}, got, "down at last tab clamps")
61+ m.Up()
62+ got, ok = m.Selected()
63+ require.True(t, ok)
64+ require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header")
65+}
66+
67+func TestChooseLayoutBoundaries(t *testing.T) {
68+ layout := ChooseLayout(domain.Size{Cols: 39, Rows: 12})
69+ require.Equal(t, domain.Rect{Width: 39, Height: 4}, layout.List)
70+ require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview)
71+
72+ layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4})
73+ require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List)
74+ require.Equal(t, domain.Rect{X: 17, Width: 23, Height: 4}, layout.Preview)
75+
76+ layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11})
77+ require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List)
78+ require.Equal(t, domain.Rect{}, layout.Preview)
79+
80+ layout = ChooseLayout(domain.Size{Cols: 24, Rows: 12})
81+ require.Equal(t, domain.Rect{Width: 24, Height: 4}, layout.List)
82+ require.Equal(t, domain.Rect{Y: 5, Width: 24, Height: 7}, layout.Preview)
83+}
84+
85+func TestSelectedMapping(t *testing.T) {
86+ m := New([]SessionView{
87+ {ID: "alpha", Name: "alpha", Tabs: []string{"one"}, Active: 0},
88+ {ID: "beta", Name: "beta", Tabs: []string{"two", "three"}, Active: 0},
89+ }, "beta", 1)
90+
91+ got, ok := m.Selected()
92+ require.True(t, ok)
93+ require.Equal(t, Target{Session: "beta", TabIndex: 1}, got)
94+}
95+
96+func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
97+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
98+ preview := Preview{
99+ Width: 24,
100+ Height: 1,
101+ Rows: [][]renderer.Cell{{
102+ cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'),
103+ cell('k'), cell('l'), cell('m'), cell('n'), cell('o'), cell('p'), cell('q'), cell('r'), cell('s'), cell('t'),
104+ cell('u'), cell('v'), {Rune: '界', Style: renderer.DefaultStyle()}, {Continuation: true, Style: renderer.DefaultStyle()},
105+ }},
106+ }
107+
108+ frame := m.Render(domain.Size{Cols: 40, Rows: 4}, preview)
109+ require.True(t, frame.At(0, 1).Style.Inverse)
110+ require.Equal(t, 'a', frame.At(17, 0).Rune)
111+ require.Equal(t, 'v', frame.At(38, 0).Rune)
112+ require.Equal(t, ' ', frame.At(39, 0).Rune, "wide rune crossing preview pane is dropped")
113+ require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks")
114+}
115+
116+func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
117+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
118+ preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}
119+
120+ frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview)
121+ require.Equal(t, 'o', frame.At(0, 0).Rune)
122+ require.Equal(t, ' ', frame.At(0, 1).Rune)
123+ require.NotEqual(t, 'x', frame.At(0, 0).Rune)
124+}
125+
126+func cell(r rune) renderer.Cell {
127+ return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
128+}
@@ -0,0 +1,128 @@
1+package picker
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestNewFlattensAndSelectsCurrentTab(t *testing.T) {
12+ m := New([]SessionView{
13+ {ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 0},
14+ {ID: "s2", Name: "two", Tabs: []string{"api"}, Active: 0},
15+ }, "s1", 1)
16+
17+ got, ok := m.Selected()
18+ require.True(t, ok)
19+ require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
20+ frame := m.Render(domain.Size{Cols: 20, Rows: 5}, Preview{})
21+ require.Equal(t, 'o', frame.At(0, 0).Rune)
22+ require.Equal(t, ' ', frame.At(0, 2).Rune)
23+ require.Equal(t, 'l', frame.At(2, 2).Rune)
24+ require.True(t, frame.At(0, 2).Style.Inverse)
25+}
26+
27+func TestNewFallsBackToActiveThenFirstLeaf(t *testing.T) {
28+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell", "logs"}, Active: 1}}, "missing", 0)
29+ got, ok := m.Selected()
30+ require.True(t, ok)
31+ require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
32+
33+ m = New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"shell"}, Active: 4}}, "missing", 0)
34+ got, ok = m.Selected()
35+ require.True(t, ok)
36+ require.Equal(t, Target{Session: "s1", TabIndex: 0}, got)
37+}
38+
39+func TestUpDownSkipsHeadersClampsAndCrossesSessions(t *testing.T) {
40+ m := New([]SessionView{
41+ {ID: "s1", Name: "one", Tabs: []string{"a", "b"}, Active: 0},
42+ {ID: "s2", Name: "two", Tabs: []string{"c"}, Active: 0},
43+ }, "s1", 0)
44+
45+ m.Up()
46+ got, ok := m.Selected()
47+ require.True(t, ok)
48+ require.Equal(t, Target{Session: "s1", TabIndex: 0}, got, "up at first tab clamps")
49+ m.Down()
50+ got, ok = m.Selected()
51+ require.True(t, ok)
52+ require.Equal(t, Target{Session: "s1", TabIndex: 1}, got)
53+ m.Down()
54+ got, ok = m.Selected()
55+ require.True(t, ok)
56+ require.Equal(t, Target{Session: "s2", TabIndex: 0}, got, "down skips second session header")
57+ m.Down()
58+ got, ok = m.Selected()
59+ require.True(t, ok)
60+ require.Equal(t, Target{Session: "s2", TabIndex: 0}, got, "down at last tab clamps")
61+ m.Up()
62+ got, ok = m.Selected()
63+ require.True(t, ok)
64+ require.Equal(t, Target{Session: "s1", TabIndex: 1}, got, "up skips second session header")
65+}
66+
67+func TestChooseLayoutBoundaries(t *testing.T) {
68+ layout := ChooseLayout(domain.Size{Cols: 39, Rows: 12})
69+ require.Equal(t, domain.Rect{Width: 39, Height: 4}, layout.List)
70+ require.Equal(t, domain.Rect{Y: 5, Width: 39, Height: 7}, layout.Preview)
71+
72+ layout = ChooseLayout(domain.Size{Cols: 40, Rows: 4})
73+ require.Equal(t, domain.Rect{Width: 16, Height: 4}, layout.List)
74+ require.Equal(t, domain.Rect{X: 17, Width: 23, Height: 4}, layout.Preview)
75+
76+ layout = ChooseLayout(domain.Size{Cols: 24, Rows: 11})
77+ require.Equal(t, domain.Rect{Width: 24, Height: 11}, layout.List)
78+ require.Equal(t, domain.Rect{}, layout.Preview)
79+
80+ layout = ChooseLayout(domain.Size{Cols: 24, Rows: 12})
81+ require.Equal(t, domain.Rect{Width: 24, Height: 4}, layout.List)
82+ require.Equal(t, domain.Rect{Y: 5, Width: 24, Height: 7}, layout.Preview)
83+}
84+
85+func TestSelectedMapping(t *testing.T) {
86+ m := New([]SessionView{
87+ {ID: "alpha", Name: "alpha", Tabs: []string{"one"}, Active: 0},
88+ {ID: "beta", Name: "beta", Tabs: []string{"two", "three"}, Active: 0},
89+ }, "beta", 1)
90+
91+ got, ok := m.Selected()
92+ require.True(t, ok)
93+ require.Equal(t, Target{Session: "beta", TabIndex: 1}, got)
94+}
95+
96+func TestRenderPreviewClipsPadsDropsWideRuneAndInvertsSelection(t *testing.T) {
97+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
98+ preview := Preview{
99+ Width: 24,
100+ Height: 1,
101+ Rows: [][]renderer.Cell{{
102+ cell('a'), cell('b'), cell('c'), cell('d'), cell('e'), cell('f'), cell('g'), cell('h'), cell('i'), cell('j'),
103+ cell('k'), cell('l'), cell('m'), cell('n'), cell('o'), cell('p'), cell('q'), cell('r'), cell('s'), cell('t'),
104+ cell('u'), cell('v'), {Rune: '界', Style: renderer.DefaultStyle()}, {Continuation: true, Style: renderer.DefaultStyle()},
105+ }},
106+ }
107+
108+ frame := m.Render(domain.Size{Cols: 40, Rows: 4}, preview)
109+ require.True(t, frame.At(0, 1).Style.Inverse)
110+ require.Equal(t, 'a', frame.At(17, 0).Rune)
111+ require.Equal(t, 'v', frame.At(38, 0).Rune)
112+ require.Equal(t, ' ', frame.At(39, 0).Rune, "wide rune crossing preview pane is dropped")
113+ require.Equal(t, ' ', frame.At(17, 1).Rune, "preview pane is padded with blanks")
114+}
115+
116+func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
117+ m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
118+ preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}}
119+
120+ frame := m.Render(domain.Size{Cols: 23, Rows: 11}, preview)
121+ require.Equal(t, 'o', frame.At(0, 0).Rune)
122+ require.Equal(t, ' ', frame.At(0, 1).Rune)
123+ require.NotEqual(t, 'x', frame.At(0, 0).Rune)
124+}
125+
126+func cell(r rune) renderer.Cell {
127+ return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
128+}
A internal/usecase/ui/modal.go +208 -0
@@ -0,0 +1,208 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+// Modal describes a centered rectangular overlay.
9+type Modal struct {
10+ WidthPct int
11+ HeightPct int
12+ MinWidth int
13+ MinHeight int
14+ Title string
15+}
16+
17+// Bounds returns the modal rectangle centered within base and clamped to base.
18+func (m Modal) Bounds(base domain.Size) domain.Rect {
19+ if base.Cols <= 0 || base.Rows <= 0 {
20+ return domain.Rect{}
21+ }
22+
23+ width := percentOf(base.Cols, m.WidthPct)
24+ height := percentOf(base.Rows, m.HeightPct)
25+ if width < m.MinWidth {
26+ width = m.MinWidth
27+ }
28+ if height < m.MinHeight {
29+ height = m.MinHeight
30+ }
31+ width = clamp(width, 0, base.Cols)
32+ height = clamp(height, 0, base.Rows)
33+
34+ return domain.Rect{
35+ X: (base.Cols - width) / 2,
36+ Y: (base.Rows - height) / 2,
37+ Width: width,
38+ Height: height,
39+ }
40+}
41+
42+// Inner returns the modal content rectangle after removing a one-cell border.
43+func (m Modal) Inner(base domain.Size) domain.Rect {
44+ bounds := m.Bounds(base)
45+ return domain.Rect{
46+ X: bounds.X + 1,
47+ Y: bounds.Y + 1,
48+ Width: max(0, bounds.Width-2),
49+ Height: max(0, bounds.Height-2),
50+ }
51+}
52+
53+// Composite draws the modal border and title, clears the interior, and returns
54+// the inner content rectangle. Cells outside the modal bounds are not changed.
55+func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect {
56+ bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
57+ inner := m.Inner(domain.Size{Cols: f.Width, Rows: f.Height})
58+ DrawBox(f, bounds, border)
59+ FillRect(f, inner, renderer.BlankCell())
60+ if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" {
61+ left := bounds.X + 1
62+ right := bounds.X + bounds.Width - 1
63+ start := bounds.X + (bounds.Width-textWidth(m.Title))/2
64+ if start < left {
65+ start = left
66+ }
67+ DrawText(f, start, bounds.Y, right, m.Title, border)
68+ }
69+ return inner
70+}
71+
72+// FillRect fills rect with cell, clipped to the frame bounds.
73+func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) {
74+ left := clamp(rect.X, 0, f.Width)
75+ top := clamp(rect.Y, 0, f.Height)
76+ right := clamp(rect.X+rect.Width, 0, f.Width)
77+ bottom := clamp(rect.Y+rect.Height, 0, f.Height)
78+ for y := top; y < bottom; y++ {
79+ for x := left; x < right; x++ {
80+ f.Set(x, y, cell)
81+ }
82+ }
83+}
84+
85+// DrawBox draws a single-cell box clipped to the frame bounds.
86+func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {
87+ if rect.Width <= 0 || rect.Height <= 0 {
88+ return
89+ }
90+ left := rect.X
91+ top := rect.Y
92+ right := rect.X + rect.Width - 1
93+ bottom := rect.Y + rect.Height - 1
94+
95+ for x := left; x <= right; x++ {
96+ setCell(f, x, top, '─', style)
97+ setCell(f, x, bottom, '─', style)
98+ }
99+ for y := top; y <= bottom; y++ {
100+ setCell(f, left, y, '│', style)
101+ setCell(f, right, y, '│', style)
102+ }
103+ setCell(f, left, top, '┌', style)
104+ setCell(f, right, top, '┐', style)
105+ setCell(f, left, bottom, '└', style)
106+ setCell(f, right, bottom, '┘', style)
107+}
108+
109+// DrawText draws text starting at x,y before exclusive clipX and returns the
110+// next x position. Wide runes that would cross clipX are dropped.
111+func DrawText(f renderer.Frame, x, y, clipX int, text string, style renderer.Style) int {
112+ if y < 0 || y >= f.Height {
113+ return x
114+ }
115+ clipX = clamp(clipX, 0, f.Width)
116+ pos := x
117+ for _, r := range text {
118+ w := runeWidth(r)
119+ if w == 0 {
120+ continue
121+ }
122+ if pos >= clipX {
123+ break
124+ }
125+ if pos+w > clipX {
126+ break
127+ }
128+ if pos >= 0 && pos < f.Width {
129+ f.Set(pos, y, renderer.Cell{Rune: r, Style: style})
130+ }
131+ if w == 2 && pos+1 >= 0 && pos+1 < f.Width {
132+ f.Set(pos+1, y, renderer.Cell{Style: style, Continuation: true})
133+ }
134+ pos += w
135+ }
136+ return pos
137+}
138+
139+func setCell(f renderer.Frame, x, y int, r rune, style renderer.Style) {
140+ if x < 0 || x >= f.Width || y < 0 || y >= f.Height {
141+ return
142+ }
143+ f.Set(x, y, renderer.Cell{Rune: r, Style: style})
144+}
145+
146+func percentOf(n, pct int) int {
147+ if pct <= 0 {
148+ return n
149+ }
150+ return n * pct / 100
151+}
152+
153+func clamp(n, low, high int) int {
154+ if n < low {
155+ return low
156+ }
157+ if n > high {
158+ return high
159+ }
160+ return n
161+}
162+
163+func textWidth(text string) int {
164+ width := 0
165+ for _, r := range text {
166+ width += runeWidth(r)
167+ }
168+ return width
169+}
170+
171+func runeWidth(r rune) int {
172+ switch {
173+ case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F):
174+ return 0
175+ case r >= 0x1100 && r <= 0x115F:
176+ return 2
177+ case r == 0x2329 || r == 0x232A:
178+ return 2
179+ case r >= 0x2E80 && r <= 0xA4CF:
180+ return 2
181+ case r >= 0xAC00 && r <= 0xD7FF:
182+ return 2
183+ case r >= 0xF900 && r <= 0xFAFF:
184+ return 2
185+ case r >= 0xFE10 && r <= 0xFE19:
186+ return 2
187+ case r >= 0xFE30 && r <= 0xFE6F:
188+ return 2
189+ case r >= 0xFF01 && r <= 0xFF60:
190+ return 2
191+ case r >= 0xFFE0 && r <= 0xFFE6:
192+ return 2
193+ case r >= 0x1B000 && r <= 0x1B12F:
194+ return 2
195+ case r >= 0x1F000 && r <= 0x1F64F:
196+ return 2
197+ case r >= 0x1F680 && r <= 0x1F6FF:
198+ return 2
199+ case r >= 0x1F900 && r <= 0x1F9FF:
200+ return 2
201+ case r >= 0x1FA70 && r <= 0x1FAFF:
202+ return 2
203+ case r >= 0x20000 && r <= 0x3FFFF:
204+ return 2
205+ default:
206+ return 1
207+ }
208+}
@@ -0,0 +1,208 @@
1+package ui
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/pkg/renderer"
6+)
7+
8+// Modal describes a centered rectangular overlay.
9+type Modal struct {
10+ WidthPct int
11+ HeightPct int
12+ MinWidth int
13+ MinHeight int
14+ Title string
15+}
16+
17+// Bounds returns the modal rectangle centered within base and clamped to base.
18+func (m Modal) Bounds(base domain.Size) domain.Rect {
19+ if base.Cols <= 0 || base.Rows <= 0 {
20+ return domain.Rect{}
21+ }
22+
23+ width := percentOf(base.Cols, m.WidthPct)
24+ height := percentOf(base.Rows, m.HeightPct)
25+ if width < m.MinWidth {
26+ width = m.MinWidth
27+ }
28+ if height < m.MinHeight {
29+ height = m.MinHeight
30+ }
31+ width = clamp(width, 0, base.Cols)
32+ height = clamp(height, 0, base.Rows)
33+
34+ return domain.Rect{
35+ X: (base.Cols - width) / 2,
36+ Y: (base.Rows - height) / 2,
37+ Width: width,
38+ Height: height,
39+ }
40+}
41+
42+// Inner returns the modal content rectangle after removing a one-cell border.
43+func (m Modal) Inner(base domain.Size) domain.Rect {
44+ bounds := m.Bounds(base)
45+ return domain.Rect{
46+ X: bounds.X + 1,
47+ Y: bounds.Y + 1,
48+ Width: max(0, bounds.Width-2),
49+ Height: max(0, bounds.Height-2),
50+ }
51+}
52+
53+// Composite draws the modal border and title, clears the interior, and returns
54+// the inner content rectangle. Cells outside the modal bounds are not changed.
55+func (m Modal) Composite(f renderer.Frame, border renderer.Style) domain.Rect {
56+ bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
57+ inner := m.Inner(domain.Size{Cols: f.Width, Rows: f.Height})
58+ DrawBox(f, bounds, border)
59+ FillRect(f, inner, renderer.BlankCell())
60+ if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" {
61+ left := bounds.X + 1
62+ right := bounds.X + bounds.Width - 1
63+ start := bounds.X + (bounds.Width-textWidth(m.Title))/2
64+ if start < left {
65+ start = left
66+ }
67+ DrawText(f, start, bounds.Y, right, m.Title, border)
68+ }
69+ return inner
70+}
71+
72+// FillRect fills rect with cell, clipped to the frame bounds.
73+func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) {
74+ left := clamp(rect.X, 0, f.Width)
75+ top := clamp(rect.Y, 0, f.Height)
76+ right := clamp(rect.X+rect.Width, 0, f.Width)
77+ bottom := clamp(rect.Y+rect.Height, 0, f.Height)
78+ for y := top; y < bottom; y++ {
79+ for x := left; x < right; x++ {
80+ f.Set(x, y, cell)
81+ }
82+ }
83+}
84+
85+// DrawBox draws a single-cell box clipped to the frame bounds.
86+func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {
87+ if rect.Width <= 0 || rect.Height <= 0 {
88+ return
89+ }
90+ left := rect.X
91+ top := rect.Y
92+ right := rect.X + rect.Width - 1
93+ bottom := rect.Y + rect.Height - 1
94+
95+ for x := left; x <= right; x++ {
96+ setCell(f, x, top, '─', style)
97+ setCell(f, x, bottom, '─', style)
98+ }
99+ for y := top; y <= bottom; y++ {
100+ setCell(f, left, y, '│', style)
101+ setCell(f, right, y, '│', style)
102+ }
103+ setCell(f, left, top, '┌', style)
104+ setCell(f, right, top, '┐', style)
105+ setCell(f, left, bottom, '└', style)
106+ setCell(f, right, bottom, '┘', style)
107+}
108+
109+// DrawText draws text starting at x,y before exclusive clipX and returns the
110+// next x position. Wide runes that would cross clipX are dropped.
111+func DrawText(f renderer.Frame, x, y, clipX int, text string, style renderer.Style) int {
112+ if y < 0 || y >= f.Height {
113+ return x
114+ }
115+ clipX = clamp(clipX, 0, f.Width)
116+ pos := x
117+ for _, r := range text {
118+ w := runeWidth(r)
119+ if w == 0 {
120+ continue
121+ }
122+ if pos >= clipX {
123+ break
124+ }
125+ if pos+w > clipX {
126+ break
127+ }
128+ if pos >= 0 && pos < f.Width {
129+ f.Set(pos, y, renderer.Cell{Rune: r, Style: style})
130+ }
131+ if w == 2 && pos+1 >= 0 && pos+1 < f.Width {
132+ f.Set(pos+1, y, renderer.Cell{Style: style, Continuation: true})
133+ }
134+ pos += w
135+ }
136+ return pos
137+}
138+
139+func setCell(f renderer.Frame, x, y int, r rune, style renderer.Style) {
140+ if x < 0 || x >= f.Width || y < 0 || y >= f.Height {
141+ return
142+ }
143+ f.Set(x, y, renderer.Cell{Rune: r, Style: style})
144+}
145+
146+func percentOf(n, pct int) int {
147+ if pct <= 0 {
148+ return n
149+ }
150+ return n * pct / 100
151+}
152+
153+func clamp(n, low, high int) int {
154+ if n < low {
155+ return low
156+ }
157+ if n > high {
158+ return high
159+ }
160+ return n
161+}
162+
163+func textWidth(text string) int {
164+ width := 0
165+ for _, r := range text {
166+ width += runeWidth(r)
167+ }
168+ return width
169+}
170+
171+func runeWidth(r rune) int {
172+ switch {
173+ case r < 0x20 || r == 0x7F || (r >= 0x80 && r <= 0x9F):
174+ return 0
175+ case r >= 0x1100 && r <= 0x115F:
176+ return 2
177+ case r == 0x2329 || r == 0x232A:
178+ return 2
179+ case r >= 0x2E80 && r <= 0xA4CF:
180+ return 2
181+ case r >= 0xAC00 && r <= 0xD7FF:
182+ return 2
183+ case r >= 0xF900 && r <= 0xFAFF:
184+ return 2
185+ case r >= 0xFE10 && r <= 0xFE19:
186+ return 2
187+ case r >= 0xFE30 && r <= 0xFE6F:
188+ return 2
189+ case r >= 0xFF01 && r <= 0xFF60:
190+ return 2
191+ case r >= 0xFFE0 && r <= 0xFFE6:
192+ return 2
193+ case r >= 0x1B000 && r <= 0x1B12F:
194+ return 2
195+ case r >= 0x1F000 && r <= 0x1F64F:
196+ return 2
197+ case r >= 0x1F680 && r <= 0x1F6FF:
198+ return 2
199+ case r >= 0x1F900 && r <= 0x1F9FF:
200+ return 2
201+ case r >= 0x1FA70 && r <= 0x1FAFF:
202+ return 2
203+ case r >= 0x20000 && r <= 0x3FFFF:
204+ return 2
205+ default:
206+ return 1
207+ }
208+}
A internal/usecase/ui/modal_test.go +142 -0
@@ -0,0 +1,142 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestModalBounds(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ base domain.Size
14+ m Modal
15+ want domain.Rect
16+ }{
17+ {
18+ name: "eighty percent centered",
19+ base: domain.Size{Cols: 100, Rows: 40},
20+ m: Modal{WidthPct: 80, HeightPct: 80},
21+ want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32},
22+ },
23+ {
24+ name: "minimum clamps to base",
25+ base: domain.Size{Cols: 20, Rows: 6},
26+ m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12},
27+ want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6},
28+ },
29+ {
30+ name: "odd centering rounds down",
31+ base: domain.Size{Cols: 11, Rows: 7},
32+ m: Modal{WidthPct: 50, HeightPct: 50},
33+ want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3},
34+ },
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ if got := tt.m.Bounds(tt.base); got != tt.want {
39+ t.Fatalf("Bounds() = %+v, want %+v", got, tt.want)
40+ }
41+ })
42+ }
43+}
44+
45+func TestModalInnerDegenerate(t *testing.T) {
46+ m := Modal{WidthPct: 100, HeightPct: 100}
47+ got := m.Inner(domain.Size{Cols: 2, Rows: 2})
48+ want := domain.Rect{X: 1, Y: 1, Width: 0, Height: 0}
49+ if got != want {
50+ t.Fatalf("Inner() = %+v, want %+v", got, want)
51+ }
52+}
53+
54+func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) {
55+ f := renderer.NewFrame(10, 6)
56+ exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
57+ FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior)
58+
59+ style := renderer.Style{Bold: true, Foreground: 2, Background: -1}
60+ m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"}
61+ inner := m.Composite(f, style)
62+ wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2}
63+ if inner != wantInner {
64+ t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner)
65+ }
66+
67+ assertRune(t, f, 2, 1, '┌')
68+ assertRune(t, f, 7, 1, '┐')
69+ assertRune(t, f, 2, 4, '└')
70+ assertRune(t, f, 7, 4, '┘')
71+ assertRune(t, f, 3, 1, '─')
72+ assertRune(t, f, 2, 2, '│')
73+ assertRune(t, f, 4, 1, 'H')
74+ assertRune(t, f, 5, 1, 'i')
75+
76+ for y := inner.Y; y < inner.Y+inner.Height; y++ {
77+ for x := inner.X; x < inner.X+inner.Width; x++ {
78+ if got := f.At(x, y); !got.Equal(renderer.BlankCell()) {
79+ t.Fatalf("interior cell (%d,%d) = %+v, want blank", x, y, got)
80+ }
81+ }
82+ }
83+ assertCell(t, f, 0, 0, exterior)
84+ assertCell(t, f, 9, 5, exterior)
85+}
86+
87+func TestFillRectClips(t *testing.T) {
88+ f := renderer.NewFrame(3, 2)
89+ cell := renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()}
90+ FillRect(f, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, cell)
91+ assertCell(t, f, 0, 0, cell)
92+ assertCell(t, f, 1, 1, cell)
93+ assertCell(t, f, 2, 1, renderer.BlankCell())
94+}
95+
96+func TestDrawTextClipsAndDropsWideRuneCrossingClip(t *testing.T) {
97+ style := renderer.Style{Inverse: true, Foreground: -1, Background: -1}
98+ t.Run("clips ascii", func(t *testing.T) {
99+ f := renderer.NewFrame(5, 1)
100+ next := DrawText(f, 1, 0, 3, "abcd", style)
101+ if next != 3 {
102+ t.Fatalf("DrawText next = %d, want 3", next)
103+ }
104+ assertRune(t, f, 1, 0, 'a')
105+ assertRune(t, f, 2, 0, 'b')
106+ assertCell(t, f, 3, 0, renderer.BlankCell())
107+ })
108+ t.Run("draws wide rune when it fits", func(t *testing.T) {
109+ f := renderer.NewFrame(5, 1)
110+ next := DrawText(f, 1, 0, 3, "你", style)
111+ if next != 3 {
112+ t.Fatalf("DrawText next = %d, want 3", next)
113+ }
114+ assertRune(t, f, 1, 0, '你')
115+ if got := f.At(2, 0); !got.Continuation {
116+ t.Fatalf("wide continuation = %+v, want continuation", got)
117+ }
118+ })
119+ t.Run("drops wide rune crossing clip", func(t *testing.T) {
120+ f := renderer.NewFrame(5, 1)
121+ next := DrawText(f, 2, 0, 3, "你A", style)
122+ if next != 2 {
123+ t.Fatalf("DrawText next = %d, want 2", next)
124+ }
125+ assertCell(t, f, 2, 0, renderer.BlankCell())
126+ assertCell(t, f, 3, 0, renderer.BlankCell())
127+ })
128+}
129+
130+func assertRune(t *testing.T, f renderer.Frame, x, y int, want rune) {
131+ t.Helper()
132+ if got := f.At(x, y).Rune; got != want {
133+ t.Fatalf("cell (%d,%d) rune = %q, want %q", x, y, got, want)
134+ }
135+}
136+
137+func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) {
138+ t.Helper()
139+ if got := f.At(x, y); !got.Equal(want) {
140+ t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want)
141+ }
142+}
@@ -0,0 +1,142 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func TestModalBounds(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ base domain.Size
14+ m Modal
15+ want domain.Rect
16+ }{
17+ {
18+ name: "eighty percent centered",
19+ base: domain.Size{Cols: 100, Rows: 40},
20+ m: Modal{WidthPct: 80, HeightPct: 80},
21+ want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32},
22+ },
23+ {
24+ name: "minimum clamps to base",
25+ base: domain.Size{Cols: 20, Rows: 6},
26+ m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12},
27+ want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6},
28+ },
29+ {
30+ name: "odd centering rounds down",
31+ base: domain.Size{Cols: 11, Rows: 7},
32+ m: Modal{WidthPct: 50, HeightPct: 50},
33+ want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3},
34+ },
35+ }
36+ for _, tt := range tests {
37+ t.Run(tt.name, func(t *testing.T) {
38+ if got := tt.m.Bounds(tt.base); got != tt.want {
39+ t.Fatalf("Bounds() = %+v, want %+v", got, tt.want)
40+ }
41+ })
42+ }
43+}
44+
45+func TestModalInnerDegenerate(t *testing.T) {
46+ m := Modal{WidthPct: 100, HeightPct: 100}
47+ got := m.Inner(domain.Size{Cols: 2, Rows: 2})
48+ want := domain.Rect{X: 1, Y: 1, Width: 0, Height: 0}
49+ if got != want {
50+ t.Fatalf("Inner() = %+v, want %+v", got, want)
51+ }
52+}
53+
54+func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) {
55+ f := renderer.NewFrame(10, 6)
56+ exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
57+ FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior)
58+
59+ style := renderer.Style{Bold: true, Foreground: 2, Background: -1}
60+ m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"}
61+ inner := m.Composite(f, style)
62+ wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2}
63+ if inner != wantInner {
64+ t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner)
65+ }
66+
67+ assertRune(t, f, 2, 1, '┌')
68+ assertRune(t, f, 7, 1, '┐')
69+ assertRune(t, f, 2, 4, '└')
70+ assertRune(t, f, 7, 4, '┘')
71+ assertRune(t, f, 3, 1, '─')
72+ assertRune(t, f, 2, 2, '│')
73+ assertRune(t, f, 4, 1, 'H')
74+ assertRune(t, f, 5, 1, 'i')
75+
76+ for y := inner.Y; y < inner.Y+inner.Height; y++ {
77+ for x := inner.X; x < inner.X+inner.Width; x++ {
78+ if got := f.At(x, y); !got.Equal(renderer.BlankCell()) {
79+ t.Fatalf("interior cell (%d,%d) = %+v, want blank", x, y, got)
80+ }
81+ }
82+ }
83+ assertCell(t, f, 0, 0, exterior)
84+ assertCell(t, f, 9, 5, exterior)
85+}
86+
87+func TestFillRectClips(t *testing.T) {
88+ f := renderer.NewFrame(3, 2)
89+ cell := renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()}
90+ FillRect(f, domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, cell)
91+ assertCell(t, f, 0, 0, cell)
92+ assertCell(t, f, 1, 1, cell)
93+ assertCell(t, f, 2, 1, renderer.BlankCell())
94+}
95+
96+func TestDrawTextClipsAndDropsWideRuneCrossingClip(t *testing.T) {
97+ style := renderer.Style{Inverse: true, Foreground: -1, Background: -1}
98+ t.Run("clips ascii", func(t *testing.T) {
99+ f := renderer.NewFrame(5, 1)
100+ next := DrawText(f, 1, 0, 3, "abcd", style)
101+ if next != 3 {
102+ t.Fatalf("DrawText next = %d, want 3", next)
103+ }
104+ assertRune(t, f, 1, 0, 'a')
105+ assertRune(t, f, 2, 0, 'b')
106+ assertCell(t, f, 3, 0, renderer.BlankCell())
107+ })
108+ t.Run("draws wide rune when it fits", func(t *testing.T) {
109+ f := renderer.NewFrame(5, 1)
110+ next := DrawText(f, 1, 0, 3, "你", style)
111+ if next != 3 {
112+ t.Fatalf("DrawText next = %d, want 3", next)
113+ }
114+ assertRune(t, f, 1, 0, '你')
115+ if got := f.At(2, 0); !got.Continuation {
116+ t.Fatalf("wide continuation = %+v, want continuation", got)
117+ }
118+ })
119+ t.Run("drops wide rune crossing clip", func(t *testing.T) {
120+ f := renderer.NewFrame(5, 1)
121+ next := DrawText(f, 2, 0, 3, "你A", style)
122+ if next != 2 {
123+ t.Fatalf("DrawText next = %d, want 2", next)
124+ }
125+ assertCell(t, f, 2, 0, renderer.BlankCell())
126+ assertCell(t, f, 3, 0, renderer.BlankCell())
127+ })
128+}
129+
130+func assertRune(t *testing.T, f renderer.Frame, x, y int, want rune) {
131+ t.Helper()
132+ if got := f.At(x, y).Rune; got != want {
133+ t.Fatalf("cell (%d,%d) rune = %q, want %q", x, y, got, want)
134+ }
135+}
136+
137+func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) {
138+ t.Helper()
139+ if got := f.At(x, y); !got.Equal(want) {
140+ t.Fatalf("cell (%d,%d) = %+v, want %+v", x, y, got, want)
141+ }
142+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close