detached 7bfa6fa

feat(config): add tabs.terminal-title to toggle OSC titles in tab labels

Adds tabs.terminal-title = on|off (default on). When off, tab labels in
the top bar and session picker drop the OSC terminal-title half and show
only the process name (or its fallback); floating and stacked pane title
bars are unaffected.

Mirrors the existing PaletteConfig plumbing: domain.TabsConfig carries the
setting, the config adapter parses on/off with a warning on other values,
and the daemon publishes it through an atomic tabsConfig snapshot read by
currentTabsConfig() so config reload applies live.

Renames tab.focusedPaneTitleLocked to focusedPaneTitle(includeTerminalTitle
bool) (review feedback: it only requires the owning session.mu, not a
"Locked" self-guard) and threads the flag through statusSegments and
pickerViews.
commit
7bfa6fab5fab02d8b6e866c63f13f796ca6a17cb
author
brice · · 2026-07-11 10:17 UTC
parent
3601ffd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +207 -23
M README.md +7 -1
@@ -58,6 +58,10 @@ floating.command =
5858 floating.width = 80%
5959 floating.height = 80%
6060
61+# Include the focused pane's OSC terminal title in tab labels (top bar and
62+# session picker). The process name always shows; off keeps only that.
63+tabs.terminal-title = on
64+
6165 # Rebindable actions. Leave a line out to keep its built-in binding.
6266 open-palette = alt+space
6367 toggle-floating-pane = alt+f
@@ -118,6 +122,8 @@ Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit
118122
119123 `floating.command` runs through the normal shell; leave it empty to launch that shell directly. `floating.width` and `floating.height` require exact percentages from `1%` through `100%`; invalid values log a warning and leave the last accepted value (or the `80%` default) intact. Reloading does not relaunch or immediately alter an installed floating terminal: a changed command is used on its next launch, while changed dimensions take effect when that terminal is next shown or resized. Only configuration—not floating runtime state—is restored after a daemon restart; restored tabs stay cold until first visited.
120124
125+`tabs.terminal-title` accepts `on` or `off` and defaults to `on`; invalid values log a warning and keep the last accepted value. Reload applies a changed value live.
126+
121127 ### Command palette anchoring
122128
123129 `palette.anchor` accepts `auto` or the nine anchors `center`, `top-left`, `top`, `top-right`, `left`, `right`, `bottom-left`, `bottom`, and `bottom-right`. Values are trimmed and case-insensitive. The default is `auto`: palettes use the bottom shelf when the terminal is narrower than 96 columns, and a 64-column bottom-right rail at 96 columns or wider. An explicit anchor changes placement only; palette query and selection are preserved. Invalid values are warned about and leave the last valid value unchanged, as do duplicate entries until a valid replacement is supplied (the last valid value wins). Configuration reload applies a changed anchor live and repositions an open palette without resetting its query or selection.
@@ -216,7 +222,7 @@ Each tab also owns one independent floating terminal. It prewarms asynchronously
216222
217223 While visible, the floating terminal receives ordinary input, bracketed paste, mouse input, clipboard-path injection, and scrollback/copy mode. Global vev bindings remain active, and a bare Esc is passed to the floating application. `Alt+f` and `FLT` toggle visibility; there is no separate floating-pane close command.
218224
219-The top bar shows each tab as `name (title)`, where the name is the tab's rename or number and the title is the focused pane's `ProcessName: ProcessTerminalTitle`. Labels share the row width: when everything fits they render in full, otherwise unused space flows from short tabs to long ones and long labels are ellipsized, degrading to the bare tab name when space runs out. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.
225+The top bar shows each tab as `name (title)`, where the name is the tab's rename or number and the title is the focused pane's `ProcessName: ProcessTerminalTitle`. Set `tabs.terminal-title = off` to drop the `ProcessTerminalTitle` half from tab labels (top bar and session picker) and show only the process name; floating and stacked pane title bars are unaffected. Labels share the row width: when everything fits they render in full, otherwise unused space flows from short tabs to long ones and long labels are ellipsized, degrading to the bare tab name when space runs out. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.
220226
221227 ## Scrollback mode and copy
222228
@@ -58,6 +58,10 @@ floating.command =
58 floating.width = 80%58 floating.width = 80%
59 floating.height = 80%59 floating.height = 80%
60 60
61+# Include the focused pane's OSC terminal title in tab labels (top bar and
62+# session picker). The process name always shows; off keeps only that.
63+tabs.terminal-title = on
64+
61 # Rebindable actions. Leave a line out to keep its built-in binding.65 # Rebindable actions. Leave a line out to keep its built-in binding.
62 open-palette = alt+space66 open-palette = alt+space
63 toggle-floating-pane = alt+f67 toggle-floating-pane = alt+f
@@ -118,6 +122,8 @@ Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit
118 122
119 `floating.command` runs through the normal shell; leave it empty to launch that shell directly. `floating.width` and `floating.height` require exact percentages from `1%` through `100%`; invalid values log a warning and leave the last accepted value (or the `80%` default) intact. Reloading does not relaunch or immediately alter an installed floating terminal: a changed command is used on its next launch, while changed dimensions take effect when that terminal is next shown or resized. Only configuration—not floating runtime state—is restored after a daemon restart; restored tabs stay cold until first visited.123 `floating.command` runs through the normal shell; leave it empty to launch that shell directly. `floating.width` and `floating.height` require exact percentages from `1%` through `100%`; invalid values log a warning and leave the last accepted value (or the `80%` default) intact. Reloading does not relaunch or immediately alter an installed floating terminal: a changed command is used on its next launch, while changed dimensions take effect when that terminal is next shown or resized. Only configuration—not floating runtime state—is restored after a daemon restart; restored tabs stay cold until first visited.
120 124
125+`tabs.terminal-title` accepts `on` or `off` and defaults to `on`; invalid values log a warning and keep the last accepted value. Reload applies a changed value live.
126+
121 ### Command palette anchoring127 ### Command palette anchoring
122 128
123 `palette.anchor` accepts `auto` or the nine anchors `center`, `top-left`, `top`, `top-right`, `left`, `right`, `bottom-left`, `bottom`, and `bottom-right`. Values are trimmed and case-insensitive. The default is `auto`: palettes use the bottom shelf when the terminal is narrower than 96 columns, and a 64-column bottom-right rail at 96 columns or wider. An explicit anchor changes placement only; palette query and selection are preserved. Invalid values are warned about and leave the last valid value unchanged, as do duplicate entries until a valid replacement is supplied (the last valid value wins). Configuration reload applies a changed anchor live and repositions an open palette without resetting its query or selection.129 `palette.anchor` accepts `auto` or the nine anchors `center`, `top-left`, `top`, `top-right`, `left`, `right`, `bottom-left`, `bottom`, and `bottom-right`. Values are trimmed and case-insensitive. The default is `auto`: palettes use the bottom shelf when the terminal is narrower than 96 columns, and a 64-column bottom-right rail at 96 columns or wider. An explicit anchor changes placement only; palette query and selection are preserved. Invalid values are warned about and leave the last valid value unchanged, as do duplicate entries until a valid replacement is supplied (the last valid value wins). Configuration reload applies a changed anchor live and repositions an open palette without resetting its query or selection.
@@ -216,7 +222,7 @@ Each tab also owns one independent floating terminal. It prewarms asynchronously
216 222
217 While visible, the floating terminal receives ordinary input, bracketed paste, mouse input, clipboard-path injection, and scrollback/copy mode. Global vev bindings remain active, and a bare Esc is passed to the floating application. `Alt+f` and `FLT` toggle visibility; there is no separate floating-pane close command.223 While visible, the floating terminal receives ordinary input, bracketed paste, mouse input, clipboard-path injection, and scrollback/copy mode. Global vev bindings remain active, and a bare Esc is passed to the floating application. `Alt+f` and `FLT` toggle visibility; there is no separate floating-pane close command.
218 224
219-The top bar shows each tab as `name (title)`, where the name is the tab's rename or number and the title is the focused pane's `ProcessName: ProcessTerminalTitle`. Labels share the row width: when everything fits they render in full, otherwise unused space flows from short tabs to long ones and long labels are ellipsized, degrading to the bare tab name when space runs out. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.225+The top bar shows each tab as `name (title)`, where the name is the tab's rename or number and the title is the focused pane's `ProcessName: ProcessTerminalTitle`. Set `tabs.terminal-title = off` to drop the `ProcessTerminalTitle` half from tab labels (top bar and session picker) and show only the process name; floating and stacked pane title bars are unaffected. Labels share the row width: when everything fits they render in full, otherwise unused space flows from short tabs to long ones and long labels are ellipsized, degrading to the bare tab name when space runs out. The bottom bar shows the active session followed by recent sessions, fading toward older entries. Ephemeral sessions are marked with `*`, for example `0*`.
220 226
221 ## Scrollback mode and copy227 ## Scrollback mode and copy
222 228
M internal/adapters/config/config.go +20 -0
@@ -32,6 +32,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3232 seenBindingKeys := make(map[string]bool)
3333 seenFloatingKeys := make(map[string]bool)
3434 seenPaletteKeys := make(map[string]bool)
35+ seenTabsKeys := make(map[string]bool)
3536
3637 scanner := bufio.NewScanner(r)
3738 lineNo := 0
@@ -103,6 +104,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
103104 continue
104105 }
105106 cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
107+ case key == "tabs.terminal-title":
108+ warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo)
109+ on, ok := parseOnOff(value)
110+ if !ok {
111+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid tabs.terminal-title %q", value)})
112+ continue
113+ }
114+ cfg.Tabs.TerminalTitle = on
106115 case key == "floating.command":
107116 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
108117 command, ok := parseBarCommand(value)
@@ -281,6 +290,17 @@ func parsePercentage(value string) (int, bool) {
281290 return percentage, err == nil && percentage >= 1 && percentage <= 100
282291 }
283292
293+func parseOnOff(value string) (bool, bool) {
294+ switch strings.ToLower(strings.TrimSpace(value)) {
295+ case "on":
296+ return true, true
297+ case "off":
298+ return false, true
299+ default:
300+ return false, false
301+ }
302+}
303+
284304 func parseBarCommand(value string) (string, bool) {
285305 value = strings.TrimSpace(value)
286306 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
@@ -32,6 +32,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
32 seenBindingKeys := make(map[string]bool)32 seenBindingKeys := make(map[string]bool)
33 seenFloatingKeys := make(map[string]bool)33 seenFloatingKeys := make(map[string]bool)
34 seenPaletteKeys := make(map[string]bool)34 seenPaletteKeys := make(map[string]bool)
35+ seenTabsKeys := make(map[string]bool)
35 36
36 scanner := bufio.NewScanner(r)37 scanner := bufio.NewScanner(r)
37 lineNo := 038 lineNo := 0
@@ -103,6 +104,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
103 continue104 continue
104 }105 }
105 cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}106 cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
107+ case key == "tabs.terminal-title":
108+ warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo)
109+ on, ok := parseOnOff(value)
110+ if !ok {
111+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid tabs.terminal-title %q", value)})
112+ continue
113+ }
114+ cfg.Tabs.TerminalTitle = on
106 case key == "floating.command":115 case key == "floating.command":
107 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)116 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
108 command, ok := parseBarCommand(value)117 command, ok := parseBarCommand(value)
@@ -281,6 +290,17 @@ func parsePercentage(value string) (int, bool) {
281 return percentage, err == nil && percentage >= 1 && percentage <= 100290 return percentage, err == nil && percentage >= 1 && percentage <= 100
282 }291 }
283 292
293+func parseOnOff(value string) (bool, bool) {
294+ switch strings.ToLower(strings.TrimSpace(value)) {
295+ case "on":
296+ return true, true
297+ case "off":
298+ return false, true
299+ default:
300+ return false, false
301+ }
302+}
303+
284 func parseBarCommand(value string) (string, bool) {304 func parseBarCommand(value string) (string, bool) {
285 value = strings.TrimSpace(value)305 value = strings.TrimSpace(value)
286 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {306 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
M internal/adapters/config/config_test.go +55 -0
@@ -338,6 +338,9 @@ func TestParse(t *testing.T) {
338338 if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {
339339 tt.want.Floating = domain.Defaults().Floating
340340 }
341+ if !tt.want.Tabs.TerminalTitle {
342+ tt.want.Tabs = domain.Defaults().Tabs
343+ }
341344 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
342345 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
343346 }
@@ -355,6 +358,58 @@ func TestParse(t *testing.T) {
355358 }
356359 }
357360
361+func TestParseTabsTerminalTitle(t *testing.T) {
362+ t.Parallel()
363+
364+ tests := []struct {
365+ name string
366+ input string
367+ want bool
368+ wantWarnings []domain.Warning
369+ }{
370+ {
371+ name: "on enables terminal title",
372+ input: "tabs.terminal-title = on\n",
373+ want: true,
374+ },
375+ {
376+ name: "off disables terminal title",
377+ input: "tabs.terminal-title = off\n",
378+ want: false,
379+ },
380+ {
381+ name: "invalid value warns and keeps default",
382+ input: "tabs.terminal-title = maybe\n",
383+ want: true,
384+ wantWarnings: []domain.Warning{
385+ {Line: 1, Msg: "invalid tabs.terminal-title \"maybe\""},
386+ },
387+ },
388+ {
389+ name: "absent key keeps default",
390+ input: "theme = dark\n",
391+ want: true,
392+ },
393+ }
394+
395+ for _, tt := range tests {
396+ t.Run(tt.name, func(t *testing.T) {
397+ t.Parallel()
398+
399+ got, warnings, err := Parse(strings.NewReader(tt.input))
400+ if err != nil {
401+ t.Fatalf("Parse() error = %v", err)
402+ }
403+ if got.Tabs.TerminalTitle != tt.want {
404+ t.Fatalf("Parse() Tabs.TerminalTitle = %v, want %v", got.Tabs.TerminalTitle, tt.want)
405+ }
406+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
407+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
408+ }
409+ })
410+ }
411+}
412+
358413 func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
359414 t.Parallel()
360415
@@ -338,6 +338,9 @@ func TestParse(t *testing.T) {
338 if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {338 if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {
339 tt.want.Floating = domain.Defaults().Floating339 tt.want.Floating = domain.Defaults().Floating
340 }340 }
341+ if !tt.want.Tabs.TerminalTitle {
342+ tt.want.Tabs = domain.Defaults().Tabs
343+ }
341 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {344 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
342 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)345 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
343 }346 }
@@ -355,6 +358,58 @@ func TestParse(t *testing.T) {
355 }358 }
356 }359 }
357 360
361+func TestParseTabsTerminalTitle(t *testing.T) {
362+ t.Parallel()
363+
364+ tests := []struct {
365+ name string
366+ input string
367+ want bool
368+ wantWarnings []domain.Warning
369+ }{
370+ {
371+ name: "on enables terminal title",
372+ input: "tabs.terminal-title = on\n",
373+ want: true,
374+ },
375+ {
376+ name: "off disables terminal title",
377+ input: "tabs.terminal-title = off\n",
378+ want: false,
379+ },
380+ {
381+ name: "invalid value warns and keeps default",
382+ input: "tabs.terminal-title = maybe\n",
383+ want: true,
384+ wantWarnings: []domain.Warning{
385+ {Line: 1, Msg: "invalid tabs.terminal-title \"maybe\""},
386+ },
387+ },
388+ {
389+ name: "absent key keeps default",
390+ input: "theme = dark\n",
391+ want: true,
392+ },
393+ }
394+
395+ for _, tt := range tests {
396+ t.Run(tt.name, func(t *testing.T) {
397+ t.Parallel()
398+
399+ got, warnings, err := Parse(strings.NewReader(tt.input))
400+ if err != nil {
401+ t.Fatalf("Parse() error = %v", err)
402+ }
403+ if got.Tabs.TerminalTitle != tt.want {
404+ t.Fatalf("Parse() Tabs.TerminalTitle = %v, want %v", got.Tabs.TerminalTitle, tt.want)
405+ }
406+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
407+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
408+ }
409+ })
410+ }
411+}
412+
358 func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {413 func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
359 t.Parallel()414 t.Parallel()
360 415
M internal/domain/config.go +11 -0
@@ -61,6 +61,13 @@ type FloatingConfig struct {
6161 Height int
6262 }
6363
64+// TabsConfig contains tab label display settings.
65+type TabsConfig struct {
66+ // TerminalTitle includes the focused pane's OSC terminal title in tab
67+ // labels (top bar and session picker). The process name always shows.
68+ TerminalTitle bool
69+}
70+
6471 // Config is the user-editable vev configuration after parsing. Unknown binding
6572 // keys are preserved here (in BindingEntries, in file order) so the usecase
6673 // layer can decide which actions it understands.
@@ -72,6 +79,7 @@ type Config struct {
7279 Snapshot SnapshotConfig
7380 Palette PaletteConfig
7481 Floating FloatingConfig
82+ Tabs TabsConfig
7583 }
7684
7785 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -99,5 +107,8 @@ func Defaults() Config {
99107 Width: 80,
100108 Height: 80,
101109 },
110+ Tabs: TabsConfig{
111+ TerminalTitle: true,
112+ },
102113 }
103114 }
@@ -61,6 +61,13 @@ type FloatingConfig struct {
61 Height int61 Height int
62 }62 }
63 63
64+// TabsConfig contains tab label display settings.
65+type TabsConfig struct {
66+ // TerminalTitle includes the focused pane's OSC terminal title in tab
67+ // labels (top bar and session picker). The process name always shows.
68+ TerminalTitle bool
69+}
70+
64 // Config is the user-editable vev configuration after parsing. Unknown binding71 // Config is the user-editable vev configuration after parsing. Unknown binding
65 // keys are preserved here (in BindingEntries, in file order) so the usecase72 // keys are preserved here (in BindingEntries, in file order) so the usecase
66 // layer can decide which actions it understands.73 // layer can decide which actions it understands.
@@ -72,6 +79,7 @@ type Config struct {
72 Snapshot SnapshotConfig79 Snapshot SnapshotConfig
73 Palette PaletteConfig80 Palette PaletteConfig
74 Floating FloatingConfig81 Floating FloatingConfig
82+ Tabs TabsConfig
75 }83 }
76 84
77 // Warning describes a non-fatal config problem. Parsers and reloaders should85 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -99,5 +107,8 @@ func Defaults() Config {
99 Width: 80,107 Width: 80,
100 Height: 80,108 Height: 80,
101 },109 },
110+ Tabs: TabsConfig{
111+ TerminalTitle: true,
112+ },
102 }113 }
103 }114 }
M internal/usecase/daemon/config.go +9 -0
@@ -31,6 +31,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
3131 d.floatingConfig.Store(&floating)
3232 palette := cfg.Palette
3333 d.paletteConfig.Store(&palette)
34+ tabs := cfg.Tabs
35+ d.tabsConfig.Store(&tabs)
3436 d.themeMode.Store(uint32(cfg.Theme))
3537 if d.barScripts != nil {
3638 d.barScripts.mu.Lock()
@@ -187,6 +189,13 @@ func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
187189 return domain.Defaults().Palette
188190 }
189191
192+func (d *Daemon) currentTabsConfig() domain.TabsConfig {
193+ if cfg := d.tabsConfig.Load(); cfg != nil {
194+ return *cfg
195+ }
196+ return domain.Defaults().Tabs
197+}
198+
190199 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
191200 if cfg := d.floatingConfig.Load(); cfg != nil {
192201 return *cfg
@@ -31,6 +31,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
31 d.floatingConfig.Store(&floating)31 d.floatingConfig.Store(&floating)
32 palette := cfg.Palette32 palette := cfg.Palette
33 d.paletteConfig.Store(&palette)33 d.paletteConfig.Store(&palette)
34+ tabs := cfg.Tabs
35+ d.tabsConfig.Store(&tabs)
34 d.themeMode.Store(uint32(cfg.Theme))36 d.themeMode.Store(uint32(cfg.Theme))
35 if d.barScripts != nil {37 if d.barScripts != nil {
36 d.barScripts.mu.Lock()38 d.barScripts.mu.Lock()
@@ -187,6 +189,13 @@ func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
187 return domain.Defaults().Palette189 return domain.Defaults().Palette
188 }190 }
189 191
192+func (d *Daemon) currentTabsConfig() domain.TabsConfig {
193+ if cfg := d.tabsConfig.Load(); cfg != nil {
194+ return *cfg
195+ }
196+ return domain.Defaults().Tabs
197+}
198+
190 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {199 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
191 if cfg := d.floatingConfig.Load(); cfg != nil {200 if cfg := d.floatingConfig.Load(); cfg != nil {
192 return *cfg201 return *cfg
M internal/usecase/daemon/config_test.go +18 -0
@@ -119,6 +119,24 @@ func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
119119 require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)
120120 }
121121
122+func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) {
123+ d := newTestDaemon(t, nil, stubClock{})
124+ require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig())
125+
126+ firstConfig := domain.Defaults()
127+ firstConfig.Tabs = domain.TabsConfig{TerminalTitle: false}
128+ d.ApplyConfig(firstConfig)
129+ first := d.currentTabsConfig()
130+
131+ secondConfig := domain.Defaults()
132+ secondConfig.Tabs = domain.TabsConfig{TerminalTitle: true}
133+ d.ApplyConfig(secondConfig)
134+ second := d.currentTabsConfig()
135+
136+ require.Equal(t, domain.TabsConfig{TerminalTitle: false}, first)
137+ require.Equal(t, domain.TabsConfig{TerminalTitle: true}, second)
138+}
139+
122140 func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
123141 tests := []struct {
124142 name string
@@ -119,6 +119,24 @@ func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
119 require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)119 require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)
120 }120 }
121 121
122+func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) {
123+ d := newTestDaemon(t, nil, stubClock{})
124+ require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig())
125+
126+ firstConfig := domain.Defaults()
127+ firstConfig.Tabs = domain.TabsConfig{TerminalTitle: false}
128+ d.ApplyConfig(firstConfig)
129+ first := d.currentTabsConfig()
130+
131+ secondConfig := domain.Defaults()
132+ secondConfig.Tabs = domain.TabsConfig{TerminalTitle: true}
133+ d.ApplyConfig(secondConfig)
134+ second := d.currentTabsConfig()
135+
136+ require.Equal(t, domain.TabsConfig{TerminalTitle: false}, first)
137+ require.Equal(t, domain.TabsConfig{TerminalTitle: true}, second)
138+}
139+
122 func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {140 func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
123 tests := []struct {141 tests := []struct {
124 name string142 name string
M internal/usecase/daemon/copymode_test.go +2 -2
@@ -115,7 +115,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
115115 snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)
116116 pane.mu.Unlock()
117117 mode := scopy.NewMode(snap)
118- bars := barState{status: sess.statusSegments()}
118+ bars := barState{status: sess.statusSegments(true)}
119119
120120 base := renderer.NewFrame(80, 25)
121121 target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}
@@ -150,7 +150,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
150150 snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
151151 mode := scopy.NewMode(snap)
152152
153- bars := barState{status: sess.statusSegments()}
153+ bars := barState{status: sess.statusSegments(true)}
154154 base, _ := composeClientFrameWithState(bars, tb, true)
155155 frame, damage := composeCopyClientFrame(mode, tb.focusedPane(), domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
156156
@@ -115,7 +115,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
115 snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)115 snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)
116 pane.mu.Unlock()116 pane.mu.Unlock()
117 mode := scopy.NewMode(snap)117 mode := scopy.NewMode(snap)
118- bars := barState{status: sess.statusSegments()}118+ bars := barState{status: sess.statusSegments(true)}
119 119
120 base := renderer.NewFrame(80, 25)120 base := renderer.NewFrame(80, 25)
121 target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}121 target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}
@@ -150,7 +150,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
150 snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)150 snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
151 mode := scopy.NewMode(snap)151 mode := scopy.NewMode(snap)
152 152
153- bars := barState{status: sess.statusSegments()}153+ bars := barState{status: sess.statusSegments(true)}
154 base, _ := composeClientFrameWithState(bars, tb, true)154 base, _ := composeClientFrameWithState(bars, tb, true)
155 frame, damage := composeCopyClientFrame(mode, tb.focusedPane(), domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)155 frame, damage := composeCopyClientFrame(mode, tb.focusedPane(), domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
156 156
M internal/usecase/daemon/daemon.go +1 -0
@@ -137,6 +137,7 @@ type Daemon struct {
137137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
138138 floatingConfig atomic.Pointer[domain.FloatingConfig]
139139 paletteConfig atomic.Pointer[domain.PaletteConfig]
140+ tabsConfig atomic.Pointer[domain.TabsConfig]
140141 themeMode atomic.Uint32
141142 barScripts *barScriptState
142143 resumeParkGrace time.Duration
@@ -137,6 +137,7 @@ type Daemon struct {
137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
138 floatingConfig atomic.Pointer[domain.FloatingConfig]138 floatingConfig atomic.Pointer[domain.FloatingConfig]
139 paletteConfig atomic.Pointer[domain.PaletteConfig]139 paletteConfig atomic.Pointer[domain.PaletteConfig]
140+ tabsConfig atomic.Pointer[domain.TabsConfig]
140 themeMode atomic.Uint32141 themeMode atomic.Uint32
141 barScripts *barScriptState142 barScripts *barScriptState
142 resumeParkGrace time.Duration143 resumeParkGrace time.Duration
M internal/usecase/daemon/pane_titles.go +9 -4
@@ -78,9 +78,11 @@ func composeTabTitle(tabName, paneTitle string) string {
7878 return tabName + " (" + paneTitle + ")"
7979 }
8080
81-// focusedPaneTitleLocked returns the focused pane's display title. Caller must
82-// hold the owning session.mu (so tb.mu then pane.mu obey lock order).
83-func (tb *tab) focusedPaneTitleLocked() string {
81+// focusedPaneTitle returns the focused pane's display title for tab labels.
82+// Caller must hold the owning session.mu (tb.mu then pane.mu obey lock order).
83+// When includeTerminalTitle is false the OSC title is omitted; the process
84+// name (or fallback) still shows.
85+func (tb *tab) focusedPaneTitle(includeTerminalTitle bool) string {
8486 tb.mu.Lock()
8587 p := tb.focusedPane()
8688 tb.mu.Unlock()
@@ -89,7 +91,10 @@ func (tb *tab) focusedPaneTitleLocked() string {
8991 }
9092 p.mu.Lock()
9193 defer p.mu.Unlock()
92- return p.displayTitleLocked()
94+ if includeTerminalTitle {
95+ return p.displayTitleLocked()
96+ }
97+ return formatPaneTitle(p.title.processName, "", p.title.displayFallback)
9398 }
9499
95100 // refreshSessionFocusedTitles opportunistically refreshes the process-name half
@@ -78,9 +78,11 @@ func composeTabTitle(tabName, paneTitle string) string {
78 return tabName + " (" + paneTitle + ")"78 return tabName + " (" + paneTitle + ")"
79 }79 }
80 80
81-// focusedPaneTitleLocked returns the focused pane's display title. Caller must81+// focusedPaneTitle returns the focused pane's display title for tab labels.
82-// hold the owning session.mu (so tb.mu then pane.mu obey lock order).82+// Caller must hold the owning session.mu (tb.mu then pane.mu obey lock order).
83-func (tb *tab) focusedPaneTitleLocked() string {83+// When includeTerminalTitle is false the OSC title is omitted; the process
84+// name (or fallback) still shows.
85+func (tb *tab) focusedPaneTitle(includeTerminalTitle bool) string {
84 tb.mu.Lock()86 tb.mu.Lock()
85 p := tb.focusedPane()87 p := tb.focusedPane()
86 tb.mu.Unlock()88 tb.mu.Unlock()
@@ -89,7 +91,10 @@ func (tb *tab) focusedPaneTitleLocked() string {
89 }91 }
90 p.mu.Lock()92 p.mu.Lock()
91 defer p.mu.Unlock()93 defer p.mu.Unlock()
92- return p.displayTitleLocked()94+ if includeTerminalTitle {
95+ return p.displayTitleLocked()
96+ }
97+ return formatPaneTitle(p.title.processName, "", p.title.displayFallback)
93 }98 }
94 99
95 // refreshSessionFocusedTitles opportunistically refreshes the process-name half100 // refreshSessionFocusedTitles opportunistically refreshes the process-name half
M internal/usecase/daemon/picker.go +2 -1
@@ -67,6 +67,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
6767 d.refreshSessionFocusedTitles(s)
6868 }
6969
70+ includeTerminalTitle := d.currentTabsConfig().TerminalTitle
7071 views := make([]picker.SessionView, 0, len(sessions)+len(stopped))
7172 curTab := 0
7273 for _, s := range sessions {
@@ -74,7 +75,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
7475 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
7576 sessionAttention := false
7677 for i, tb := range s.tabs {
77- label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitleLocked())
78+ label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitle(includeTerminalTitle))
7879 if tb.attention {
7980 label = attentionSuffix(label)
8081 sessionAttention = true
@@ -67,6 +67,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
67 d.refreshSessionFocusedTitles(s)67 d.refreshSessionFocusedTitles(s)
68 }68 }
69 69
70+ includeTerminalTitle := d.currentTabsConfig().TerminalTitle
70 views := make([]picker.SessionView, 0, len(sessions)+len(stopped))71 views := make([]picker.SessionView, 0, len(sessions)+len(stopped))
71 curTab := 072 curTab := 0
72 for _, s := range sessions {73 for _, s := range sessions {
@@ -74,7 +75,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
74 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}75 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
75 sessionAttention := false76 sessionAttention := false
76 for i, tb := range s.tabs {77 for i, tb := range s.tabs {
77- label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitleLocked())78+ label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitle(includeTerminalTitle))
78 if tb.attention {79 if tb.attention {
79 label = attentionSuffix(label)80 label = attentionSuffix(label)
80 sessionAttention = true81 sessionAttention = true
M internal/usecase/daemon/picker_test.go +24 -0
@@ -91,6 +91,30 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
9191 require.Equal(t, []string{"1 (vim)", attentionSuffix("logs (sh)")}, views[0].Tabs)
9292 }
9393
94+func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
95+ p1, releasePTY1 := newBlockingPTY(t)
96+ p2, releasePTY2 := newBlockingPTY(t)
97+ defer releasePTY1()
98+ defer releasePTY2()
99+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
100+ d.shell = "/bin/sh"
101+ sess.tabs[1].name = "logs"
102+
103+ pane0 := sess.tabs[0].focusedPane()
104+ pane0.mu.Lock()
105+ pane0.title.processName = "vim"
106+ pane0.title.processNameValid = true
107+ pane0.title.terminalTitle = "server.go — vev"
108+ pane0.mu.Unlock()
109+
110+ d.ApplyConfig(domain.Config{Tabs: domain.TabsConfig{TerminalTitle: false}})
111+
112+ views, _ := d.pickerViews(sess)
113+
114+ require.Len(t, views, 1)
115+ require.Equal(t, []string{"1 (vim)", "logs (sh)"}, views[0].Tabs)
116+}
117+
94118 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
95119 p1, release1 := newBlockingPTY(t)
96120 p2, release2 := newBlockingPTY(t)
@@ -91,6 +91,30 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) {
91 require.Equal(t, []string{"1 (vim)", attentionSuffix("logs (sh)")}, views[0].Tabs)91 require.Equal(t, []string{"1 (vim)", attentionSuffix("logs (sh)")}, views[0].Tabs)
92 }92 }
93 93
94+func TestPickerViewsOmitsTerminalTitleWhenTabsConfigDisabled(t *testing.T) {
95+ p1, releasePTY1 := newBlockingPTY(t)
96+ p2, releasePTY2 := newBlockingPTY(t)
97+ defer releasePTY1()
98+ defer releasePTY2()
99+ d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
100+ d.shell = "/bin/sh"
101+ sess.tabs[1].name = "logs"
102+
103+ pane0 := sess.tabs[0].focusedPane()
104+ pane0.mu.Lock()
105+ pane0.title.processName = "vim"
106+ pane0.title.processNameValid = true
107+ pane0.title.terminalTitle = "server.go — vev"
108+ pane0.mu.Unlock()
109+
110+ d.ApplyConfig(domain.Config{Tabs: domain.TabsConfig{TerminalTitle: false}})
111+
112+ views, _ := d.pickerViews(sess)
113+
114+ require.Len(t, views, 1)
115+ require.Equal(t, []string{"1 (vim)", "logs (sh)"}, views[0].Tabs)
116+}
117+
94 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {118 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
95 p1, release1 := newBlockingPTY(t)119 p1, release1 := newBlockingPTY(t)
96 p2, release2 := newBlockingPTY(t)120 p2, release2 := newBlockingPTY(t)
M internal/usecase/daemon/render.go +1 -1
@@ -604,7 +604,7 @@ func (c *composedFrameCache) invalidate() {
604604 }
605605
606606 func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
607- return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...)
607+ return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
608608 }
609609
610610 func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
@@ -604,7 +604,7 @@ func (c *composedFrameCache) invalidate() {
604 }604 }
605 605
606 func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {606 func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
607- return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...)607+ return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
608 }608 }
609 609
610 func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {610 func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
M internal/usecase/daemon/render_test.go +7 -7
@@ -529,13 +529,13 @@ func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) {
529529 var bars barCache
530530 var composed composedFrameCache
531531 sess := &session{id: "s", name: "work", tabs: []*tab{win}}
532- composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, true, solveTabLayoutLocked(win), &bars, &composed)
532+ composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, true, solveTabLayoutLocked(win), &bars, &composed)
533533 left.screen.ClearDamage()
534534 right.screen.ClearDamage()
535535 left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'})
536536 right.screen.Write([]byte("x"))
537537
538- frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, false, solveTabLayoutLocked(win), &bars, &composed)
538+ frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, false, solveTabLayoutLocked(win), &bars, &composed)
539539
540540 require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted")
541541 require.Equal(t, 'x', frame.At(22, 1).Rune)
@@ -548,7 +548,7 @@ func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) {
548548 p.screen.Write([]byte("old"))
549549 var bars barCache
550550 var composed composedFrameCache
551- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
551+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
552552
553553 _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
554554 require.NotEmpty(t, damage)
@@ -572,7 +572,7 @@ func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) {
572572 p2.screen.Write([]byte("shown"))
573573 var bars barCache
574574 var composed composedFrameCache
575- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
575+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
576576
577577 _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
578578
@@ -591,7 +591,7 @@ func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) {
591591 theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
592592 var bars barCache
593593 var composed composedFrameCache
594- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(), theme: theme}
594+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true), theme: theme}
595595 win.tree.Focus = left.id
596596 composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
597597 left.screen.ClearDamage()
@@ -616,7 +616,7 @@ func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) {
616616 right.screen.Write([]byte("R"))
617617 var bars barCache
618618 var composed composedFrameCache
619- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
619+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
620620 win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
621621 composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
622622 require.Equal(t, '│', composed.frame.At(20, 1).Rune)
@@ -725,7 +725,7 @@ func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
725725 win.panes[p2.id] = p2
726726 var bars barCache
727727 var composed composedFrameCache
728- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
728+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
729729 composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
730730 p1.screen.ClearDamage()
731731 p2.screen.ClearDamage()
@@ -529,13 +529,13 @@ func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) {
529 var bars barCache529 var bars barCache
530 var composed composedFrameCache530 var composed composedFrameCache
531 sess := &session{id: "s", name: "work", tabs: []*tab{win}}531 sess := &session{id: "s", name: "work", tabs: []*tab{win}}
532- composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, true, solveTabLayoutLocked(win), &bars, &composed)532+ composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, true, solveTabLayoutLocked(win), &bars, &composed)
533 left.screen.ClearDamage()533 left.screen.ClearDamage()
534 right.screen.ClearDamage()534 right.screen.ClearDamage()
535 left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'})535 left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'})
536 right.screen.Write([]byte("x"))536 right.screen.Write([]byte("x"))
537 537
538- frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, false, solveTabLayoutLocked(win), &bars, &composed)538+ frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, false, solveTabLayoutLocked(win), &bars, &composed)
539 539
540 require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted")540 require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted")
541 require.Equal(t, 'x', frame.At(22, 1).Rune)541 require.Equal(t, 'x', frame.At(22, 1).Rune)
@@ -548,7 +548,7 @@ func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) {
548 p.screen.Write([]byte("old"))548 p.screen.Write([]byte("old"))
549 var bars barCache549 var bars barCache
550 var composed composedFrameCache550 var composed composedFrameCache
551- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}551+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
552 552
553 _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)553 _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
554 require.NotEmpty(t, damage)554 require.NotEmpty(t, damage)
@@ -572,7 +572,7 @@ func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) {
572 p2.screen.Write([]byte("shown"))572 p2.screen.Write([]byte("shown"))
573 var bars barCache573 var bars barCache
574 var composed composedFrameCache574 var composed composedFrameCache
575- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}575+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
576 576
577 _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)577 _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
578 578
@@ -591,7 +591,7 @@ func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) {
591 theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}591 theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
592 var bars barCache592 var bars barCache
593 var composed composedFrameCache593 var composed composedFrameCache
594- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(), theme: theme}594+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true), theme: theme}
595 win.tree.Focus = left.id595 win.tree.Focus = left.id
596 composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)596 composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
597 left.screen.ClearDamage()597 left.screen.ClearDamage()
@@ -616,7 +616,7 @@ func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) {
616 right.screen.Write([]byte("R"))616 right.screen.Write([]byte("R"))
617 var bars barCache617 var bars barCache
618 var composed composedFrameCache618 var composed composedFrameCache
619- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}619+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
620 win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}620 win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
621 composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)621 composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
622 require.Equal(t, '│', composed.frame.At(20, 1).Rune)622 require.Equal(t, '│', composed.frame.At(20, 1).Rune)
@@ -725,7 +725,7 @@ func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
725 win.panes[p2.id] = p2725 win.panes[p2.id] = p2
726 var bars barCache726 var bars barCache
727 var composed composedFrameCache727 var composed composedFrameCache
728- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}728+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
729 composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)729 composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
730 p1.screen.ClearDamage()730 p1.screen.ClearDamage()
731 p2.screen.ClearDamage()731 p2.screen.ClearDamage()
M internal/usecase/daemon/status.go +7 -3
@@ -198,7 +198,7 @@ type statusTab struct {
198198 attention bool
199199 }
200200
201-func (s *session) statusSegments() statusSnapshot {
201+func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
202202 s.mu.Lock()
203203 defer s.mu.Unlock()
204204 name := s.name
@@ -210,7 +210,7 @@ func (s *session) statusSegments() statusSnapshot {
210210 name := tabDisplayName(tb, i)
211211 active := i == s.active
212212 attention := tb.attention && (!active || tb.attentionVisiblePaint)
213- snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitleLocked(), active: active, attention: attention}
213+ snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}
214214 }
215215 return snap
216216 }
@@ -275,7 +275,11 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
275275 state.attentionFrame = d.attentionFrame()
276276 }
277277 if cur != nil {
278- state.status = cur.statusSegments()
278+ includeTerminalTitle := true
279+ if d != nil {
280+ includeTerminalTitle = d.currentTabsConfig().TerminalTitle
281+ }
282+ state.status = cur.statusSegments(includeTerminalTitle)
279283 }
280284 if d != nil {
281285 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
@@ -198,7 +198,7 @@ type statusTab struct {
198 attention bool198 attention bool
199 }199 }
200 200
201-func (s *session) statusSegments() statusSnapshot {201+func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
202 s.mu.Lock()202 s.mu.Lock()
203 defer s.mu.Unlock()203 defer s.mu.Unlock()
204 name := s.name204 name := s.name
@@ -210,7 +210,7 @@ func (s *session) statusSegments() statusSnapshot {
210 name := tabDisplayName(tb, i)210 name := tabDisplayName(tb, i)
211 active := i == s.active211 active := i == s.active
212 attention := tb.attention && (!active || tb.attentionVisiblePaint)212 attention := tb.attention && (!active || tb.attentionVisiblePaint)
213- snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitleLocked(), active: active, attention: attention}213+ snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention}
214 }214 }
215 return snap215 return snap
216 }216 }
@@ -275,7 +275,11 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
275 state.attentionFrame = d.attentionFrame()275 state.attentionFrame = d.attentionFrame()
276 }276 }
277 if cur != nil {277 if cur != nil {
278- state.status = cur.statusSegments()278+ includeTerminalTitle := true
279+ if d != nil {
280+ includeTerminalTitle = d.currentTabsConfig().TerminalTitle
281+ }
282+ state.status = cur.statusSegments(includeTerminalTitle)
279 }283 }
280 if d != nil {284 if d != nil {
281 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)285 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
M internal/usecase/daemon/status_test.go +33 -3
@@ -117,7 +117,7 @@ func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {
117117 pane1.title.terminalTitle = "build output"
118118 pane1.mu.Unlock()
119119
120- snap := sess.statusSegments()
120+ snap := sess.statusSegments(true)
121121
122122 require.Len(t, snap.tabs, 2)
123123 require.Equal(t, "1", snap.tabs[0].name)
@@ -126,6 +126,36 @@ func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {
126126 require.Equal(t, "build output", snap.tabs[1].paneTitle)
127127 }
128128
129+func TestStatusSegmentsOmitsTerminalTitleWhenDisabled(t *testing.T) {
130+ p1, releasePTY1 := newBlockingPTY(t)
131+ p2, releasePTY2 := newBlockingPTY(t)
132+ _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
133+ defer releasePTY1()
134+ defer releasePTY2()
135+
136+ sess.tabs[1].name = "logs"
137+
138+ pane0 := sess.tabs[0].focusedPane()
139+ pane0.mu.Lock()
140+ pane0.title.processName = "vim"
141+ pane0.title.processNameValid = true
142+ pane0.title.terminalTitle = "server.go — vev"
143+ pane0.mu.Unlock()
144+
145+ pane1 := sess.tabs[1].focusedPane()
146+ pane1.mu.Lock()
147+ pane1.title.terminalTitle = "build output"
148+ pane1.mu.Unlock()
149+
150+ snap := sess.statusSegments(false)
151+
152+ require.Len(t, snap.tabs, 2)
153+ require.Equal(t, "1", snap.tabs[0].name)
154+ require.Equal(t, "vim", snap.tabs[0].paneTitle, "OSC title must be omitted while process name still shows")
155+ require.Equal(t, "logs", snap.tabs[1].name)
156+ require.Equal(t, "sh", snap.tabs[1].paneTitle, "no process name and terminal title disabled falls back to the shell fallback")
157+}
158+
129159 func TestFitTabLabels(t *testing.T) {
130160 tests := []struct {
131161 name string
@@ -234,7 +264,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
234264 Known: true,
235265 })
236266
237- bars := barState{status: sess.statusSegments(), theme: ac.getTheme()}
267+ bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()}
238268 frame, damage := composeClientFrameWithState(bars, win, true)
239269 out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage)
240270
@@ -720,7 +750,7 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
720750 sess.tabs[1].attentionAt = time.Unix(10, 0)
721751 sess.mu.Unlock()
722752
723- frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(), attentionFrame: 1}, win, true)
753+ frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)
724754
725755 // Tab 2's label is enriched with its focused pane's title ("sh", the
726756 // default shell fallback) and truncated to fit alongside the bell.
@@ -117,7 +117,7 @@ func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {
117 pane1.title.terminalTitle = "build output"117 pane1.title.terminalTitle = "build output"
118 pane1.mu.Unlock()118 pane1.mu.Unlock()
119 119
120- snap := sess.statusSegments()120+ snap := sess.statusSegments(true)
121 121
122 require.Len(t, snap.tabs, 2)122 require.Len(t, snap.tabs, 2)
123 require.Equal(t, "1", snap.tabs[0].name)123 require.Equal(t, "1", snap.tabs[0].name)
@@ -126,6 +126,36 @@ func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) {
126 require.Equal(t, "build output", snap.tabs[1].paneTitle)126 require.Equal(t, "build output", snap.tabs[1].paneTitle)
127 }127 }
128 128
129+func TestStatusSegmentsOmitsTerminalTitleWhenDisabled(t *testing.T) {
130+ p1, releasePTY1 := newBlockingPTY(t)
131+ p2, releasePTY2 := newBlockingPTY(t)
132+ _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2)
133+ defer releasePTY1()
134+ defer releasePTY2()
135+
136+ sess.tabs[1].name = "logs"
137+
138+ pane0 := sess.tabs[0].focusedPane()
139+ pane0.mu.Lock()
140+ pane0.title.processName = "vim"
141+ pane0.title.processNameValid = true
142+ pane0.title.terminalTitle = "server.go — vev"
143+ pane0.mu.Unlock()
144+
145+ pane1 := sess.tabs[1].focusedPane()
146+ pane1.mu.Lock()
147+ pane1.title.terminalTitle = "build output"
148+ pane1.mu.Unlock()
149+
150+ snap := sess.statusSegments(false)
151+
152+ require.Len(t, snap.tabs, 2)
153+ require.Equal(t, "1", snap.tabs[0].name)
154+ require.Equal(t, "vim", snap.tabs[0].paneTitle, "OSC title must be omitted while process name still shows")
155+ require.Equal(t, "logs", snap.tabs[1].name)
156+ require.Equal(t, "sh", snap.tabs[1].paneTitle, "no process name and terminal title disabled falls back to the shell fallback")
157+}
158+
129 func TestFitTabLabels(t *testing.T) {159 func TestFitTabLabels(t *testing.T) {
130 tests := []struct {160 tests := []struct {
131 name string161 name string
@@ -234,7 +264,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
234 Known: true,264 Known: true,
235 })265 })
236 266
237- bars := barState{status: sess.statusSegments(), theme: ac.getTheme()}267+ bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()}
238 frame, damage := composeClientFrameWithState(bars, win, true)268 frame, damage := composeClientFrameWithState(bars, win, true)
239 out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage)269 out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage)
240 270
@@ -720,7 +750,7 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
720 sess.tabs[1].attentionAt = time.Unix(10, 0)750 sess.tabs[1].attentionAt = time.Unix(10, 0)
721 sess.mu.Unlock()751 sess.mu.Unlock()
722 752
723- frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(), attentionFrame: 1}, win, true)753+ frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)
724 754
725 // Tab 2's label is enriched with its focused pane's title ("sh", the755 // Tab 2's label is enriched with its focused pane's title ("sh", the
726 // default shell fallback) and truncated to fit alongside the bell.756 // default shell fallback) and truncated to fit alongside the bell.
M internal/usecase/daemon/title_cache_regression_test.go +1 -1
@@ -15,7 +15,7 @@ func TestComposeClientFrameCachePrunesTitleGenerationsAfterLayoutChurn(t *testin
1515 first := win.focusedPane()
1616 var bars barCache
1717 var composed composedFrameCache
18- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
18+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
1919
2020 for i := 0; i < 8; i++ {
2121 id := layout.PaneID(fmt.Sprintf("pane-%d", i+2))
@@ -15,7 +15,7 @@ func TestComposeClientFrameCachePrunesTitleGenerationsAfterLayoutChurn(t *testin
15 first := win.focusedPane()15 first := win.focusedPane()
16 var bars barCache16 var bars barCache
17 var composed composedFrameCache17 var composed composedFrameCache
18- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}18+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
19 19
20 for i := 0; i < 8; i++ {20 for i := 0; i < 8; i++ {
21 id := layout.PaneID(fmt.Sprintf("pane-%d", i+2))21 id := layout.PaneID(fmt.Sprintf("pane-%d", i+2))

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