detached 4428ba6

feat(daemon): compose status bar

commit
4428ba6d87fa654aa5d67b5174bf64aedfe1bc02
author
brice · · 2026-07-02 14:49 UTC
parent
98c7add
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +179 -42
M internal/usecase/daemon/daemon.go +105 -39
@@ -490,7 +490,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
490490 // createSessionLocked spawns a PTY-backed session and starts its reader and
491491 // scheduler goroutines. Caller holds d.mu.
492492 func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size) (*session, error) {
493- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), sz)
493+ winSize := windowSize(sz)
494+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), winSize)
494495 if err != nil {
495496 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
496497 }
@@ -500,9 +501,9 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
500501
501502 win := &window{
502503 pty: pty,
503- screen: vt.NewScreen(sz.Cols, sz.Rows),
504+ screen: vt.NewScreen(winSize.Cols, winSize.Rows),
504505 dirty: make(chan struct{}, 1),
505- size: sz,
506+ size: winSize,
506507 }
507508 sctx, cancel := context.WithCancel(d.serveCtx)
508509 sess := &session{
@@ -519,15 +520,16 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
519520 }
520521
521522 func (d *Daemon) createWindow(sess *session, sz domain.Size) error {
522- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sz)
523+ winSize := windowSize(sz)
524+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), winSize)
523525 if err != nil {
524526 return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err)
525527 }
526528 win := &window{
527529 pty: pty,
528- screen: vt.NewScreen(sz.Cols, sz.Rows),
530+ screen: vt.NewScreen(winSize.Cols, winSize.Rows),
529531 dirty: make(chan struct{}, 1),
530- size: sz,
532+ size: winSize,
531533 }
532534 sess.mu.Lock()
533535 sess.windows = append(sess.windows, win)
@@ -537,6 +539,17 @@ func (d *Daemon) createWindow(sess *session, sz domain.Size) error {
537539 return nil
538540 }
539541
542+func windowSize(clientSize domain.Size) domain.Size {
543+ if !clientSize.Valid() {
544+ clientSize = defaultSize
545+ }
546+ rows := clientSize.Rows - 1
547+ if rows < 1 {
548+ rows = 1
549+ }
550+ return domain.Size{Cols: clientSize.Cols, Rows: rows}
551+}
552+
540553 func (d *Daemon) startWindowGoroutines(sess *session, win *window) {
541554 d.sessWg.Add(2)
542555 go d.ptyReader(sess, win)
@@ -578,7 +591,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
578591 wsz := win.size
579592 win.mu.Unlock()
580593
581- if clientSize.Valid() && wsz != clientSize {
594+ if clientSize.Valid() && wsz != windowSize(clientSize) {
582595 d.resize(sess, ac, clientSize)
583596 return
584597 }
@@ -704,6 +717,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
704717 // M4 will install copy-mode state here; for M3 the binding is intentionally intercepted.
705718 case keys.ActionRenameSession:
706719 h.sess.promoteEphemeral()
720+ h.d.paint(h.sess, h.ac, true)
707721 case keys.ActionSwitchWindow1, keys.ActionSwitchWindow2, keys.ActionSwitchWindow3,
708722 keys.ActionSwitchWindow4, keys.ActionSwitchWindow5, keys.ActionSwitchWindow6,
709723 keys.ActionSwitchWindow7, keys.ActionSwitchWindow8, keys.ActionSwitchWindow9:
@@ -844,9 +858,9 @@ func (d *Daemon) render(sess *session, win *window) {
844858 d.paint(sess, ac, false)
845859 }
846860
847-// paint draws the screen for ac and sends the resulting bytes. The renderer
848-// shadow is read/written under win.mu; sendMu makes Draw→Send atomic per client
849-// so the scheduler and a concurrent resize never interleave their output.
861+// paint draws the composed client frame (active window plus status bar) and
862+// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
863+// such as switch/create/close/rename/resize so the repaint is complete.
850864 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
851865 win := sess.activeWindow()
852866 if win == nil {
@@ -858,7 +872,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
858872 if reset {
859873 ac.rend.Reset()
860874 }
861- data, err := ac.rend.Draw(win.screen.Frame, win.screen.Damage())
875+ frame, damage := composeClientFrame(sess, win, reset)
876+ data, err := ac.rend.Draw(frame, damage)
862877 win.screen.ClearDamage()
863878 win.mu.Unlock()
864879
@@ -877,44 +892,95 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
877892 }
878893 }
879894
880-// resize applies a client size change in the plan's strict order: pty.Resize,
881-// then screen.Resize under the window lock, then a renderer reset + full
882-// redraw, then send.
883-func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
884- if !sz.Valid() {
885- return
886- }
887- win := sess.activeWindow()
888- if win == nil {
889- return
895+// resize applies a client size change to every window using rows-1 for the PTY
896+// and VT screen, then resets the renderer shadow and repaints the composed
897+// client-sized frame (including the status row).
898+func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) {
899+ width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height
900+ frame := renderer.NewFrame(width, screenRows+1)
901+ for y := 0; y < screenRows; y++ {
902+ copy(frame.Row(y), win.screen.Frame.Row(y))
903+ }
904+ drawStatus(frame.Row(screenRows), sess)
905+ if full {
906+ return frame, []renderer.Damage{renderer.FullRedraw()}
907+ }
908+ damage := append([]renderer.Damage(nil), win.screen.Damage()...)
909+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1})
910+ return frame, damage
911+}
912+
913+func drawStatus(row []renderer.Cell, sess *session) {
914+ for i := range row {
915+ row[i] = renderer.BlankCell()
916+ }
917+ status := sess.statusSegments()
918+ x := 0
919+ writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle())
920+ for _, w := range status.windows {
921+ style := renderer.DefaultStyle()
922+ if w.active {
923+ style.Inverse = true
924+ }
925+ writeStatusText(row, &x, " "+w.name+" ", style)
890926 }
927+}
928+
929+type statusSnapshot struct {
930+ session string
931+ windows []statusWindow
932+}
933+
934+type statusWindow struct {
935+ name string
936+ active bool
937+}
891938
892- if err := win.pty.Resize(sz); err != nil {
893- d.log.Warn("pty resize failed", "err", err, "session", sess.name)
939+func (s *session) statusSegments() statusSnapshot {
940+ s.mu.Lock()
941+ defer s.mu.Unlock()
942+ snap := statusSnapshot{session: s.name, windows: make([]statusWindow, len(s.windows))}
943+ for i := range s.windows {
944+ name := strconv.Itoa(i + 1)
945+ snap.windows[i] = statusWindow{name: name, active: i == s.active}
894946 }
947+ return snap
948+}
895949
896- ac.sendMu.Lock()
897- win.mu.Lock()
898- win.screen.Resize(sz.Cols, sz.Rows)
899- win.size = sz
900- ac.rend.Reset()
901- data, err := ac.rend.Draw(win.screen.Frame, win.screen.Damage())
902- win.screen.ClearDamage()
903- win.mu.Unlock()
950+func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
951+ for _, r := range text {
952+ if *x >= len(row) {
953+ return
954+ }
955+ row[*x] = renderer.Cell{Rune: r, Style: style}
956+ (*x)++
957+ }
958+}
904959
905- var serr error
906- if err == nil && len(data) > 0 {
907- serr = ac.tr.Send(frameOutput(data))
960+func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
961+ if !sz.Valid() {
962+ return
908963 }
909- ac.sendMu.Unlock()
964+ winSize := windowSize(sz)
965+ ac.size = sz
910966
911- if err != nil {
912- d.log.Error("resize draw failed", "err", err, "session", sess.name)
967+ sess.mu.Lock()
968+ windows := append([]*window(nil), sess.windows...)
969+ sess.mu.Unlock()
970+ if len(windows) == 0 {
913971 return
914972 }
915- if serr != nil {
916- d.detachOnSendError(sess, ac)
973+
974+ for _, win := range windows {
975+ if err := win.pty.Resize(winSize); err != nil {
976+ d.log.Warn("pty resize failed", "err", err, "session", sess.name)
977+ }
978+ win.mu.Lock()
979+ win.screen.Resize(winSize.Cols, winSize.Rows)
980+ win.size = winSize
981+ win.mu.Unlock()
917982 }
983+ d.paint(sess, ac, true)
918984 }
919985
920986 // detachOnSendError drops a client whose transport failed. Like every other
@@ -490,7 +490,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
490 // createSessionLocked spawns a PTY-backed session and starts its reader and490 // createSessionLocked spawns a PTY-backed session and starts its reader and
491 // scheduler goroutines. Caller holds d.mu.491 // scheduler goroutines. Caller holds d.mu.
492 func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size) (*session, error) {492 func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size) (*session, error) {
493- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), sz)493+ winSize := windowSize(sz)
494+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), winSize)
494 if err != nil {495 if err != nil {
495 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)496 return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
496 }497 }
@@ -500,9 +501,9 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
500 501
501 win := &window{502 win := &window{
502 pty: pty,503 pty: pty,
503- screen: vt.NewScreen(sz.Cols, sz.Rows),504+ screen: vt.NewScreen(winSize.Cols, winSize.Rows),
504 dirty: make(chan struct{}, 1),505 dirty: make(chan struct{}, 1),
505- size: sz,506+ size: winSize,
506 }507 }
507 sctx, cancel := context.WithCancel(d.serveCtx)508 sctx, cancel := context.WithCancel(d.serveCtx)
508 sess := &session{509 sess := &session{
@@ -519,15 +520,16 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size
519 }520 }
520 521
521 func (d *Daemon) createWindow(sess *session, sz domain.Size) error {522 func (d *Daemon) createWindow(sess *session, sz domain.Size) error {
522- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), sz)523+ winSize := windowSize(sz)
524+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(sess.name), winSize)
523 if err != nil {525 if err != nil {
524 return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err)526 return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err)
525 }527 }
526 win := &window{528 win := &window{
527 pty: pty,529 pty: pty,
528- screen: vt.NewScreen(sz.Cols, sz.Rows),530+ screen: vt.NewScreen(winSize.Cols, winSize.Rows),
529 dirty: make(chan struct{}, 1),531 dirty: make(chan struct{}, 1),
530- size: sz,532+ size: winSize,
531 }533 }
532 sess.mu.Lock()534 sess.mu.Lock()
533 sess.windows = append(sess.windows, win)535 sess.windows = append(sess.windows, win)
@@ -537,6 +539,17 @@ func (d *Daemon) createWindow(sess *session, sz domain.Size) error {
537 return nil539 return nil
538 }540 }
539 541
542+func windowSize(clientSize domain.Size) domain.Size {
543+ if !clientSize.Valid() {
544+ clientSize = defaultSize
545+ }
546+ rows := clientSize.Rows - 1
547+ if rows < 1 {
548+ rows = 1
549+ }
550+ return domain.Size{Cols: clientSize.Cols, Rows: rows}
551+}
552+
540 func (d *Daemon) startWindowGoroutines(sess *session, win *window) {553 func (d *Daemon) startWindowGoroutines(sess *session, win *window) {
541 d.sessWg.Add(2)554 d.sessWg.Add(2)
542 go d.ptyReader(sess, win)555 go d.ptyReader(sess, win)
@@ -578,7 +591,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
578 wsz := win.size591 wsz := win.size
579 win.mu.Unlock()592 win.mu.Unlock()
580 593
581- if clientSize.Valid() && wsz != clientSize {594+ if clientSize.Valid() && wsz != windowSize(clientSize) {
582 d.resize(sess, ac, clientSize)595 d.resize(sess, ac, clientSize)
583 return596 return
584 }597 }
@@ -704,6 +717,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
704 // M4 will install copy-mode state here; for M3 the binding is intentionally intercepted.717 // M4 will install copy-mode state here; for M3 the binding is intentionally intercepted.
705 case keys.ActionRenameSession:718 case keys.ActionRenameSession:
706 h.sess.promoteEphemeral()719 h.sess.promoteEphemeral()
720+ h.d.paint(h.sess, h.ac, true)
707 case keys.ActionSwitchWindow1, keys.ActionSwitchWindow2, keys.ActionSwitchWindow3,721 case keys.ActionSwitchWindow1, keys.ActionSwitchWindow2, keys.ActionSwitchWindow3,
708 keys.ActionSwitchWindow4, keys.ActionSwitchWindow5, keys.ActionSwitchWindow6,722 keys.ActionSwitchWindow4, keys.ActionSwitchWindow5, keys.ActionSwitchWindow6,
709 keys.ActionSwitchWindow7, keys.ActionSwitchWindow8, keys.ActionSwitchWindow9:723 keys.ActionSwitchWindow7, keys.ActionSwitchWindow8, keys.ActionSwitchWindow9:
@@ -844,9 +858,9 @@ func (d *Daemon) render(sess *session, win *window) {
844 d.paint(sess, ac, false)858 d.paint(sess, ac, false)
845 }859 }
846 860
847-// paint draws the screen for ac and sends the resulting bytes. The renderer861+// paint draws the composed client frame (active window plus status bar) and
848-// shadow is read/written under win.mu; sendMu makes Draw→Send atomic per client862+// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
849-// so the scheduler and a concurrent resize never interleave their output.863+// such as switch/create/close/rename/resize so the repaint is complete.
850 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {864 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
851 win := sess.activeWindow()865 win := sess.activeWindow()
852 if win == nil {866 if win == nil {
@@ -858,7 +872,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
858 if reset {872 if reset {
859 ac.rend.Reset()873 ac.rend.Reset()
860 }874 }
861- data, err := ac.rend.Draw(win.screen.Frame, win.screen.Damage())875+ frame, damage := composeClientFrame(sess, win, reset)
876+ data, err := ac.rend.Draw(frame, damage)
862 win.screen.ClearDamage()877 win.screen.ClearDamage()
863 win.mu.Unlock()878 win.mu.Unlock()
864 879
@@ -877,44 +892,95 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
877 }892 }
878 }893 }
879 894
880-// resize applies a client size change in the plan's strict order: pty.Resize,895+// resize applies a client size change to every window using rows-1 for the PTY
881-// then screen.Resize under the window lock, then a renderer reset + full896+// and VT screen, then resets the renderer shadow and repaints the composed
882-// redraw, then send.897+// client-sized frame (including the status row).
883-func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {898+func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) {
884- if !sz.Valid() {899+ width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height
885- return900+ frame := renderer.NewFrame(width, screenRows+1)
886- }901+ for y := 0; y < screenRows; y++ {
887- win := sess.activeWindow()902+ copy(frame.Row(y), win.screen.Frame.Row(y))
888- if win == nil {903+ }
889- return904+ drawStatus(frame.Row(screenRows), sess)
905+ if full {
906+ return frame, []renderer.Damage{renderer.FullRedraw()}
907+ }
908+ damage := append([]renderer.Damage(nil), win.screen.Damage()...)
909+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows, Width: width, Height: 1})
910+ return frame, damage
911+}
912+
913+func drawStatus(row []renderer.Cell, sess *session) {
914+ for i := range row {
915+ row[i] = renderer.BlankCell()
916+ }
917+ status := sess.statusSegments()
918+ x := 0
919+ writeStatusText(row, &x, " "+status.session+" ", renderer.DefaultStyle())
920+ for _, w := range status.windows {
921+ style := renderer.DefaultStyle()
922+ if w.active {
923+ style.Inverse = true
924+ }
925+ writeStatusText(row, &x, " "+w.name+" ", style)
890 }926 }
927+}
928+
929+type statusSnapshot struct {
930+ session string
931+ windows []statusWindow
932+}
933+
934+type statusWindow struct {
935+ name string
936+ active bool
937+}
891 938
892- if err := win.pty.Resize(sz); err != nil {939+func (s *session) statusSegments() statusSnapshot {
893- d.log.Warn("pty resize failed", "err", err, "session", sess.name)940+ s.mu.Lock()
941+ defer s.mu.Unlock()
942+ snap := statusSnapshot{session: s.name, windows: make([]statusWindow, len(s.windows))}
943+ for i := range s.windows {
944+ name := strconv.Itoa(i + 1)
945+ snap.windows[i] = statusWindow{name: name, active: i == s.active}
894 }946 }
947+ return snap
948+}
895 949
896- ac.sendMu.Lock()950+func writeStatusText(row []renderer.Cell, x *int, text string, style renderer.Style) {
897- win.mu.Lock()951+ for _, r := range text {
898- win.screen.Resize(sz.Cols, sz.Rows)952+ if *x >= len(row) {
899- win.size = sz953+ return
900- ac.rend.Reset()954+ }
901- data, err := ac.rend.Draw(win.screen.Frame, win.screen.Damage())955+ row[*x] = renderer.Cell{Rune: r, Style: style}
902- win.screen.ClearDamage()956+ (*x)++
903- win.mu.Unlock()957+ }
958+}
904 959
905- var serr error960+func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
906- if err == nil && len(data) > 0 {961+ if !sz.Valid() {
907- serr = ac.tr.Send(frameOutput(data))962+ return
908 }963 }
909- ac.sendMu.Unlock()964+ winSize := windowSize(sz)
965+ ac.size = sz
910 966
911- if err != nil {967+ sess.mu.Lock()
912- d.log.Error("resize draw failed", "err", err, "session", sess.name)968+ windows := append([]*window(nil), sess.windows...)
969+ sess.mu.Unlock()
970+ if len(windows) == 0 {
913 return971 return
914 }972 }
915- if serr != nil {973+
916- d.detachOnSendError(sess, ac)974+ for _, win := range windows {
975+ if err := win.pty.Resize(winSize); err != nil {
976+ d.log.Warn("pty resize failed", "err", err, "session", sess.name)
977+ }
978+ win.mu.Lock()
979+ win.screen.Resize(winSize.Cols, winSize.Rows)
980+ win.size = winSize
981+ win.mu.Unlock()
917 }982 }
983+ d.paint(sess, ac, true)
918 }984 }
919 985
920 // detachOnSendError drops a client whose transport failed. Like every other986 // detachOnSendError drops a client whose transport failed. Like every other
M internal/usecase/daemon/daemon_test.go +74 -3
@@ -207,7 +207,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
207207 sctx, cancel := context.WithCancel(d.serveCtx)
208208 windows := make([]*window, 0, len(ptys))
209209 for _, p := range ptys {
210- windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}})
210+ windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}})
211211 }
212212 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac}
213213 d.sessions[sess.id] = sess
@@ -476,7 +476,7 @@ func TestAltDDetachesCurrentClient(t *testing.T) {
476476 }
477477
478478 func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
479- d, sess, ac, _, releases := newManualWindowSession(t, 1)
479+ d, sess, ac, sends, releases := newManualWindowSession(t, 1)
480480 defer releases[0]()
481481 sess.ephemeral = true
482482 sess.name = "0"
@@ -485,6 +485,76 @@ func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
485485
486486 require.False(t, sess.ephemeral)
487487 require.Equal(t, "0", sess.name)
488+ awaitFrame(t, sends, ports.MsgOutput)
489+}
490+
491+func TestStatusCompositionGolden(t *testing.T) {
492+ p1, releasePTY1 := newBlockingPTY(t)
493+ p2, releasePTY2 := newBlockingPTY(t)
494+ _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
495+ defer releasePTY1()
496+ defer releasePTY2()
497+ sess.active = 1
498+ sess.name = "work"
499+
500+ win := sess.activeWindow()
501+ win.screen = vt.NewScreen(12, 2)
502+ win.size = domain.Size{Cols: 12, Rows: 2}
503+ win.screen.Write([]byte("hello"))
504+
505+ frame, damage := composeClientFrame(sess, win, true)
506+
507+ require.Equal(t, 12, frame.Width)
508+ require.Equal(t, 3, frame.Height)
509+ require.Equal(t, "hello ", rowText(frame.Row(0)))
510+ require.Equal(t, " work 1 2 ", rowText(frame.Row(2)))
511+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
512+ for i, c := range frame.Row(2) {
513+ if i >= len(" work 1 ") && i < len(" work 1 2 ") {
514+ require.True(t, c.Style.Inverse, "active window segment cell %d should be inverse", i)
515+ }
516+ }
517+}
518+
519+func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
520+ p1, releasePTY1 := newBlockingPTY(t)
521+ p2, releasePTY2 := newBlockingPTY(t)
522+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
523+ tr, sends, releaseConn := newConn(t,
524+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
525+ frameInput([]byte("\x1bc")),
526+ frameInput([]byte("\x1b1")),
527+ ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},
528+ )
529+
530+ var hg sync.WaitGroup
531+ hg.Go(func() { d.handleConn(tr) })
532+ awaitFrame(t, sends, ports.MsgWelcome)
533+ first := awaitFrame(t, sends, ports.MsgOutput)
534+ created := awaitFrame(t, sends, ports.MsgOutput)
535+ switched := awaitFrame(t, sends, ports.MsgOutput)
536+ resized := awaitFrame(t, sends, ports.MsgOutput)
537+
538+ for _, f := range []ports.Frame{first, created, switched, resized} {
539+ out, err := ports.UnmarshalOutput(f.Payload)
540+ require.NoError(t, err)
541+ require.Contains(t, string(out.Data), "work")
542+ require.Contains(t, string(out.Data), ";7m", "active status window should be inverse-highlighted")
543+ }
544+
545+ releaseConn()
546+ releasePTY1()
547+ releasePTY2()
548+ hg.Wait()
549+ d.sessWg.Wait()
550+}
551+
552+func rowText(row []renderer.Cell) string {
553+ runes := make([]rune, len(row))
554+ for i, c := range row {
555+ runes[i] = c.Rune
556+ }
557+ return string(runes)
488558 }
489559
490560 func TestAttachReplaceDetachesOld(t *testing.T) {
@@ -646,7 +716,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
646716 p := portsmocks.NewMockPTY(t)
647717 var screenWidthAtResize int
648718 win := &window{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
649- p.EXPECT().Resize(newSize).RunAndReturn(func(sz domain.Size) error {
719+ p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error {
650720 // The screen must not yet be resized when the PTY is: proves order.
651721 screenWidthAtResize = win.screen.Frame.Width
652722 return nil
@@ -670,6 +740,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
670740
671741 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
672742 require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")
743+ require.Equal(t, 29, win.screen.Frame.Height, "screen reserves bottom status row")
673744 require.True(t, gotOutput.Load(), "resize forces a full redraw output")
674745 }
675746
@@ -207,7 +207,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio
207 sctx, cancel := context.WithCancel(d.serveCtx)207 sctx, cancel := context.WithCancel(d.serveCtx)
208 windows := make([]*window, 0, len(ptys))208 windows := make([]*window, 0, len(ptys))
209 for _, p := range ptys {209 for _, p := range ptys {
210- windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}})210+ windows = append(windows, &window{pty: p, screen: vt.NewScreen(80, 23), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 23}})
211 }211 }
212 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac}212 sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac}
213 d.sessions[sess.id] = sess213 d.sessions[sess.id] = sess
@@ -476,7 +476,7 @@ func TestAltDDetachesCurrentClient(t *testing.T) {
476 }476 }
477 477
478 func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {478 func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
479- d, sess, ac, _, releases := newManualWindowSession(t, 1)479+ d, sess, ac, sends, releases := newManualWindowSession(t, 1)
480 defer releases[0]()480 defer releases[0]()
481 sess.ephemeral = true481 sess.ephemeral = true
482 sess.name = "0"482 sess.name = "0"
@@ -485,6 +485,76 @@ func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) {
485 485
486 require.False(t, sess.ephemeral)486 require.False(t, sess.ephemeral)
487 require.Equal(t, "0", sess.name)487 require.Equal(t, "0", sess.name)
488+ awaitFrame(t, sends, ports.MsgOutput)
489+}
490+
491+func TestStatusCompositionGolden(t *testing.T) {
492+ p1, releasePTY1 := newBlockingPTY(t)
493+ p2, releasePTY2 := newBlockingPTY(t)
494+ _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
495+ defer releasePTY1()
496+ defer releasePTY2()
497+ sess.active = 1
498+ sess.name = "work"
499+
500+ win := sess.activeWindow()
501+ win.screen = vt.NewScreen(12, 2)
502+ win.size = domain.Size{Cols: 12, Rows: 2}
503+ win.screen.Write([]byte("hello"))
504+
505+ frame, damage := composeClientFrame(sess, win, true)
506+
507+ require.Equal(t, 12, frame.Width)
508+ require.Equal(t, 3, frame.Height)
509+ require.Equal(t, "hello ", rowText(frame.Row(0)))
510+ require.Equal(t, " work 1 2 ", rowText(frame.Row(2)))
511+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
512+ for i, c := range frame.Row(2) {
513+ if i >= len(" work 1 ") && i < len(" work 1 2 ") {
514+ require.True(t, c.Style.Inverse, "active window segment cell %d should be inverse", i)
515+ }
516+ }
517+}
518+
519+func TestStatusRepaintsOnCreateSwitchAndResize(t *testing.T) {
520+ p1, releasePTY1 := newBlockingPTY(t)
521+ p2, releasePTY2 := newBlockingPTY(t)
522+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
523+ tr, sends, releaseConn := newConn(t,
524+ mustHello(ports.IntentNew, "work", domain.Size{Cols: 20, Rows: 5}),
525+ frameInput([]byte("\x1bc")),
526+ frameInput([]byte("\x1b1")),
527+ ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},
528+ )
529+
530+ var hg sync.WaitGroup
531+ hg.Go(func() { d.handleConn(tr) })
532+ awaitFrame(t, sends, ports.MsgWelcome)
533+ first := awaitFrame(t, sends, ports.MsgOutput)
534+ created := awaitFrame(t, sends, ports.MsgOutput)
535+ switched := awaitFrame(t, sends, ports.MsgOutput)
536+ resized := awaitFrame(t, sends, ports.MsgOutput)
537+
538+ for _, f := range []ports.Frame{first, created, switched, resized} {
539+ out, err := ports.UnmarshalOutput(f.Payload)
540+ require.NoError(t, err)
541+ require.Contains(t, string(out.Data), "work")
542+ require.Contains(t, string(out.Data), ";7m", "active status window should be inverse-highlighted")
543+ }
544+
545+ releaseConn()
546+ releasePTY1()
547+ releasePTY2()
548+ hg.Wait()
549+ d.sessWg.Wait()
550+}
551+
552+func rowText(row []renderer.Cell) string {
553+ runes := make([]rune, len(row))
554+ for i, c := range row {
555+ runes[i] = c.Rune
556+ }
557+ return string(runes)
488 }558 }
489 559
490 func TestAttachReplaceDetachesOld(t *testing.T) {560 func TestAttachReplaceDetachesOld(t *testing.T) {
@@ -646,7 +716,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
646 p := portsmocks.NewMockPTY(t)716 p := portsmocks.NewMockPTY(t)
647 var screenWidthAtResize int717 var screenWidthAtResize int
648 win := &window{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}718 win := &window{screen: vt.NewScreen(80, 24), dirty: make(chan struct{}, 1), size: domain.Size{Cols: 80, Rows: 24}}
649- p.EXPECT().Resize(newSize).RunAndReturn(func(sz domain.Size) error {719+ p.EXPECT().Resize(domain.Size{Cols: 100, Rows: 29}).RunAndReturn(func(sz domain.Size) error {
650 // The screen must not yet be resized when the PTY is: proves order.720 // The screen must not yet be resized when the PTY is: proves order.
651 screenWidthAtResize = win.screen.Frame.Width721 screenWidthAtResize = win.screen.Frame.Width
652 return nil722 return nil
@@ -670,6 +740,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
670 740
671 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")741 require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize")
672 require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")742 require.Equal(t, 100, win.screen.Frame.Width, "screen resized after pty")
743+ require.Equal(t, 29, win.screen.Frame.Height, "screen reserves bottom status row")
673 require.True(t, gotOutput.Load(), "resize forces a full redraw output")744 require.True(t, gotOutput.Load(), "resize forces a full redraw output")
674 }745 }
675 746

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