feat(daemon): compose status bar
- commit
- 4428ba6d87fa654aa5d67b5174bf64aedfe1bc02
- author
- brice · · 2026-07-02 14:49 UTC
- parent
- 98c7add
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 490 | 490 | // createSessionLocked spawns a PTY-backed session and starts its reader and | |
| 491 | 491 | // scheduler goroutines. Caller holds d.mu. | |
| 492 | 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 | 495 | if err != nil { | |
| 495 | 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 | 502 | win := &window{ | |
| 502 | 503 | pty: pty, | |
| 503 | - | screen: vt.NewScreen(sz.Cols, sz.Rows), | |
| 504 | + | screen: vt.NewScreen(winSize.Cols, winSize.Rows), | |
| 504 | 505 | dirty: make(chan struct{}, 1), | |
| 505 | - | size: sz, | |
| 506 | + | size: winSize, | |
| 506 | 507 | } | |
| 507 | 508 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 508 | 509 | sess := &session{ | |
| @@ -519,15 +520,16 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, sz domain.Size | |||
| 519 | 520 | } | |
| 520 | 521 | ||
| 521 | 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 | 525 | if err != nil { | |
| 524 | 526 | return fmt.Errorf("daemon: spawning window for session %q: %w", sess.name, err) | |
| 525 | 527 | } | |
| 526 | 528 | win := &window{ | |
| 527 | 529 | pty: pty, | |
| 528 | - | screen: vt.NewScreen(sz.Cols, sz.Rows), | |
| 530 | + | screen: vt.NewScreen(winSize.Cols, winSize.Rows), | |
| 529 | 531 | dirty: make(chan struct{}, 1), | |
| 530 | - | size: sz, | |
| 532 | + | size: winSize, | |
| 531 | 533 | } | |
| 532 | 534 | sess.mu.Lock() | |
| 533 | 535 | sess.windows = append(sess.windows, win) | |
| @@ -537,6 +539,17 @@ func (d *Daemon) createWindow(sess *session, sz domain.Size) error { | |||
| 537 | 539 | 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 | 553 | func (d *Daemon) startWindowGoroutines(sess *session, win *window) { | |
| 541 | 554 | d.sessWg.Add(2) | |
| 542 | 555 | go d.ptyReader(sess, win) | |
| @@ -578,7 +591,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 578 | 591 | wsz := win.size | |
| 579 | 592 | win.mu.Unlock() | |
| 580 | 593 | ||
| 581 | - | if clientSize.Valid() && wsz != clientSize { | |
| 594 | + | if clientSize.Valid() && wsz != windowSize(clientSize) { | |
| 582 | 595 | d.resize(sess, ac, clientSize) | |
| 583 | 596 | return | |
| 584 | 597 | } | |
| @@ -704,6 +717,7 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 704 | 717 | // M4 will install copy-mode state here; for M3 the binding is intentionally intercepted. | |
| 705 | 718 | case keys.ActionRenameSession: | |
| 706 | 719 | h.sess.promoteEphemeral() | |
| 720 | + | h.d.paint(h.sess, h.ac, true) | |
| 707 | 721 | case keys.ActionSwitchWindow1, keys.ActionSwitchWindow2, keys.ActionSwitchWindow3, | |
| 708 | 722 | keys.ActionSwitchWindow4, keys.ActionSwitchWindow5, keys.ActionSwitchWindow6, | |
| 709 | 723 | keys.ActionSwitchWindow7, keys.ActionSwitchWindow8, keys.ActionSwitchWindow9: | |
| @@ -844,9 +858,9 @@ func (d *Daemon) render(sess *session, win *window) { | |||
| 844 | 858 | d.paint(sess, ac, false) | |
| 845 | 859 | } | |
| 846 | 860 | ||
| 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. | |
| 850 | 864 | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |
| 851 | 865 | win := sess.activeWindow() | |
| 852 | 866 | if win == nil { | |
| @@ -858,7 +872,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 858 | 872 | if reset { | |
| 859 | 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 | 877 | win.screen.ClearDamage() | |
| 863 | 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, | |
| 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) | |
| 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 { | |
| 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} | |
| 894 | 946 | } | |
| 947 | + | return snap | |
| 948 | + | } | |
| 895 | 949 | ||
| 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 | + | } | |
| 904 | 959 | ||
| 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 | |
| 908 | 963 | } | |
| 909 | - | ac.sendMu.Unlock() | |
| 964 | + | winSize := windowSize(sz) | |
| 965 | + | ac.size = sz | |
| 910 | 966 | ||
| 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 { | |
| 913 | 971 | return | |
| 914 | 972 | } | |
| 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() | |
| 917 | 982 | } | |
| 983 | + | d.paint(sess, ac, true) | |
| 918 | 984 | } | |
| 919 | 985 | ||
| 920 | 986 | // 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 and | 490 | // 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 nil | 539 | 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.size | 591 | 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 | return | 596 | 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 renderer | 861 | + | // 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 client | 862 | + | // 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 + full | 896 | + | // 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 | - | return | 900 | + | 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 | - | return | 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) | |||
| 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 = sz | 953 | + | 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 error | 960 | + | 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 | return | 971 | 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 other | 986 | // 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 | |||
| 207 | 207 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 208 | 208 | windows := make([]*window, 0, len(ptys)) | |
| 209 | 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 | 212 | sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, windows: windows, client: ac} | |
| 213 | 213 | d.sessions[sess.id] = sess | |
| @@ -476,7 +476,7 @@ func TestAltDDetachesCurrentClient(t *testing.T) { | |||
| 476 | 476 | } | |
| 477 | 477 | ||
| 478 | 478 | func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) { | |
| 479 | - | d, sess, ac, _, releases := newManualWindowSession(t, 1) | |
| 479 | + | d, sess, ac, sends, releases := newManualWindowSession(t, 1) | |
| 480 | 480 | defer releases[0]() | |
| 481 | 481 | sess.ephemeral = true | |
| 482 | 482 | sess.name = "0" | |
| @@ -485,6 +485,76 @@ func TestAltRPromotesEphemeralSessionPromptlessly(t *testing.T) { | |||
| 485 | 485 | ||
| 486 | 486 | require.False(t, sess.ephemeral) | |
| 487 | 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 | 560 | func TestAttachReplaceDetachesOld(t *testing.T) { | |
| @@ -646,7 +716,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||
| 646 | 716 | p := portsmocks.NewMockPTY(t) | |
| 647 | 717 | var screenWidthAtResize int | |
| 648 | 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 | 720 | // The screen must not yet be resized when the PTY is: proves order. | |
| 651 | 721 | screenWidthAtResize = win.screen.Frame.Width | |
| 652 | 722 | return nil | |
| @@ -670,6 +740,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||
| 670 | 740 | ||
| 671 | 741 | require.Equal(t, 80, screenWidthAtResize, "pty.Resize must run before screen.Resize") | |
| 672 | 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 | 744 | require.True(t, gotOutput.Load(), "resize forces a full redraw output") | |
| 674 | 745 | } | |
| 675 | 746 | ||
| @@ -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] = sess | 213 | 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 = true | 481 | 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 int | 717 | 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.Width | 721 | screenWidthAtResize = win.screen.Frame.Width | ||
| 652 | return nil | 722 | 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 | ||||