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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +207 -23
M README.md +7 -1
| @@ -58,6 +58,10 @@ floating.command = | |||
| 58 | 58 | floating.width = 80% | |
| 59 | 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 | 65 | # Rebindable actions. Leave a line out to keep its built-in binding. | |
| 62 | 66 | open-palette = alt+space | |
| 63 | 67 | 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 | 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 | 127 | ### Command palette anchoring | |
| 122 | 128 | ||
| 123 | 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 | 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 | 227 | ## Scrollback mode and copy | |
| 222 | 228 | ||
| @@ -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+space | 66 | open-palette = alt+space | ||
| 63 | toggle-floating-pane = alt+f | 67 | 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 anchoring | 127 | ### 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 copy | 227 | ## 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) { | |||
| 32 | 32 | seenBindingKeys := make(map[string]bool) | |
| 33 | 33 | seenFloatingKeys := make(map[string]bool) | |
| 34 | 34 | seenPaletteKeys := make(map[string]bool) | |
| 35 | + | seenTabsKeys := make(map[string]bool) | |
| 35 | 36 | ||
| 36 | 37 | scanner := bufio.NewScanner(r) | |
| 37 | 38 | lineNo := 0 | |
| @@ -103,6 +104,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||
| 103 | 104 | continue | |
| 104 | 105 | } | |
| 105 | 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 | 115 | case key == "floating.command": | |
| 107 | 116 | warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo) | |
| 108 | 117 | command, ok := parseBarCommand(value) | |
| @@ -281,6 +290,17 @@ func parsePercentage(value string) (int, bool) { | |||
| 281 | 290 | 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 | 304 | func parseBarCommand(value string) (string, bool) { | |
| 285 | 305 | value = strings.TrimSpace(value) | |
| 286 | 306 | 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 := 0 | 38 | lineNo := 0 | ||
| @@ -103,6 +104,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) { | |||||
| 103 | continue | 104 | 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 <= 100 | 290 | 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) { | |||
| 338 | 338 | if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 { | |
| 339 | 339 | tt.want.Floating = domain.Defaults().Floating | |
| 340 | 340 | } | |
| 341 | + | if !tt.want.Tabs.TerminalTitle { | |
| 342 | + | tt.want.Tabs = domain.Defaults().Tabs | |
| 343 | + | } | |
| 341 | 344 | if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet { | |
| 342 | 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 | 413 | func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) { | |
| 359 | 414 | t.Parallel() | |
| 360 | 415 | ||
| @@ -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().Floating | 339 | 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 { | |||
| 61 | 61 | 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 | 71 | // Config is the user-editable vev configuration after parsing. Unknown binding | |
| 65 | 72 | // keys are preserved here (in BindingEntries, in file order) so the usecase | |
| 66 | 73 | // layer can decide which actions it understands. | |
| @@ -72,6 +79,7 @@ type Config struct { | |||
| 72 | 79 | Snapshot SnapshotConfig | |
| 73 | 80 | Palette PaletteConfig | |
| 74 | 81 | Floating FloatingConfig | |
| 82 | + | Tabs TabsConfig | |
| 75 | 83 | } | |
| 76 | 84 | ||
| 77 | 85 | // Warning describes a non-fatal config problem. Parsers and reloaders should | |
| @@ -99,5 +107,8 @@ func Defaults() Config { | |||
| 99 | 107 | Width: 80, | |
| 100 | 108 | Height: 80, | |
| 101 | 109 | }, | |
| 110 | + | Tabs: TabsConfig{ | |
| 111 | + | TerminalTitle: true, | |
| 112 | + | }, | |
| 102 | 113 | } | |
| 103 | 114 | } | |
| @@ -61,6 +61,13 @@ type FloatingConfig struct { | |||||
| 61 | Height int | 61 | 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 binding | 71 | // Config is the user-editable vev configuration after parsing. Unknown binding | ||
| 65 | // keys are preserved here (in BindingEntries, in file order) so the usecase | 72 | // 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 SnapshotConfig | 79 | Snapshot SnapshotConfig | ||
| 73 | Palette PaletteConfig | 80 | Palette PaletteConfig | ||
| 74 | Floating FloatingConfig | 81 | Floating FloatingConfig | ||
| 82 | + | Tabs TabsConfig | |||
| 75 | } | 83 | } | ||
| 76 | 84 | ||||
| 77 | // Warning describes a non-fatal config problem. Parsers and reloaders should | 85 | // 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) { | |||
| 31 | 31 | d.floatingConfig.Store(&floating) | |
| 32 | 32 | palette := cfg.Palette | |
| 33 | 33 | d.paletteConfig.Store(&palette) | |
| 34 | + | tabs := cfg.Tabs | |
| 35 | + | d.tabsConfig.Store(&tabs) | |
| 34 | 36 | d.themeMode.Store(uint32(cfg.Theme)) | |
| 35 | 37 | if d.barScripts != nil { | |
| 36 | 38 | d.barScripts.mu.Lock() | |
| @@ -187,6 +189,13 @@ func (d *Daemon) currentPaletteConfig() domain.PaletteConfig { | |||
| 187 | 189 | 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 | 199 | func (d *Daemon) currentFloatingConfig() domain.FloatingConfig { | |
| 191 | 200 | if cfg := d.floatingConfig.Load(); cfg != nil { | |
| 192 | 201 | 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.Palette | 32 | 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().Palette | 189 | 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 *cfg | 201 | return *cfg | ||
M internal/usecase/daemon/config_test.go +18 -0
| @@ -119,6 +119,24 @@ func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) { | |||
| 119 | 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 | 140 | func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) { | |
| 123 | 141 | tests := []struct { | |
| 124 | 142 | 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 string | 142 | name string | ||
M internal/usecase/daemon/copymode_test.go +2 -2
| @@ -115,7 +115,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) { | |||
| 115 | 115 | snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame) | |
| 116 | 116 | pane.mu.Unlock() | |
| 117 | 117 | mode := scopy.NewMode(snap) | |
| 118 | - | bars := barState{status: sess.statusSegments()} | |
| 118 | + | bars := barState{status: sess.statusSegments(true)} | |
| 119 | 119 | ||
| 120 | 120 | base := renderer.NewFrame(80, 25) | |
| 121 | 121 | target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23} | |
| @@ -150,7 +150,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 150 | 150 | snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame) | |
| 151 | 151 | mode := scopy.NewMode(snap) | |
| 152 | 152 | ||
| 153 | - | bars := barState{status: sess.statusSegments()} | |
| 153 | + | bars := barState{status: sess.statusSegments(true)} | |
| 154 | 154 | base, _ := composeClientFrameWithState(bars, tb, true) | |
| 155 | 155 | frame, damage := composeCopyClientFrame(mode, tb.focusedPane(), domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars) | |
| 156 | 156 | ||
| @@ -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 { | |||
| 137 | 137 | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | |
| 138 | 138 | floatingConfig atomic.Pointer[domain.FloatingConfig] | |
| 139 | 139 | paletteConfig atomic.Pointer[domain.PaletteConfig] | |
| 140 | + | tabsConfig atomic.Pointer[domain.TabsConfig] | |
| 140 | 141 | themeMode atomic.Uint32 | |
| 141 | 142 | barScripts *barScriptState | |
| 142 | 143 | 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.Uint32 | 141 | themeMode atomic.Uint32 | ||
| 141 | barScripts *barScriptState | 142 | barScripts *barScriptState | ||
| 142 | resumeParkGrace time.Duration | 143 | resumeParkGrace time.Duration | ||
M internal/usecase/daemon/pane_titles.go +9 -4
| @@ -78,9 +78,11 @@ func composeTabTitle(tabName, paneTitle string) string { | |||
| 78 | 78 | return tabName + " (" + paneTitle + ")" | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 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 { | |
| 84 | 86 | tb.mu.Lock() | |
| 85 | 87 | p := tb.focusedPane() | |
| 86 | 88 | tb.mu.Unlock() | |
| @@ -89,7 +91,10 @@ func (tb *tab) focusedPaneTitleLocked() string { | |||
| 89 | 91 | } | |
| 90 | 92 | p.mu.Lock() | |
| 91 | 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 | 100 | // 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 must | 81 | + | // 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 half | 100 | // 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) { | |||
| 67 | 67 | d.refreshSessionFocusedTitles(s) | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | + | includeTerminalTitle := d.currentTabsConfig().TerminalTitle | |
| 70 | 71 | views := make([]picker.SessionView, 0, len(sessions)+len(stopped)) | |
| 71 | 72 | curTab := 0 | |
| 72 | 73 | for _, s := range sessions { | |
| @@ -74,7 +75,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 74 | 75 | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))} | |
| 75 | 76 | sessionAttention := false | |
| 76 | 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 | 79 | if tb.attention { | |
| 79 | 80 | label = attentionSuffix(label) | |
| 80 | 81 | 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 := 0 | 72 | 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 := false | 76 | 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 = true | 81 | sessionAttention = true | ||
M internal/usecase/daemon/picker_test.go +24 -0
| @@ -91,6 +91,30 @@ func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |||
| 91 | 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 | 118 | func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | |
| 95 | 119 | p1, release1 := newBlockingPTY(t) | |
| 96 | 120 | 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() { | |||
| 604 | 604 | } | |
| 605 | 605 | ||
| 606 | 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 | 610 | 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) { | |||
| 529 | 529 | var bars barCache | |
| 530 | 530 | var composed composedFrameCache | |
| 531 | 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 | 533 | left.screen.ClearDamage() | |
| 534 | 534 | right.screen.ClearDamage() | |
| 535 | 535 | left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'}) | |
| 536 | 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 | 540 | require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted") | |
| 541 | 541 | require.Equal(t, 'x', frame.At(22, 1).Rune) | |
| @@ -548,7 +548,7 @@ func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) { | |||
| 548 | 548 | p.screen.Write([]byte("old")) | |
| 549 | 549 | var bars barCache | |
| 550 | 550 | 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 | 553 | _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |
| 554 | 554 | require.NotEmpty(t, damage) | |
| @@ -572,7 +572,7 @@ func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) { | |||
| 572 | 572 | p2.screen.Write([]byte("shown")) | |
| 573 | 573 | var bars barCache | |
| 574 | 574 | 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 | 577 | _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |
| 578 | 578 | ||
| @@ -591,7 +591,7 @@ func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) { | |||
| 591 | 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 | 592 | var bars barCache | |
| 593 | 593 | 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 | 595 | win.tree.Focus = left.id | |
| 596 | 596 | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | |
| 597 | 597 | left.screen.ClearDamage() | |
| @@ -616,7 +616,7 @@ func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) { | |||
| 616 | 616 | right.screen.Write([]byte("R")) | |
| 617 | 617 | var bars barCache | |
| 618 | 618 | 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 | 620 | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |
| 621 | 621 | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | |
| 622 | 622 | require.Equal(t, '│', composed.frame.At(20, 1).Rune) | |
| @@ -725,7 +725,7 @@ func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) { | |||
| 725 | 725 | win.panes[p2.id] = p2 | |
| 726 | 726 | var bars barCache | |
| 727 | 727 | 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 | 729 | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | |
| 730 | 730 | p1.screen.ClearDamage() | |
| 731 | 731 | p2.screen.ClearDamage() | |
| @@ -529,13 +529,13 @@ func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) { | |||||
| 529 | var bars barCache | 529 | var bars barCache | ||
| 530 | var composed composedFrameCache | 530 | 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 barCache | 549 | var bars barCache | ||
| 550 | var composed composedFrameCache | 550 | 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 barCache | 573 | var bars barCache | ||
| 574 | var composed composedFrameCache | 574 | 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 barCache | 592 | var bars barCache | ||
| 593 | var composed composedFrameCache | 593 | 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.id | 595 | 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 barCache | 617 | var bars barCache | ||
| 618 | var composed composedFrameCache | 618 | 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] = p2 | 725 | win.panes[p2.id] = p2 | ||
| 726 | var bars barCache | 726 | var bars barCache | ||
| 727 | var composed composedFrameCache | 727 | 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 { | |||
| 198 | 198 | attention bool | |
| 199 | 199 | } | |
| 200 | 200 | ||
| 201 | - | func (s *session) statusSegments() statusSnapshot { | |
| 201 | + | func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot { | |
| 202 | 202 | s.mu.Lock() | |
| 203 | 203 | defer s.mu.Unlock() | |
| 204 | 204 | name := s.name | |
| @@ -210,7 +210,7 @@ func (s *session) statusSegments() statusSnapshot { | |||
| 210 | 210 | name := tabDisplayName(tb, i) | |
| 211 | 211 | active := i == s.active | |
| 212 | 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 | 215 | return snap | |
| 216 | 216 | } | |
| @@ -275,7 +275,11 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | |||
| 275 | 275 | state.attentionFrame = d.attentionFrame() | |
| 276 | 276 | } | |
| 277 | 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 | 284 | if d != nil { | |
| 281 | 285 | state.topRight, state.bottomRight = d.barScriptSnapshot(cur) | |
| @@ -198,7 +198,7 @@ type statusTab struct { | |||||
| 198 | attention bool | 198 | 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.name | 204 | 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.active | 211 | 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 snap | 215 | 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) { | |||
| 117 | 117 | pane1.title.terminalTitle = "build output" | |
| 118 | 118 | pane1.mu.Unlock() | |
| 119 | 119 | ||
| 120 | - | snap := sess.statusSegments() | |
| 120 | + | snap := sess.statusSegments(true) | |
| 121 | 121 | ||
| 122 | 122 | require.Len(t, snap.tabs, 2) | |
| 123 | 123 | require.Equal(t, "1", snap.tabs[0].name) | |
| @@ -126,6 +126,36 @@ func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) { | |||
| 126 | 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 | 159 | func TestFitTabLabels(t *testing.T) { | |
| 130 | 160 | tests := []struct { | |
| 131 | 161 | name string | |
| @@ -234,7 +264,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) { | |||
| 234 | 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 | 268 | frame, damage := composeClientFrameWithState(bars, win, true) | |
| 239 | 269 | out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage) | |
| 240 | 270 | ||
| @@ -720,7 +750,7 @@ func TestTopBarRendersAttentionBell(t *testing.T) { | |||
| 720 | 750 | sess.tabs[1].attentionAt = time.Unix(10, 0) | |
| 721 | 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 | 755 | // Tab 2's label is enriched with its focused pane's title ("sh", the | |
| 726 | 756 | // 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 string | 161 | 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", the | 755 | // 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 | |||
| 15 | 15 | first := win.focusedPane() | |
| 16 | 16 | var bars barCache | |
| 17 | 17 | 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 | 20 | for i := 0; i < 8; i++ { | |
| 21 | 21 | 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 barCache | 16 | var bars barCache | ||
| 17 | var composed composedFrameCache | 17 | 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)) | ||