fix(daemon): preserve terminal-only pane titles
- commit
- 77ab500184a5405cf518e5a5e26401efa7f510f4
- author
- brice · · 2026-07-09 23:36 UTC
- committer
- brice · · 2026-07-10 05:40 UTC
- parent
- af6c204
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +53 -15
M internal/usecase/daemon/pane.go +1 -0
| @@ -48,6 +48,7 @@ func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz do | |||
| 48 | 48 | dirty: make(chan struct{}, 1), | |
| 49 | 49 | flush: make(chan struct{}, 1), | |
| 50 | 50 | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | |
| 51 | + | title: paneTitleState{displayFallback: "sh"}, | |
| 51 | 52 | } | |
| 52 | 53 | } | |
| 53 | 54 | ||
| @@ -48,6 +48,7 @@ func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz do | |||||
| 48 | dirty: make(chan struct{}, 1), | 48 | dirty: make(chan struct{}, 1), | ||
| 49 | flush: make(chan struct{}, 1), | 49 | flush: make(chan struct{}, 1), | ||
| 50 | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | 50 | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | ||
| 51 | + | title: paneTitleState{displayFallback: "sh"}, | |||
| 51 | } | 52 | } | ||
| 52 | } | 53 | } | ||
| 53 | 54 | ||||
M internal/usecase/daemon/pane_titles.go +22 -7
| @@ -18,6 +18,7 @@ type paneTitleState struct { | |||
| 18 | 18 | processNameAt time.Time | |
| 19 | 19 | processNameValid bool | |
| 20 | 20 | terminalTitle string | |
| 21 | + | displayFallback string | |
| 21 | 22 | generation uint64 | |
| 22 | 23 | } | |
| 23 | 24 | ||
| @@ -38,6 +39,10 @@ func (p *pane) formattedTitleLocked(fallback string) string { | |||
| 38 | 39 | return formatPaneTitle(p.title.processName, p.title.terminalTitle, fallback) | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 42 | + | func (p *pane) displayTitleLocked() string { | |
| 43 | + | return p.formattedTitleLocked(p.title.displayFallback) | |
| 44 | + | } | |
| 45 | + | ||
| 41 | 46 | // refreshTerminalTitleLocked synchronizes the title retained by the VT parser. | |
| 42 | 47 | // It must be called while p.mu is held, including by ptyReader around Write. | |
| 43 | 48 | func (p *pane) refreshTerminalTitleLocked() { | |
| @@ -94,13 +99,21 @@ func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string | |||
| 94 | 99 | now := d.clock.Now() | |
| 95 | 100 | p.mu.Lock() | |
| 96 | 101 | if !force && p.title.processNameValid && now.Sub(p.title.processNameAt) < paneTitleCacheTTL { | |
| 97 | - | title := p.formattedTitleLocked(fallback) | |
| 102 | + | oldTitle := p.displayTitleLocked() | |
| 103 | + | p.title.displayFallback = fallback | |
| 104 | + | title := p.displayTitleLocked() | |
| 105 | + | if oldTitle != title { | |
| 106 | + | p.title.generation++ | |
| 107 | + | } | |
| 98 | 108 | p.mu.Unlock() | |
| 99 | 109 | return title | |
| 100 | 110 | } | |
| 101 | 111 | p.mu.Unlock() | |
| 102 | 112 | ||
| 103 | - | processName := fallback | |
| 113 | + | // A failed lookup means no process name was discovered. Keep that state | |
| 114 | + | // distinct from the display fallback: an OSC title must then stand alone, | |
| 115 | + | // and future floating panes can supply their command fallback at format time. | |
| 116 | + | processName := "" | |
| 104 | 117 | if d.procComm != nil && p.pty != nil { | |
| 105 | 118 | if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 { | |
| 106 | 119 | if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" { | |
| @@ -110,13 +123,15 @@ func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string | |||
| 110 | 123 | } | |
| 111 | 124 | ||
| 112 | 125 | p.mu.Lock() | |
| 113 | - | if p.title.processName != processName { | |
| 114 | - | p.title.processName = processName | |
| 115 | - | p.title.generation++ | |
| 116 | - | } | |
| 126 | + | oldTitle := p.displayTitleLocked() | |
| 127 | + | p.title.processName = processName | |
| 117 | 128 | p.title.processNameAt = now | |
| 118 | 129 | p.title.processNameValid = true | |
| 119 | - | title := p.formattedTitleLocked(fallback) | |
| 130 | + | p.title.displayFallback = fallback | |
| 131 | + | title := p.displayTitleLocked() | |
| 132 | + | if oldTitle != title { | |
| 133 | + | p.title.generation++ | |
| 134 | + | } | |
| 120 | 135 | p.mu.Unlock() | |
| 121 | 136 | return title | |
| 122 | 137 | } | |
| @@ -18,6 +18,7 @@ type paneTitleState struct { | |||||
| 18 | processNameAt time.Time | 18 | processNameAt time.Time | ||
| 19 | processNameValid bool | 19 | processNameValid bool | ||
| 20 | terminalTitle string | 20 | terminalTitle string | ||
| 21 | + | displayFallback string | |||
| 21 | generation uint64 | 22 | generation uint64 | ||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| @@ -38,6 +39,10 @@ func (p *pane) formattedTitleLocked(fallback string) string { | |||||
| 38 | return formatPaneTitle(p.title.processName, p.title.terminalTitle, fallback) | 39 | return formatPaneTitle(p.title.processName, p.title.terminalTitle, fallback) | ||
| 39 | } | 40 | } | ||
| 40 | 41 | ||||
| 42 | + | func (p *pane) displayTitleLocked() string { | |||
| 43 | + | return p.formattedTitleLocked(p.title.displayFallback) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 41 | // refreshTerminalTitleLocked synchronizes the title retained by the VT parser. | 46 | // refreshTerminalTitleLocked synchronizes the title retained by the VT parser. | ||
| 42 | // It must be called while p.mu is held, including by ptyReader around Write. | 47 | // It must be called while p.mu is held, including by ptyReader around Write. | ||
| 43 | func (p *pane) refreshTerminalTitleLocked() { | 48 | func (p *pane) refreshTerminalTitleLocked() { | ||
| @@ -94,13 +99,21 @@ func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string | |||||
| 94 | now := d.clock.Now() | 99 | now := d.clock.Now() | ||
| 95 | p.mu.Lock() | 100 | p.mu.Lock() | ||
| 96 | if !force && p.title.processNameValid && now.Sub(p.title.processNameAt) < paneTitleCacheTTL { | 101 | if !force && p.title.processNameValid && now.Sub(p.title.processNameAt) < paneTitleCacheTTL { | ||
| 97 | - | title := p.formattedTitleLocked(fallback) | 102 | + | oldTitle := p.displayTitleLocked() |
| 103 | + | p.title.displayFallback = fallback | |||
| 104 | + | title := p.displayTitleLocked() | |||
| 105 | + | if oldTitle != title { | |||
| 106 | + | p.title.generation++ | |||
| 107 | + | } | |||
| 98 | p.mu.Unlock() | 108 | p.mu.Unlock() | ||
| 99 | return title | 109 | return title | ||
| 100 | } | 110 | } | ||
| 101 | p.mu.Unlock() | 111 | p.mu.Unlock() | ||
| 102 | 112 | ||||
| 103 | - | processName := fallback | 113 | + | // A failed lookup means no process name was discovered. Keep that state |
| 114 | + | // distinct from the display fallback: an OSC title must then stand alone, | |||
| 115 | + | // and future floating panes can supply their command fallback at format time. | |||
| 116 | + | processName := "" | |||
| 104 | if d.procComm != nil && p.pty != nil { | 117 | if d.procComm != nil && p.pty != nil { | ||
| 105 | if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 { | 118 | if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 { | ||
| 106 | if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" { | 119 | if comm, err := d.procComm(pgid); err == nil && strings.TrimSpace(comm) != "" { | ||
| @@ -110,13 +123,15 @@ func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string | |||||
| 110 | } | 123 | } | ||
| 111 | 124 | ||||
| 112 | p.mu.Lock() | 125 | p.mu.Lock() | ||
| 113 | - | if p.title.processName != processName { | 126 | + | oldTitle := p.displayTitleLocked() |
| 114 | - | p.title.processName = processName | 127 | + | p.title.processName = processName |
| 115 | - | p.title.generation++ | |||
| 116 | - | } | |||
| 117 | p.title.processNameAt = now | 128 | p.title.processNameAt = now | ||
| 118 | p.title.processNameValid = true | 129 | p.title.processNameValid = true | ||
| 119 | - | title := p.formattedTitleLocked(fallback) | 130 | + | p.title.displayFallback = fallback |
| 131 | + | title := p.displayTitleLocked() | |||
| 132 | + | if oldTitle != title { | |||
| 133 | + | p.title.generation++ | |||
| 134 | + | } | |||
| 120 | p.mu.Unlock() | 135 | p.mu.Unlock() | ||
| 121 | return title | 136 | return title | ||
| 122 | } | 137 | } | ||
M internal/usecase/daemon/pane_titles_test.go +16 -5
| @@ -95,18 +95,29 @@ func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) { | |||
| 95 | 95 | require.Equal(t, int32(2), calls.Load(), "focus refresh should bypass TTL") | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | - | func TestRefreshPaneTitleFallsBackToShellBase(t *testing.T) { | |
| 98 | + | func TestRefreshPaneTitleLookupFailureKeepsProcessNameEmpty(t *testing.T) { | |
| 99 | 99 | pty := portsmocks.NewMockPTY(t) | |
| 100 | - | pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Once() | |
| 100 | + | pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Twice() | |
| 101 | 101 | _, sess, _, _ := newManualSessionWithPTYs(t, pty) | |
| 102 | 102 | clk := portsmocks.NewMockClock(t) | |
| 103 | 103 | clk.EXPECT().Now().Return(time.Time{}).Maybe() | |
| 104 | 104 | d := newTestDaemon(t, nil, clk) | |
| 105 | 105 | d.shell = "/usr/bin/fish" | |
| 106 | 106 | d.procComm = func(int) (string, error) { return "", errors.New("unused") } | |
| 107 | + | p := sess.activeTab().focusedPane() | |
| 107 | 108 | ||
| 108 | - | title := d.refreshPaneTitle(sess, "pane-1") | |
| 109 | + | p.mu.Lock() | |
| 110 | + | p.screen.Write([]byte("\x1b]2;terminal\a")) | |
| 111 | + | p.refreshTerminalTitleLocked() | |
| 112 | + | p.mu.Unlock() | |
| 113 | + | require.Equal(t, "terminal", d.refreshPaneDisplayTitle(sess, p, true)) | |
| 114 | + | p.mu.Lock() | |
| 115 | + | require.Empty(t, p.title.processName, "a lookup failure must not become process state") | |
| 116 | + | p.mu.Unlock() | |
| 109 | 117 | ||
| 110 | - | require.Equal(t, "fish", title) | |
| 111 | - | require.Equal(t, "fish", sess.activeTab().focusedPane().title.processName) | |
| 118 | + | p.mu.Lock() | |
| 119 | + | p.screen.Write([]byte("\x1b]2;\a")) | |
| 120 | + | p.refreshTerminalTitleLocked() | |
| 121 | + | p.mu.Unlock() | |
| 122 | + | require.Equal(t, "fish", d.refreshPaneDisplayTitle(sess, p, true)) | |
| 112 | 123 | } | |
| @@ -95,18 +95,29 @@ func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) { | |||||
| 95 | require.Equal(t, int32(2), calls.Load(), "focus refresh should bypass TTL") | 95 | require.Equal(t, int32(2), calls.Load(), "focus refresh should bypass TTL") | ||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | - | func TestRefreshPaneTitleFallsBackToShellBase(t *testing.T) { | 98 | + | func TestRefreshPaneTitleLookupFailureKeepsProcessNameEmpty(t *testing.T) { |
| 99 | pty := portsmocks.NewMockPTY(t) | 99 | pty := portsmocks.NewMockPTY(t) | ||
| 100 | - | pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Once() | 100 | + | pty.EXPECT().ForegroundPgid().Return(0, errors.New("no foreground process")).Twice() |
| 101 | _, sess, _, _ := newManualSessionWithPTYs(t, pty) | 101 | _, sess, _, _ := newManualSessionWithPTYs(t, pty) | ||
| 102 | clk := portsmocks.NewMockClock(t) | 102 | clk := portsmocks.NewMockClock(t) | ||
| 103 | clk.EXPECT().Now().Return(time.Time{}).Maybe() | 103 | clk.EXPECT().Now().Return(time.Time{}).Maybe() | ||
| 104 | d := newTestDaemon(t, nil, clk) | 104 | d := newTestDaemon(t, nil, clk) | ||
| 105 | d.shell = "/usr/bin/fish" | 105 | d.shell = "/usr/bin/fish" | ||
| 106 | d.procComm = func(int) (string, error) { return "", errors.New("unused") } | 106 | d.procComm = func(int) (string, error) { return "", errors.New("unused") } | ||
| 107 | + | p := sess.activeTab().focusedPane() | |||
| 107 | 108 | ||||
| 108 | - | title := d.refreshPaneTitle(sess, "pane-1") | 109 | + | p.mu.Lock() |
| 110 | + | p.screen.Write([]byte("\x1b]2;terminal\a")) | |||
| 111 | + | p.refreshTerminalTitleLocked() | |||
| 112 | + | p.mu.Unlock() | |||
| 113 | + | require.Equal(t, "terminal", d.refreshPaneDisplayTitle(sess, p, true)) | |||
| 114 | + | p.mu.Lock() | |||
| 115 | + | require.Empty(t, p.title.processName, "a lookup failure must not become process state") | |||
| 116 | + | p.mu.Unlock() | |||
| 109 | 117 | ||||
| 110 | - | require.Equal(t, "fish", title) | 118 | + | p.mu.Lock() |
| 111 | - | require.Equal(t, "fish", sess.activeTab().focusedPane().title.processName) | 119 | + | p.screen.Write([]byte("\x1b]2;\a")) |
| 120 | + | p.refreshTerminalTitleLocked() | |||
| 121 | + | p.mu.Unlock() | |||
| 122 | + | require.Equal(t, "fish", d.refreshPaneDisplayTitle(sess, p, true)) | |||
| 112 | } | 123 | } | ||
M internal/usecase/daemon/render.go +3 -3
| @@ -664,7 +664,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do | |||
| 664 | 664 | focused := tb.tree.Focus == pl.ID | |
| 665 | 665 | pl = offsetPlacement(pl, area.X, area.Y) | |
| 666 | 666 | if pl.TitleBar.Height > 0 { | |
| 667 | - | generation := drawPaneTitleBar(frame, pl, p, focused, theme, string(p.id)) | |
| 667 | + | generation := drawPaneTitleBar(frame, pl, p, focused, theme) | |
| 668 | 668 | if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) { | |
| 669 | 669 | titleDamage := pl.TitleBar | |
| 670 | 670 | titleDamage.X -= area.X | |
| @@ -737,7 +737,7 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo | |||
| 737 | 737 | } | |
| 738 | 738 | } | |
| 739 | 739 | ||
| 740 | - | func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme, fallback string) uint64 { | |
| 740 | + | func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 { | |
| 741 | 741 | styles := newThemeStyles(theme) | |
| 742 | 742 | style := styles.border | |
| 743 | 743 | if focused { | |
| @@ -749,7 +749,7 @@ func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focuse | |||
| 749 | 749 | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | |
| 750 | 750 | } | |
| 751 | 751 | p.mu.Lock() | |
| 752 | - | title := p.formattedTitleLocked(fallback) | |
| 752 | + | title := p.displayTitleLocked() | |
| 753 | 753 | generation := p.title.generation | |
| 754 | 754 | p.mu.Unlock() | |
| 755 | 755 | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | |
| @@ -664,7 +664,7 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do | |||||
| 664 | focused := tb.tree.Focus == pl.ID | 664 | focused := tb.tree.Focus == pl.ID | ||
| 665 | pl = offsetPlacement(pl, area.X, area.Y) | 665 | pl = offsetPlacement(pl, area.X, area.Y) | ||
| 666 | if pl.TitleBar.Height > 0 { | 666 | if pl.TitleBar.Height > 0 { | ||
| 667 | - | generation := drawPaneTitleBar(frame, pl, p, focused, theme, string(p.id)) | 667 | + | generation := drawPaneTitleBar(frame, pl, p, focused, theme) |
| 668 | if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) { | 668 | if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) { | ||
| 669 | titleDamage := pl.TitleBar | 669 | titleDamage := pl.TitleBar | ||
| 670 | titleDamage.X -= area.X | 670 | titleDamage.X -= area.X | ||
| @@ -737,7 +737,7 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo | |||||
| 737 | } | 737 | } | ||
| 738 | } | 738 | } | ||
| 739 | 739 | ||||
| 740 | - | func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme, fallback string) uint64 { | 740 | + | func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 { |
| 741 | styles := newThemeStyles(theme) | 741 | styles := newThemeStyles(theme) | ||
| 742 | style := styles.border | 742 | style := styles.border | ||
| 743 | if focused { | 743 | if focused { | ||
| @@ -749,7 +749,7 @@ func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focuse | |||||
| 749 | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | 749 | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | ||
| 750 | } | 750 | } | ||
| 751 | p.mu.Lock() | 751 | p.mu.Lock() | ||
| 752 | - | title := p.formattedTitleLocked(fallback) | 752 | + | title := p.displayTitleLocked() |
| 753 | generation := p.title.generation | 753 | generation := p.title.generation | ||
| 754 | p.mu.Unlock() | 754 | p.mu.Unlock() | ||
| 755 | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | 755 | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | ||
M internal/usecase/daemon/render_test.go +11 -0
| @@ -661,6 +661,17 @@ func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) { | |||
| 661 | 661 | } | |
| 662 | 662 | } | |
| 663 | 663 | ||
| 664 | + | func TestComposeTabFrameStackUsesShellFallback(t *testing.T) { | |
| 665 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |
| 666 | + | p := win.focusedPane() | |
| 667 | + | p.title.displayFallback = "fish" | |
| 668 | + | win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id} | |
| 669 | + | ||
| 670 | + | frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}) | |
| 671 | + | ||
| 672 | + | require.Equal(t, "fish", rowText(frame.Row(0))[:4]) | |
| 673 | + | } | |
| 674 | + | ||
| 664 | 675 | func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |
| 665 | 676 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |
| 666 | 677 | p1 := win.focusedPane() | |
| @@ -661,6 +661,17 @@ func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) { | |||||
| 661 | } | 661 | } | ||
| 662 | } | 662 | } | ||
| 663 | 663 | ||||
| 664 | + | func TestComposeTabFrameStackUsesShellFallback(t *testing.T) { | |||
| 665 | + | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |||
| 666 | + | p := win.focusedPane() | |||
| 667 | + | p.title.displayFallback = "fish" | |||
| 668 | + | win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id} | |||
| 669 | + | ||||
| 670 | + | frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}) | |||
| 671 | + | ||||
| 672 | + | require.Equal(t, "fish", rowText(frame.Row(0))[:4]) | |||
| 673 | + | } | |||
| 674 | + | ||||
| 664 | func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | 675 | func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | ||
| 665 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | 676 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | ||
| 666 | p1 := win.focusedPane() | 677 | p1 := win.focusedPane() | ||