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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 739 | 739 | ||
| 740 | 740 | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)+len(stopped)) | |
| 741 | 741 | for _, sess := range sessions { | |
| 742 | + | e.d.refreshSessionFocusedTitles(sess) | |
| 742 | 743 | snap := sess.snapshotView(viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: false}) | |
| 743 | 744 | tabs, err := remoteCatalogTabs(snap) | |
| 744 | 745 | 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) { | |||
| 781 | 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 | 819 | func TestRemoteCatalogTabCountSaturates(t *testing.T) { | |
| 785 | 820 | d := newTestDaemon(t, nil, stubClock{}) | |
| 786 | 821 | 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 | |||
| 588 | 588 | if clock == nil { | |
| 589 | 589 | clock = systemClock{} | |
| 590 | 590 | } | |
| 591 | - | shell := os.Getenv("SHELL") | |
| 592 | - | if shell == "" { | |
| 593 | - | shell = "/bin/sh" | |
| 594 | - | } | |
| 595 | 591 | paneProcessCtx, paneProcessCancel := context.WithCancel(context.Background()) | |
| 596 | 592 | d := &Daemon{ | |
| 597 | 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 | 601 | clock: clock, | |
| 606 | 602 | log: log, | |
| 607 | 603 | baseEnv: os.Environ(), | |
| 608 | - | shell: shell, | |
| 604 | + | shell: defaultShellCommand, | |
| 609 | 605 | dirOrHome: dirOrHome, | |
| 610 | 606 | done: make(chan struct{}), | |
| 611 | 607 | 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 | |||
| 377 | 377 | if tabCtx == nil { | |
| 378 | 378 | tabCtx = context.Background() | |
| 379 | 379 | } | |
| 380 | - | command, args := d.ptyCommand(env) | |
| 380 | + | launch := d.shellLaunch(env) | |
| 381 | 381 | if cfg.Command != "" { | |
| 382 | - | args = []string{"-lc", cfg.Command} | |
| 382 | + | launch.args = []string{"-lc", cfg.Command} | |
| 383 | 383 | } | |
| 384 | 384 | return floatingLaunchSpec{ | |
| 385 | 385 | sessionName: name, | |
| @@ -388,9 +388,9 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 388 | 388 | geometry: geometry, | |
| 389 | 389 | paneStableID: paneStableID, | |
| 390 | 390 | 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), | |
| 394 | 394 | parentCtx: tabCtx, | |
| 395 | 395 | userOpen: userOpen, | |
| 396 | 396 | }, nil | |
| @@ -424,10 +424,9 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||
| 424 | 424 | d.failFloatingLaunch(sess, tb, generation, spec.userOpen, spec.sessionName, err) | |
| 425 | 425 | 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 | 428 | p.rect = spec.geometry.ptyRect() | |
| 429 | 429 | p.popupGeometry = spec.geometry | |
| 430 | - | p.title.displayFallback = spec.fallback | |
| 431 | 430 | if !lifetime.publish(p) { | |
| 432 | 431 | _ = pty.Close() | |
| 433 | 432 | 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 | }, nil | 396 | }, 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 | return | 425 | 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.geometry | 429 | 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 { | |||
| 163 | 163 | } | |
| 164 | 164 | ||
| 165 | 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 | 170 | screen := vt.NewScreenWithHistory(sz.Cols, sz.Rows, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}) | |
| 167 | 171 | return &pane{ | |
| 168 | 172 | id: id, | |
| @@ -171,6 +175,6 @@ func newPaneWithStableID(id layout.PaneID, stableID string, pty ports.PTY, sz do | |||
| 171 | 175 | screen: screen, | |
| 172 | 176 | history: screen.History(), | |
| 173 | 177 | rect: domain.Rect{Width: sz.Cols, Height: sz.Rows}, | |
| 174 | - | title: paneTitleState{displayFallback: "sh"}, | |
| 178 | + | title: paneTitleState{displayFallback: title}, | |
| 175 | 179 | } | |
| 176 | 180 | } | |
| @@ -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( | |||
| 87 | 87 | if err != nil { | |
| 88 | 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 | 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 | 93 | if err != nil { | |
| 94 | 94 | lifetime.abort() | |
| 95 | 95 | if pty != nil { | |
| @@ -99,7 +99,7 @@ func (d *Daemon) spawnPaneOpAt( | |||
| 99 | 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 | 103 | p.rect = newRect | |
| 104 | 104 | ||
| 105 | 105 | 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 = newRect | 103 | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "path/filepath" | |
| 5 | 4 | "strings" | |
| 6 | 5 | "time" | |
| 7 | 6 | ||
| @@ -116,7 +115,6 @@ func (d *Daemon) refreshSessionFocusedTitles(sess *session) { | |||
| 116 | 115 | if sess == nil { | |
| 117 | 116 | return | |
| 118 | 117 | } | |
| 119 | - | fallback := d.paneTitleFallback() | |
| 120 | 118 | sess.mu.Lock() | |
| 121 | 119 | panes := make([]*pane, 0, len(sess.tabs)) | |
| 122 | 120 | for _, tb := range sess.tabs { | |
| @@ -128,19 +126,10 @@ func (d *Daemon) refreshSessionFocusedTitles(sess *session) { | |||
| 128 | 126 | } | |
| 129 | 127 | sess.mu.Unlock() | |
| 130 | 128 | for _, p := range panes { | |
| 131 | - | p.setDisplayFallback(fallback) | |
| 132 | 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 | 133 | func (d *Daemon) refreshPaneTitle(sess *session, id layout.PaneID, owningTab ...*tab) string { | |
| 145 | 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 | 142 | // current tab supplies it explicitly; direct callers refresh every matching | |
| 154 | 143 | // tab because layout pane IDs are local to a tab and may repeat. | |
| 155 | 144 | func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force bool, owningTab ...*tab) string { | |
| 156 | - | fallback := d.paneTitleFallback() | |
| 157 | 145 | if sess == nil { | |
| 158 | - | return fallback | |
| 146 | + | return defaultShellTitle | |
| 159 | 147 | } | |
| 160 | 148 | tabs := owningTab | |
| 161 | 149 | if len(tabs) == 0 || tabs[0] == nil { | |
| @@ -163,7 +151,7 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b | |||
| 163 | 151 | tabs = append([]*tab(nil), sess.tabs...) | |
| 164 | 152 | sess.mu.Unlock() | |
| 165 | 153 | } | |
| 166 | - | title := fallback | |
| 154 | + | title := "" | |
| 167 | 155 | for _, tb := range tabs { | |
| 168 | 156 | if tb == nil { | |
| 169 | 157 | continue | |
| @@ -174,7 +162,6 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b | |||
| 174 | 162 | if p == nil { | |
| 175 | 163 | continue | |
| 176 | 164 | } | |
| 177 | - | p.setDisplayFallback(fallback) | |
| 178 | 165 | title = d.refreshPaneDisplayTitle(sess, p, force) | |
| 179 | 166 | } | |
| 180 | 167 | return title | |
| @@ -185,7 +172,7 @@ func (d *Daemon) refreshPaneTitleCached(sess *session, id layout.PaneID, force b | |||
| 185 | 172 | // callers that already render under pane locking cannot recurse on that lock. | |
| 186 | 173 | func (d *Daemon) refreshPaneDisplayTitle(_ *session, p *pane, force bool) string { | |
| 187 | 174 | if p == nil { | |
| 188 | - | return d.paneTitleFallback() | |
| 175 | + | return defaultShellTitle | |
| 189 | 176 | } | |
| 190 | 177 | now := d.clock.Now() | |
| 191 | 178 | p.mu.Lock() | |
| @@ -1,7 +1,6 @@ | |||||
| 1 | package daemon | 1 | 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 | return | 116 | 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 matching | 142 | // 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 fallback | 146 | + | return defaultShellTitle |
| 159 | } | 147 | } | ||
| 160 | tabs := owningTab | 148 | 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 := fallback | 154 | + | title := "" |
| 167 | for _, tb := range tabs { | 155 | for _, tb := range tabs { | ||
| 168 | if tb == nil { | 156 | if tb == nil { | ||
| 169 | continue | 157 | 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 | continue | 163 | 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 title | 167 | 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) | |||
| 162 | 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 | 166 | d, sess, _, _ := newManualSessionWithPTYs(t, nil) | |
| 167 | 167 | p := testAttachmentTab(sess).focusedPane() | |
| 168 | + | p.setDisplayFallback("fish") | |
| 168 | 169 | ||
| 169 | - | d.shell = "/usr/bin/fish" | |
| 170 | + | d.shell = "/bin/zsh" | |
| 170 | 171 | require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1")) | |
| 171 | 172 | p.mu.Lock() | |
| 172 | 173 | firstGeneration := p.title.generation | |
| 173 | 174 | p.mu.Unlock() | |
| 174 | 175 | require.Equal(t, "fish", d.refreshPaneTitle(sess, "pane-1")) | |
| 175 | 176 | 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") | |
| 183 | 178 | p.mu.Unlock() | |
| 184 | 179 | } | |
| 185 | 180 | ||
| @@ -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.generation | 173 | 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 ( | |||
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | 9 | "os" | |
| 10 | + | "path/filepath" | |
| 10 | 11 | "slices" | |
| 11 | 12 | "strconv" | |
| 12 | 13 | "strings" | |
| @@ -269,9 +270,9 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st | |||
| 269 | 270 | closeTabs(tabs) | |
| 270 | 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 | 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 | 276 | if err != nil { | |
| 276 | 277 | lifetime.abort() | |
| 277 | 278 | if pty != nil { | |
| @@ -281,7 +282,7 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st | |||
| 281 | 282 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session") | |
| 282 | 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 | 286 | if !lifetime.publish(tb.focusedPane()) { | |
| 286 | 287 | lifetime.abort() | |
| 287 | 288 | _ = pty.Close() | |
| @@ -537,9 +538,9 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom | |||
| 537 | 538 | if err != nil { | |
| 538 | 539 | return err | |
| 539 | 540 | } | |
| 540 | - | command, args := d.ptyCommand(env) | |
| 541 | + | launch := d.shellLaunch(env) | |
| 541 | 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 | 544 | if err != nil { | |
| 544 | 545 | lifetime.abort() | |
| 545 | 546 | if pty != nil { | |
| @@ -548,7 +549,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, _ dom | |||
| 548 | 549 | d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "tab") | |
| 549 | 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 | 553 | themeClient := ac | |
| 553 | 554 | if themeClient == nil && len(attachments) != 0 { | |
| 554 | 555 | themeClient = attachments[0] | |
| @@ -605,8 +606,12 @@ func newTab(pty ports.PTY, sz domain.Size) *tab { | |||
| 605 | 606 | } | |
| 606 | 607 | ||
| 607 | 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 | 613 | id := layout.PaneID("pane-1") | |
| 609 | - | p := newPaneWithStableID(id, paneStableID, pty, sz) | |
| 614 | + | p := newPaneWithStableIDAndTitle(id, paneStableID, pty, sz, title) | |
| 610 | 615 | return &tab{ | |
| 611 | 616 | stableID: tabStableID, | |
| 612 | 617 | tree: layout.NewTree(id), | |
| @@ -1831,11 +1836,34 @@ func terminalEnvFromEnvironment(env []string) terminalEnv { | |||
| 1831 | 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 | 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), nil | |
| 1866 | + | return title | |
| 1839 | 1867 | } | |
| 1840 | 1868 | ||
| 1841 | 1869 | func shellFromEnvironment(env []string) string { | |
| @@ -1846,7 +1874,7 @@ func shellFromEnvironment(env []string) string { | |||
| 1846 | 1874 | } | |
| 1847 | 1875 | } | |
| 1848 | 1876 | if shell == "" { | |
| 1849 | - | return "/bin/sh" | |
| 1877 | + | return defaultShellCommand | |
| 1850 | 1878 | } | |
| 1851 | 1879 | return shell | |
| 1852 | 1880 | } | |
| @@ -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 err | 539 | 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 := ac | 553 | 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), nil | 1866 | + | 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 shell | 1879 | return shell | ||
| 1852 | } | 1880 | } | ||
M internal/usecase/daemon/session_test.go +2 -0
| @@ -1618,9 +1618,11 @@ func TestAttachEnvironmentReplacesFuturePTYInputs(t *testing.T) { | |||
| 1618 | 1618 | ||
| 1619 | 1619 | require.Equal(t, "/usr/bin/fish", commands[0]) | |
| 1620 | 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 | 1622 | require.NoError(t, d.createTab(sess, ac.size)) | |
| 1622 | 1623 | require.Equal(t, "/bin/bash", commands[1]) | |
| 1623 | 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 | 1628 | 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 | |||
| 260 | 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 | 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 | 266 | if err != nil { | |
| 267 | 267 | lifetime.abort() | |
| 268 | 268 | if pty != nil { | |
| @@ -270,7 +270,7 @@ func (d *Daemon) restoreSnapshotPane(ctx context.Context, sessionName, tabStable | |||
| 270 | 270 | } | |
| 271 | 271 | 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 | 274 | if !lifetime.publish(p) { | |
| 275 | 275 | _ = pty.Close() | |
| 276 | 276 | 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, err | 271 | 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() | ||