detached 77ab500

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
4848 dirty: make(chan struct{}, 1),
4949 flush: make(chan struct{}, 1),
5050 rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
51+ title: paneTitleState{displayFallback: "sh"},
5152 }
5253 }
5354
@@ -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 {
1818 processNameAt time.Time
1919 processNameValid bool
2020 terminalTitle string
21+ displayFallback string
2122 generation uint64
2223 }
2324
@@ -38,6 +39,10 @@ func (p *pane) formattedTitleLocked(fallback string) string {
3839 return formatPaneTitle(p.title.processName, p.title.terminalTitle, fallback)
3940 }
4041
42+func (p *pane) displayTitleLocked() string {
43+ return p.formattedTitleLocked(p.title.displayFallback)
44+}
45+
4146 // refreshTerminalTitleLocked synchronizes the title retained by the VT parser.
4247 // It must be called while p.mu is held, including by ptyReader around Write.
4348 func (p *pane) refreshTerminalTitleLocked() {
@@ -94,13 +99,21 @@ func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string
9499 now := d.clock.Now()
95100 p.mu.Lock()
96101 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+ }
98108 p.mu.Unlock()
99109 return title
100110 }
101111 p.mu.Unlock()
102112
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 := ""
104117 if d.procComm != nil && p.pty != nil {
105118 if pgid, err := p.pty.ForegroundPgid(); err == nil && pgid > 0 {
106119 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
110123 }
111124
112125 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
117128 p.title.processNameAt = now
118129 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+ }
120135 p.mu.Unlock()
121136 return title
122137 }
@@ -18,6 +18,7 @@ type paneTitleState struct {
18 processNameAt time.Time18 processNameAt time.Time
19 processNameValid bool19 processNameValid bool
20 terminalTitle string20 terminalTitle string
21+ displayFallback string
21 generation uint6422 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 title109 return title
100 }110 }
101 p.mu.Unlock()111 p.mu.Unlock()
102 112
103- processName := fallback113+ // 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 = processName127+ p.title.processName = processName
115- p.title.generation++
116- }
117 p.title.processNameAt = now128 p.title.processNameAt = now
118 p.title.processNameValid = true129 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 title136 return title
122 }137 }
M internal/usecase/daemon/pane_titles_test.go +16 -5
@@ -95,18 +95,29 @@ func TestRefreshPaneTitleCachesByTTLAndRefreshesOnFocus(t *testing.T) {
9595 require.Equal(t, int32(2), calls.Load(), "focus refresh should bypass TTL")
9696 }
9797
98-func TestRefreshPaneTitleFallsBackToShellBase(t *testing.T) {
98+func TestRefreshPaneTitleLookupFailureKeepsProcessNameEmpty(t *testing.T) {
9999 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()
101101 _, sess, _, _ := newManualSessionWithPTYs(t, pty)
102102 clk := portsmocks.NewMockClock(t)
103103 clk.EXPECT().Now().Return(time.Time{}).Maybe()
104104 d := newTestDaemon(t, nil, clk)
105105 d.shell = "/usr/bin/fish"
106106 d.procComm = func(int) (string, error) { return "", errors.New("unused") }
107+ p := sess.activeTab().focusedPane()
107108
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()
109117
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))
112123 }
@@ -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
664664 focused := tb.tree.Focus == pl.ID
665665 pl = offsetPlacement(pl, area.X, area.Y)
666666 if pl.TitleBar.Height > 0 {
667- generation := drawPaneTitleBar(frame, pl, p, focused, theme, string(p.id))
667+ generation := drawPaneTitleBar(frame, pl, p, focused, theme)
668668 if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
669669 titleDamage := pl.TitleBar
670670 titleDamage.X -= area.X
@@ -737,7 +737,7 @@ func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bo
737737 }
738738 }
739739
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 {
741741 styles := newThemeStyles(theme)
742742 style := styles.border
743743 if focused {
@@ -749,7 +749,7 @@ func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focuse
749749 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
750750 }
751751 p.mu.Lock()
752- title := p.formattedTitleLocked(fallback)
752+ title := p.displayTitleLocked()
753753 generation := p.title.generation
754754 p.mu.Unlock()
755755 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.ID664 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.TitleBar669 titleDamage := pl.TitleBar
670 titleDamage.X -= area.X670 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.border742 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.generation753 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) {
661661 }
662662 }
663663
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+
664675 func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
665676 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
666677 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()

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