detached d92ade2

fix(daemon): preserve per-pane shell titles

commit
d92ade2fb8514fd6c5fcbf05d6429018c953d90e
author
brice · · 2026-08-11 04:00 UTC
committer
brice · · 2026-08-11 04:07 UTC
parent
fa80e95
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +102 -55
M internal/usecase/daemon/control.go +1 -0
@@ -739,6 +739,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
739739
740740 rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped))
741741 for _, sess := range sessions {
742+ e.d.refreshSessionFocusedTitles(sess)
742743 snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false})
743744 tabs, err := remoteCatalogTabs(snap)
744745 if err != nil {
@@ -739,6 +739,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
739 739
740 rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped))740 rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped))
741 for _, sess := range sessions {741 for _, sess := range sessions {
742+ e.d.refreshSessionFocusedTitles(sess)
742 snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false})743 snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false})
743 tabs, err := remoteCatalogTabs(snap)744 tabs, err := remoteCatalogTabs(snap)
744 if err != nil {745 if err != nil {
M internal/usecase/daemon/control_test.go +35 -0
@@ -781,6 +781,41 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
781781 require.NotContains(t, listAfter.Output, "old")
782782 }
783783
784+func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
785+ d := newTestDaemon(t, nil, stubClock{})
786+ sess := addControlSession(d, "work", "t_work", "p_work")
787+ sess.incarnation[0] = 1
788+ tb := sess.tabs[0]
789+ tb.name = "shell"
790+ p := tb.focusedPane()
791+ pty := portsmocks.NewMockPTY(t)
792+ pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()
793+ p.pty = pty
794+ d.procComm = func(pid int) (string, error) {
795+ require.Equal(t, 1234, pid)
796+ return "fish", nil
797+ }
798+
799+ result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
800+ require.True(t, result.OK, result.Text)
801+ var catalog ports.RemoteCatalog
802+ require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
803+ require.Equal(t, []ports.RemoteCatalogSession{{
804+ LifecycleID: sess.incarnation,
805+ Name: "work",
806+ State: "up",
807+ Ephemeral: true,
808+ Tabs: []ports.RemoteCatalogTab{{
809+ ID: "t_work",
810+ Index: 0,
811+ Name: "shell",
812+ Detail: " (fish)",
813+ }},
814+ ActiveTabID: "t_work",
815+ LastUsedSeq: 1,
816+ }}, catalog.Sessions)
817+}
818+
784819 func TestRemoteCatalogTabCountSaturates(t *testing.T) {
785820 d := newTestDaemon(t, nil, stubClock{})
786821 sess := addControlSession(d, "work", "t_work", "p_work")
@@ -781,6 +781,41 @@ func TestRemoteCatalogJSONOutput(t *testing.T) {
781 require.NotContains(t, listAfter.Output, "old")781 require.NotContains(t, listAfter.Output, "old")
782 }782 }
783 783
784+func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
785+ d := newTestDaemon(t, nil, stubClock{})
786+ sess := addControlSession(d, "work", "t_work", "p_work")
787+ sess.incarnation[0] = 1
788+ tb := sess.tabs[0]
789+ tb.name = "shell"
790+ p := tb.focusedPane()
791+ pty := portsmocks.NewMockPTY(t)
792+ pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()
793+ p.pty = pty
794+ d.procComm = func(pid int) (string, error) {
795+ require.Equal(t, 1234, pid)
796+ return "fish", nil
797+ }
798+
799+ result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
800+ require.True(t, result.OK, result.Text)
801+ var catalog ports.RemoteCatalog
802+ require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
803+ require.Equal(t, []ports.RemoteCatalogSession{{
804+ LifecycleID: sess.incarnation,
805+ Name: "work",
806+ State: "up",
807+ Ephemeral: true,
808+ Tabs: []ports.RemoteCatalogTab{{
809+ ID: "t_work",
810+ Index: 0,
811+ Name: "shell",
812+ Detail: " (fish)",
813+ }},
814+ ActiveTabID: "t_work",
815+ LastUsedSeq: 1,
816+ }}, catalog.Sessions)
817+}
818+
784 func TestRemoteCatalogTabCountSaturates(t *testing.T) {819 func TestRemoteCatalogTabCountSaturates(t *testing.T) {
785 d := newTestDaemon(t, nil, stubClock{})820 d := newTestDaemon(t, nil, stubClock{})
786 sess := addControlSession(d, "work", "t_work", "p_work")821 sess := addControlSession(d, "work", "t_work", "p_work")
M internal/usecase/daemon/daemon.go +1 -5
@@ -588,10 +588,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
588588 if clock == nil {
589589 clock = systemClock{}
590590 }
591- shell := os.Getenv("SHELL")
592- if shell == "" {
593- shell = "/bin/sh"
594- }
595591 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
596592 d := &Daemon{
597593 sessions: make(map[domain.SessionID]*session),
@@ -605,7 +601,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
605601 clock: clock,
606602 log: log,
607603 baseEnv: os.Environ(),
608- shell: shell,
604+ shell: defaultShellCommand,
609605 dirOrHome: dirOrHome,
610606 done: make(chan struct{}),
611607 restoreDone: make(chan struct{}),
@@ -588,10 +588,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
588 if clock == nil {588 if clock == nil {
589 clock = systemClock{}589 clock = systemClock{}
590 }590 }
591- shell := os.Getenv("SHELL")
592- if shell == "" {
593- shell = "/bin/sh"
594- }
595 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())591 paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background())
596 d := &Daemon{592 d := &Daemon{
597 sessions: make(map[domain.SessionID]*session),593 sessions: make(map[domain.SessionID]*session),
@@ -605,7 +601,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
605 clock: clock,601 clock: clock,
606 log: log,602 log: log,
607 baseEnv: os.Environ(),603 baseEnv: os.Environ(),
608- shell: shell,604+ shell: defaultShellCommand,
609 dirOrHome: dirOrHome,605 dirOrHome: dirOrHome,
610 done: make(chan struct{}),606 done: make(chan struct{}),
611 restoreDone: make(chan struct{}),607 restoreDone: make(chan struct{}),
M internal/usecase/daemon/floating.go +6 -7
@@ -377,9 +377,9 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
377377 if tabCtx == nil {
378378 tabCtx = context.Background()
379379 }
380- command, args := d.ptyCommand(env)
380+ launch := d.shellLaunch(env)
381381 if cfg.Command != "" {
382- args = []string{"-lc", cfg.Command}
382+ launch.args = []string{"-lc", cfg.Command}
383383 }
384384 return floatingLaunchSpec{
385385 sessionName: name,
@@ -388,9 +388,9 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
388388 geometry: geometry,
389389 paneStableID: paneStableID,
390390 env: childEnvFrom(env, name, tabStableID, paneStableID, term),
391- command: command,
392- args: args,
393- fallback: floatingCommandFallback(cfg.Command, command),
391+ command: launch.command,
392+ args: launch.args,
393+ fallback: floatingCommandFallback(cfg.Command, launch.command),
394394 parentCtx: tabCtx,
395395 userOpen: userOpen,
396396 }, nil
@@ -424,10 +424,9 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
424424 d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err)
425425 return
426426 }
427- p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)
427+ p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback)
428428 p.rect = spec.geometry.ptyRect()
429429 p.popupGeometry = spec.geometry
430- p.title.displayFallback = spec.fallback
431430 if !lifetime.publish(p) {
432431 _ = pty.Close()
433432 d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, lifetime.ctx.Err())
@@ -377,9 +377,9 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
377 if tabCtx == nil {377 if tabCtx == nil {
378 tabCtx = context.Background()378 tabCtx = context.Background()
379 }379 }
380- command, args := d.ptyCommand(env)380+ launch := d.shellLaunch(env)
381 if cfg.Command != "" {381 if cfg.Command != "" {
382- args = []string{"-lc", cfg.Command}382+ launch.args = []string{"-lc", cfg.Command}
383 }383 }
384 return floatingLaunchSpec{384 return floatingLaunchSpec{
385 sessionName: name,385 sessionName: name,
@@ -388,9 +388,9 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
388 geometry: geometry,388 geometry: geometry,
389 paneStableID: paneStableID,389 paneStableID: paneStableID,
390 env: childEnvFrom(env, name, tabStableID, paneStableID, term),390 env: childEnvFrom(env, name, tabStableID, paneStableID, term),
391- command: command,391+ command: launch.command,
392- args: args,392+ args: launch.args,
393- fallback: floatingCommandFallback(cfg.Command, command),393+ fallback: floatingCommandFallback(cfg.Command, launch.command),
394 parentCtx: tabCtx,394 parentCtx: tabCtx,
395 userOpen: userOpen,395 userOpen: userOpen,
396 }, nil396 }, nil
@@ -424,10 +424,9 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
424 d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err)424 d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err)
425 return425 return
426 }426 }
427- p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)427+ p := newPaneWithStableIDAndTitle(layout.PaneID("floating"), spec.paneStableID, pty, spec.size, spec.fallback)
428 p.rect = spec.geometry.ptyRect()428 p.rect = spec.geometry.ptyRect()
429 p.popupGeometry = spec.geometry429 p.popupGeometry = spec.geometry
430- p.title.displayFallback = spec.fallback
431 if !lifetime.publish(p) {430 if !lifetime.publish(p) {
432 _ = pty.Close()431 _ = pty.Close()
433 d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, lifetime.ctx.Err())432 d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, lifetime.ctx.Err())
M internal/usecase/daemon/pane.go +5 -1
@@ -163,6 +163,10 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
163163 }
164164
165165 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
166+ return newPaneWithStableIDAndTitle(id, stableID, pty, sz, defaultShellTitle)
167+}
168+
169+func newPaneWithStableIDAndTitle(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size, title string) *pane {
166170 screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells})
167171 return &pane{
168172 id: id,
@@ -171,6 +175,6 @@ func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz do
171175 screen: screen,
172176 history: screen.History(),
173177 rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
174- title: paneTitleState{displayFallback: "sh"},
178+ title: paneTitleState{displayFallback: title},
175179 }
176180 }
@@ -163,6 +163,10 @@ func newPane(id layout.PaneID, pty ports.PTY, sz domain.Size) *pane {
163 }163 }
164 164
165 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {165 func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size) *pane {
166+ return newPaneWithStableIDAndTitle(id, stableID, pty, sz, defaultShellTitle)
167+}
168+
169+func newPaneWithStableIDAndTitle(id layout.PaneID, stableID string, pty ports.PTY, sz domain.Size, title string) *pane {
166 screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells})170 screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells})
167 return &pane{171 return &pane{
168 id: id,172 id: id,
@@ -171,6 +175,6 @@ func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz do
171 screen: screen,175 screen: screen,
172 history: screen.History(),176 history: screen.History(),
173 rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},177 rect: domain.Rect{Width: sz.Cols, Height: sz.Rows},
174- title: paneTitleState{displayFallback: "sh"},178+ title: paneTitleState{displayFallback: title},
175 }179 }
176 }180 }
M internal/usecase/daemon/pane_actions.go +3 -3
@@ -87,9 +87,9 @@ func (d *Daemon) spawnPaneOpAt(
8787 if err != nil {
8888 return paneFocusChange{}, fmt.Errorf("daemon: generating pane identity: %w", err)
8989 }
90- command, args := d.ptyCommand(env)
90+ launch := d.shellLaunch(env)
9191 lifetime := d.newPaneProcessLifetime(tb.ctx)
92- pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
92+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
9393 if err != nil {
9494 lifetime.abort()
9595 if pty != nil {
@@ -99,7 +99,7 @@ func (d *Daemon) spawnPaneOpAt(
9999 return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)
100100 }
101101
102- p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect))
102+ p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title)
103103 p.rect = newRect
104104
105105 tb.mu.Lock()
@@ -87,9 +87,9 @@ func (d *Daemon) spawnPaneOpAt(
87 if err != nil {87 if err != nil {
88 return paneFocusChange{}, fmt.Errorf("daemon: generating pane identity: %w", err)88 return paneFocusChange{}, fmt.Errorf("daemon: generating pane identity: %w", err)
89 }89 }
90- command, args := d.ptyCommand(env)90+ launch := d.shellLaunch(env)
91 lifetime := d.newPaneProcessLifetime(tb.ctx)91 lifetime := d.newPaneProcessLifetime(tb.ctx)
92- pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect))92+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, rectSize(newRect))
93 if err != nil {93 if err != nil {
94 lifetime.abort()94 lifetime.abort()
95 if pty != nil {95 if pty != nil {
@@ -99,7 +99,7 @@ func (d *Daemon) spawnPaneOpAt(
99 return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)99 return paneFocusChange{}, domain.UserErr(domain.NoticePaneSpawn, "couldn't open pane: shell failed to start", err)
100 }100 }
101 101
102- p := newPaneWithStableID(newID, paneStableID, pty, rectSize(newRect))102+ p := newPaneWithStableIDAndTitle(newID, paneStableID, pty, rectSize(newRect), launch.title)
103 p.rect = newRect103 p.rect = newRect
104 104
105 tb.mu.Lock()105 tb.mu.Lock()
M internal/usecase/daemon/pane_titles.go +3 -16
@@ -1,7 +1,6 @@
11 package daemon
22
33 import (
4- "path/filepath"
54 "strings"
65 "time"
76
@@ -116,7 +115,6 @@ func (d *Daemon) refreshSessionFocusedTitles(sess *session) {
116115 if sess == nil {
117116 return
118117 }
119- fallback := d.paneTitleFallback()
120118 sess.mu.Lock()
121119 panes := make([]*pane, 0, len(sess.tabs))
122120 for _, tb := range sess.tabs {
@@ -128,19 +126,10 @@ func (d *Daemon) refreshSessionFocusedTitles(sess *session) {
128126 }
129127 sess.mu.Unlock()
130128 for _, p := range panes {
131- p.setDisplayFallback(fallback)
132129 d.refreshPaneDisplayTitle(sess, p, false)
133130 }
134131 }
135132
136-func (d *Daemon) paneTitleFallback() string {
137- fallback := filepath.Base(d.shell)
138- if fallback == "." || fallback == string(filepath.Separator) || fallback == "" {
139- return d.shell
140- }
141- return fallback
142-}
143-
144133 func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID, owningTab ...*tab) string {
145134 return d.refreshPaneTitleCached(sess, id, false, owningTab...)
146135 }
@@ -153,9 +142,8 @@ func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID, owning
153142 // current tab supplies it explicitly; direct callers refresh every matching
154143 // tab because layout pane IDs are local to a tab and may repeat.
155144 func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool, owningTab ...*tab) string {
156- fallback := d.paneTitleFallback()
157145 if sess == nil {
158- return fallback
146+ return defaultShellTitle
159147 }
160148 tabs := owningTab
161149 if len(tabs) == 0 || tabs[0] == nil {
@@ -163,7 +151,7 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b
163151 tabs = append([]*tab(nil), sess.tabs...)
164152 sess.mu.Unlock()
165153 }
166- title := fallback
154+ title := ""
167155 for _, tb := range tabs {
168156 if tb == nil {
169157 continue
@@ -174,7 +162,6 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b
174162 if p == nil {
175163 continue
176164 }
177- p.setDisplayFallback(fallback)
178165 title = d.refreshPaneDisplayTitle(sess, p, force)
179166 }
180167 return title
@@ -185,7 +172,7 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b
185172 // callers that already render under pane locking cannot recurse on that lock.
186173 func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string {
187174 if p == nil {
188- return d.paneTitleFallback()
175+ return defaultShellTitle
189176 }
190177 now := d.clock.Now()
191178 p.mu.Lock()
@@ -1,7 +1,6 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "path/filepath"
5 "strings"4 "strings"
6 "time"5 "time"
7 6
@@ -116,7 +115,6 @@ func (d *Daemon) refreshSessionFocusedTitles(sess *session) {
116 if sess == nil {115 if sess == nil {
117 return116 return
118 }117 }
119- fallback := d.paneTitleFallback()
120 sess.mu.Lock()118 sess.mu.Lock()
121 panes := make([]*pane, 0, len(sess.tabs))119 panes := make([]*pane, 0, len(sess.tabs))
122 for _, tb := range sess.tabs {120 for _, tb := range sess.tabs {
@@ -128,19 +126,10 @@ func (d *Daemon) refreshSessionFocusedTitles(sess *session) {
128 }126 }
129 sess.mu.Unlock()127 sess.mu.Unlock()
130 for _, p := range panes {128 for _, p := range panes {
131- p.setDisplayFallback(fallback)
132 d.refreshPaneDisplayTitle(sess, p, false)129 d.refreshPaneDisplayTitle(sess, p, false)
133 }130 }
134 }131 }
135 132
136-func (d *Daemon) paneTitleFallback() string {
137- fallback := filepath.Base(d.shell)
138- if fallback == "." || fallback == string(filepath.Separator) || fallback == "" {
139- return d.shell
140- }
141- return fallback
142-}
143-
144 func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID, owningTab ...*tab) string {133 func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID, owningTab ...*tab) string {
145 return d.refreshPaneTitleCached(sess, id, false, owningTab...)134 return d.refreshPaneTitleCached(sess, id, false, owningTab...)
146 }135 }
@@ -153,9 +142,8 @@ func (d *Daemon) refreshPaneTitleOnFocus(sess *session, id layout.PaneID, owning
153 // current tab supplies it explicitly; direct callers refresh every matching142 // current tab supplies it explicitly; direct callers refresh every matching
154 // tab because layout pane IDs are local to a tab and may repeat.143 // tab because layout pane IDs are local to a tab and may repeat.
155 func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool, owningTab ...*tab) string {144 func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool, owningTab ...*tab) string {
156- fallback := d.paneTitleFallback()
157 if sess == nil {145 if sess == nil {
158- return fallback146+ return defaultShellTitle
159 }147 }
160 tabs := owningTab148 tabs := owningTab
161 if len(tabs) == 0 || tabs[0] == nil {149 if len(tabs) == 0 || tabs[0] == nil {
@@ -163,7 +151,7 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b
163 tabs = append([]*tab(nil), sess.tabs...)151 tabs = append([]*tab(nil), sess.tabs...)
164 sess.mu.Unlock()152 sess.mu.Unlock()
165 }153 }
166- title := fallback154+ title := ""
167 for _, tb := range tabs {155 for _, tb := range tabs {
168 if tb == nil {156 if tb == nil {
169 continue157 continue
@@ -174,7 +162,6 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b
174 if p == nil {162 if p == nil {
175 continue163 continue
176 }164 }
177- p.setDisplayFallback(fallback)
178 title = d.refreshPaneDisplayTitle(sess, p, force)165 title = d.refreshPaneDisplayTitle(sess, p, force)
179 }166 }
180 return title167 return title
@@ -185,7 +172,7 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b
185 // callers that already render under pane locking cannot recurse on that lock.172 // callers that already render under pane locking cannot recurse on that lock.
186 func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string {173 func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string {
187 if p == nil {174 if p == nil {
188- return d.paneTitleFallback()175+ return defaultShellTitle
189 }176 }
190 now := d.clock.Now()177 now := d.clock.Now()
191 p.mu.Lock()178 p.mu.Lock()
M internal/usecase/daemon/pane_titles_test.go +4 -9
@@ -162,24 +162,19 @@ func TestRefreshFloatingPaneTitleUsesShellFallbackForEmptyCommand(t *testing.T)
162162 require.Equal(t, "zsh", d.refreshPaneDisplayTitle(nil, p, true))
163163 }
164164
165-func TestRefreshPaneTitleUpdatesNormalPaneShellFallback(t *testing.T) {
165+func TestRefreshPaneTitleRetainsNormalPaneShellFallback(t *testing.T) {
166166 d, sess, _, _ := newManualSessionWithPTYs(t, nil)
167167 p := testAttachmentTab(sess).focusedPane()
168+ p.setDisplayFallback("fish")
168169
169- d.shell = "/usr/bin/fish"
170+ d.shell = "/bin/zsh"
170171 require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1"))
171172 p.mu.Lock()
172173 firstGeneration := p.title.generation
173174 p.mu.Unlock()
174175 require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1"))
175176 p.mu.Lock()
176- require.Equal(t, firstGeneration, p.title.generation, "unchanged displayed title must not damage the title row")
177- p.mu.Unlock()
178-
179- d.shell = "/bin/zsh"
180- require.Equal(t, "zsh", d.refreshPaneTitle(sess, "pane-1"))
181- p.mu.Lock()
182- require.Equal(t, firstGeneration+1, p.title.generation)
177+ require.Equal(t, firstGeneration, p.title.generation, "a title refresh must preserve the pane-owned shell fallback")
183178 p.mu.Unlock()
184179 }
185180
@@ -162,24 +162,19 @@ func TestRefreshFloatingPaneTitleUsesShellFallbackForEmptyCommand(t *testing.T)
162 require.Equal(t, "zsh", d.refreshPaneDisplayTitle(nil, p, true))162 require.Equal(t, "zsh", d.refreshPaneDisplayTitle(nil, p, true))
163 }163 }
164 164
165-func TestRefreshPaneTitleUpdatesNormalPaneShellFallback(t *testing.T) {165+func TestRefreshPaneTitleRetainsNormalPaneShellFallback(t *testing.T) {
166 d, sess, _, _ := newManualSessionWithPTYs(t, nil)166 d, sess, _, _ := newManualSessionWithPTYs(t, nil)
167 p := testAttachmentTab(sess).focusedPane()167 p := testAttachmentTab(sess).focusedPane()
168+ p.setDisplayFallback("fish")
168 169
169- d.shell = "/usr/bin/fish"170+ d.shell = "/bin/zsh"
170 require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1"))171 require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1"))
171 p.mu.Lock()172 p.mu.Lock()
172 firstGeneration := p.title.generation173 firstGeneration := p.title.generation
173 p.mu.Unlock()174 p.mu.Unlock()
174 require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1"))175 require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1"))
175 p.mu.Lock()176 p.mu.Lock()
176- require.Equal(t, firstGeneration, p.title.generation, "unchanged displayed title must not damage the title row")177+ require.Equal(t, firstGeneration, p.title.generation, "a title refresh must preserve the pane-owned shell fallback")
177- p.mu.Unlock()
178-
179- d.shell = "/bin/zsh"
180- require.Equal(t, "zsh", d.refreshPaneTitle(sess, "pane-1"))
181- p.mu.Lock()
182- require.Equal(t, firstGeneration+1, p.title.generation)
183 p.mu.Unlock()178 p.mu.Unlock()
184 }179 }
185 180
M internal/usecase/daemon/session.go +39 -11
@@ -7,6 +7,7 @@ import (
77 "errors"
88 "fmt"
99 "os"
10+ "path/filepath"
1011 "slices"
1112 "strconv"
1213 "strings"
@@ -269,9 +270,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
269270 closeTabs(tabs)
270271 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
271272 }
272- command, args := d.ptyCommand(env)
273+ launch := d.shellLaunch(env)
273274 lifetime := d.newPaneProcessLifetime(d.serveCtx)
274- pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
275+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
275276 if err != nil {
276277 lifetime.abort()
277278 if pty != nil {
@@ -281,7 +282,7 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
281282 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
282283 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)
283284 }
284- tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
285+ tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)
285286 if !lifetime.publish(tb.focusedPane()) {
286287 lifetime.abort()
287288 _ = pty.Close()
@@ -537,9 +538,9 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
537538 if err != nil {
538539 return err
539540 }
540- command, args := d.ptyCommand(env)
541+ launch := d.shellLaunch(env)
541542 lifetime := d.newPaneProcessLifetime(sess.ctx)
542- pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
543+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
543544 if err != nil {
544545 lifetime.abort()
545546 if pty != nil {
@@ -548,7 +549,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
548549 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
549550 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
550551 }
551- tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
552+ tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)
552553 themeClient := ac
553554 if themeClient == nil && len(attachments) != 0 {
554555 themeClient = attachments[0]
@@ -605,8 +606,12 @@ func newTab(pty ports.PTY, sz domain.Size) *tab {
605606 }
606607
607608 func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {
609+ return newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, sz, defaultShellTitle)
610+}
611+
612+func newTabWithStableIDAndTitle(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size, title string) *tab {
608613 id := layout.PaneID("pane-1")
609- p := newPaneWithStableID(id, paneStableID, pty, sz)
614+ p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title)
610615 return &tab{
611616 stableID: tabStableID,
612617 tree: layout.NewTree(id),
@@ -1831,11 +1836,34 @@ func terminalEnvFromEnvironment(env []string) terminalEnv {
18311836 return terminalEnv{TrueColor: ports.DetectTrueColor(term, colorTerm)}
18321837 }
18331838
1834-func (d *Daemon) ptyCommand(env []string) (string, []string) {
1839+const defaultShellCommand = "/bin/sh"
1840+const defaultShellTitle = "sh"
1841+
1842+type shellLaunchSpec struct {
1843+ command string
1844+ args []string
1845+ title string
1846+}
1847+
1848+// shellLaunch resolves the command and display fallback for one normal PTY.
1849+// Both values come from the same client environment snapshot (or explicit
1850+// daemon override), so headless catalogues identify the shell they actually run.
1851+func (d *Daemon) shellLaunch(env []string) shellLaunchSpec {
1852+ command := shellFromEnvironment(env)
1853+ var args []string
18351854 if d.shellOverride {
1836- return d.shell, append([]string(nil), d.shellArgs...)
1855+ command = d.shell
1856+ args = append([]string(nil), d.shellArgs...)
1857+ }
1858+ return shellLaunchSpec{command: command, args: args, title: shellDisplayName(command)}
1859+}
1860+
1861+func shellDisplayName(command string) string {
1862+ title := filepath.Base(command)
1863+ if title == "." || title == string(filepath.Separator) || title == "" {
1864+ return command
18371865 }
1838- return shellFromEnvironment(env), nil
1866+ return title
18391867 }
18401868
18411869 func shellFromEnvironment(env []string) string {
@@ -1846,7 +1874,7 @@ func shellFromEnvironment(env []string) string {
18461874 }
18471875 }
18481876 if shell == "" {
1849- return "/bin/sh"
1877+ return defaultShellCommand
18501878 }
18511879 return shell
18521880 }
@@ -7,6 +7,7 @@ import (
7 "errors"7 "errors"
8 "fmt"8 "fmt"
9 "os"9 "os"
10+ "path/filepath"
10 "slices"11 "slices"
11 "strconv"12 "strconv"
12 "strings"13 "strings"
@@ -269,9 +270,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
269 closeTabs(tabs)270 closeTabs(tabs)
270 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)271 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", err)
271 }272 }
272- command, args := d.ptyCommand(env)273+ launch := d.shellLaunch(env)
273 lifetime := d.newPaneProcessLifetime(d.serveCtx)274 lifetime := d.newPaneProcessLifetime(d.serveCtx)
274- pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)275+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
275 if err != nil {276 if err != nil {
276 lifetime.abort()277 lifetime.abort()
277 if pty != nil {278 if pty != nil {
@@ -281,7 +282,7 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
281 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")282 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
282 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)283 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session: shell failed to start", err)
283 }284 }
284- tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)285+ tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)
285 if !lifetime.publish(tb.focusedPane()) {286 if !lifetime.publish(tb.focusedPane()) {
286 lifetime.abort()287 lifetime.abort()
287 _ = pty.Close()288 _ = pty.Close()
@@ -537,9 +538,9 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
537 if err != nil {538 if err != nil {
538 return err539 return err
539 }540 }
540- command, args := d.ptyCommand(env)541+ launch := d.shellLaunch(env)
541 lifetime := d.newPaneProcessLifetime(sess.ctx)542 lifetime := d.newPaneProcessLifetime(sess.ctx)
542- pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)543+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(env, name, tabStableID, paneStableID, term), cwd, tbSize)
543 if err != nil {544 if err != nil {
544 lifetime.abort()545 lifetime.abort()
545 if pty != nil {546 if pty != nil {
@@ -548,7 +549,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom
548 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")549 d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab")
549 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)550 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
550 }551 }
551- tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)552+ tb := newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, tbSize, launch.title)
552 themeClient := ac553 themeClient := ac
553 if themeClient == nil && len(attachments) != 0 {554 if themeClient == nil && len(attachments) != 0 {
554 themeClient = attachments[0]555 themeClient = attachments[0]
@@ -605,8 +606,12 @@ func newTab(pty ports.PTY, sz domain.Size) *tab {
605 }606 }
606 607
607 func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {608 func newTabWithStableID(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size) *tab {
609+ return newTabWithStableIDAndTitle(tabStableID, paneStableID, pty, sz, defaultShellTitle)
610+}
611+
612+func newTabWithStableIDAndTitle(tabStableID, paneStableID string, pty ports.PTY, sz domain.Size, title string) *tab {
608 id := layout.PaneID("pane-1")613 id := layout.PaneID("pane-1")
609- p := newPaneWithStableID(id, paneStableID, pty, sz)614+ p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title)
610 return &tab{615 return &tab{
611 stableID: tabStableID,616 stableID: tabStableID,
612 tree: layout.NewTree(id),617 tree: layout.NewTree(id),
@@ -1831,11 +1836,34 @@ func terminalEnvFromEnvironment(env []string) terminalEnv {
1831 return terminalEnv{TrueColor: ports.DetectTrueColor(term, colorTerm)}1836 return terminalEnv{TrueColor: ports.DetectTrueColor(term, colorTerm)}
1832 }1837 }
1833 1838
1834-func (d *Daemon) ptyCommand(env []string) (string, []string) {1839+const defaultShellCommand = "/bin/sh"
1840+const defaultShellTitle = "sh"
1841+
1842+type shellLaunchSpec struct {
1843+ command string
1844+ args []string
1845+ title string
1846+}
1847+
1848+// shellLaunch resolves the command and display fallback for one normal PTY.
1849+// Both values come from the same client environment snapshot (or explicit
1850+// daemon override), so headless catalogues identify the shell they actually run.
1851+func (d *Daemon) shellLaunch(env []string) shellLaunchSpec {
1852+ command := shellFromEnvironment(env)
1853+ var args []string
1835 if d.shellOverride {1854 if d.shellOverride {
1836- return d.shell, append([]string(nil), d.shellArgs...)1855+ command = d.shell
1856+ args = append([]string(nil), d.shellArgs...)
1857+ }
1858+ return shellLaunchSpec{command: command, args: args, title: shellDisplayName(command)}
1859+}
1860+
1861+func shellDisplayName(command string) string {
1862+ title := filepath.Base(command)
1863+ if title == "." || title == string(filepath.Separator) || title == "" {
1864+ return command
1837 }1865 }
1838- return shellFromEnvironment(env), nil1866+ return title
1839 }1867 }
1840 1868
1841 func shellFromEnvironment(env []string) string {1869 func shellFromEnvironment(env []string) string {
@@ -1846,7 +1874,7 @@ func shellFromEnvironment(env []string) string {
1846 }1874 }
1847 }1875 }
1848 if shell == "" {1876 if shell == "" {
1849- return "/bin/sh"1877+ return defaultShellCommand
1850 }1878 }
1851 return shell1879 return shell
1852 }1880 }
M internal/usecase/daemon/session_test.go +2 -0
@@ -1618,9 +1618,11 @@ func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {
16181618
16191619 require.Equal(t, "/usr/bin/fish", commands[0])
16201620 require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])
1621+ require.Equal(t, "fish", sess.tabs[0].focusedPaneTitle(false), "the initial tab title identifies the shell requested by its creator")
16211622 require.NoError(t, d.createTab(sess, ac.size))
16221623 require.Equal(t, "/bin/bash", commands[1])
16231624 require.Equal(t, []string{"SECRET=second", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])
1625+ require.Equal(t, "bash", sess.tabs[1].focusedPaneTitle(false), "later tabs use the most recently attached shell")
16241626 }
16251627
16261628 func TestChildEnvFromPreservesNonReservedEntriesVerbatimAndInOrder(t *testing.T) {
@@ -1618,9 +1618,11 @@ func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) {
1618 1618
1619 require.Equal(t, "/usr/bin/fish", commands[0])1619 require.Equal(t, "/usr/bin/fish", commands[0])
1620 require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])1620 require.Equal(t, []string{"SECRET=first", "TERM_PROGRAM_extra=keep", "SHELL=/usr/bin/fish", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[0].stableID + ",pane=" + sess.tabs[0].panes["pane-1"].stableID}, envs[0])
1621+ require.Equal(t, "fish", sess.tabs[0].focusedPaneTitle(false), "the initial tab title identifies the shell requested by its creator")
1621 require.NoError(t, d.createTab(sess, ac.size))1622 require.NoError(t, d.createTab(sess, ac.size))
1622 require.Equal(t, "/bin/bash", commands[1])1623 require.Equal(t, "/bin/bash", commands[1])
1623 require.Equal(t, []string{"SECRET=second", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])1624 require.Equal(t, []string{"SECRET=second", "TERM_PROGRAM_extra=keep", "SHELL=/bin/bash", "A=a=b", "TERM=xterm-256color", "TERM_PROGRAM=vev", "VEV=session=work,tab=" + sess.tabs[1].stableID + ",pane=" + sess.tabs[1].panes["pane-1"].stableID}, envs[1])
1625+ require.Equal(t, "bash", sess.tabs[1].focusedPaneTitle(false), "later tabs use the most recently attached shell")
1624 }1626 }
1625 1627
1626 func TestChildEnvFromPreservesNonReservedEntriesVerbatimAndInOrder(t *testing.T) {1628 func TestChildEnvFromPreservesNonReservedEntriesVerbatimAndInOrder(t *testing.T) {
M internal/usecase/daemon/snapshot_restore.go +3 -3
@@ -260,9 +260,9 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
260260 return nil, fmt.Errorf("snapshot: generating pane identity: %w", err)
261261 }
262262 }
263- command, args := d.ptyCommand(restoreEnv)
263+ launch := d.shellLaunch(restoreEnv)
264264 lifetime := d.newPaneProcessLifetime(ctx, tabCtx)
265- pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))
265+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))
266266 if err != nil {
267267 lifetime.abort()
268268 if pty != nil {
@@ -270,7 +270,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
270270 }
271271 return nil, err
272272 }
273- p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize))
273+ p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize), launch.title)
274274 if !lifetime.publish(p) {
275275 _ = pty.Close()
276276 return nil, lifetime.ctx.Err()
@@ -260,9 +260,9 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
260 return nil, fmt.Errorf("snapshot: generating pane identity: %w", err)260 return nil, fmt.Errorf("snapshot: generating pane identity: %w", err)
261 }261 }
262 }262 }
263- command, args := d.ptyCommand(restoreEnv)263+ launch := d.shellLaunch(restoreEnv)
264 lifetime := d.newPaneProcessLifetime(ctx, tabCtx)264 lifetime := d.newPaneProcessLifetime(ctx, tabCtx)
265- pty, err := d.ptys.Open(lifetime.ctx, command, args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))265+ pty, err := d.ptys.Open(lifetime.ctx, launch.command, launch.args, childEnvFrom(restoreEnv, sessionName, tabStableID, paneStableID, restoreTerm), paneSnap.Cwd, restorePTYSize(contentRect, tabSize))
266 if err != nil {266 if err != nil {
267 lifetime.abort()267 lifetime.abort()
268 if pty != nil {268 if pty != nil {
@@ -270,7 +270,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable
270 }270 }
271 return nil, err271 return nil, err
272 }272 }
273- p := newPaneWithStableID(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize))273+ p := newPaneWithStableIDAndTitle(paneSnap.ID, paneStableID, pty, restorePTYSize(contentRect, tabSize), launch.title)
274 if !lifetime.publish(p) {274 if !lifetime.publish(p) {
275 _ = pty.Close()275 _ = pty.Close()
276 return nil, lifetime.ctx.Err()276 return nil, lifetime.ctx.Err()

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