Merge pull request #70 from bnema/feature/enriched-tab-titles
feat: enrich tab titles with focused pane process titles (top bar + session picker)
22 files · +527 -29
M README.md +8 -2
| @@ -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. | |
| @@ -199,7 +205,7 @@ Rename prompts are prefilled with the current session name. Empty names and name | |||
| 199 | 205 | ||
| 200 | 206 | ## Session picker | |
| 201 | 207 | ||
| 202 | - | Open it with `SSP` in the palette. It lists sessions and tabs, previews the selected target when space allows, and marks stopped sessions. | |
| 208 | + | Open it with `SSP` in the palette. It lists sessions and tabs — tab entries use the same `name (title)` form as the top bar, ellipsized to the list width — previews the selected target when space allows, and marks stopped sessions. | |
| 203 | 209 | ||
| 204 | 210 | | Key | Action | | |
| 205 | 211 | |---|---| | |
| @@ -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 numbered tabs. 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. | ||
| @@ -199,7 +205,7 @@ Rename prompts are prefilled with the current session name. Empty names and name | |||||
| 199 | 205 | ||||
| 200 | ## Session picker | 206 | ## Session picker | ||
| 201 | 207 | ||||
| 202 | - | Open it with `SSP` in the palette. It lists sessions and tabs, previews the selected target when space allows, and marks stopped sessions. | 208 | + | Open it with `SSP` in the palette. It lists sessions and tabs — tab entries use the same `name (title)` form as the top bar, ellipsized to the list width — previews the selected target when space allows, and marks stopped sessions. |
| 203 | 209 | ||||
| 204 | | Key | Action | | 210 | | Key | Action | | ||
| 205 | |---|---| | 211 | |---|---| | ||
| @@ -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 numbered tabs. 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/app/daemon_integration_test.go +5 -2
| @@ -222,8 +222,11 @@ func TestIntegration_CommandPaletteCreatesTab(t *testing.T) { | |||
| 222 | 222 | awaitText(t, p, sz, "Commands") | |
| 223 | 223 | ||
| 224 | 224 | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})})) | |
| 225 | - | text := awaitScreenText(t, p, sz, " 1 2 ") | |
| 226 | - | require.Contains(t, text, " 1 2 ") | |
| 225 | + | // Tab labels are enriched with the focused pane's title; with no process | |
| 226 | + | // inspector wired in this test, that title falls back to the shell's | |
| 227 | + | // basename ("sh"). | |
| 228 | + | text := awaitScreenText(t, p, sz, " 1 (sh) 2 (sh) ") | |
| 229 | + | require.Contains(t, text, " 1 (sh) 2 (sh) ") | |
| 227 | 230 | } | |
| 228 | 231 | ||
| 229 | 232 | func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) { | |
| @@ -222,8 +222,11 @@ func TestIntegration_CommandPaletteCreatesTab(t *testing.T) { | |||||
| 222 | awaitText(t, p, sz, "Commands") | 222 | awaitText(t, p, sz, "Commands") | ||
| 223 | 223 | ||||
| 224 | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})})) | 224 | require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("CNT\r")})})) | ||
| 225 | - | text := awaitScreenText(t, p, sz, " 1 2 ") | 225 | + | // Tab labels are enriched with the focused pane's title; with no process |
| 226 | - | require.Contains(t, text, " 1 2 ") | 226 | + | // inspector wired in this test, that title falls back to the shell's |
| 227 | + | // basename ("sh"). | |||
| 228 | + | text := awaitScreenText(t, p, sz, " 1 (sh) 2 (sh) ") | |||
| 229 | + | require.Contains(t, text, " 1 (sh) 2 (sh) ") | |||
| 227 | } | 230 | } | ||
| 228 | 231 | ||||
| 229 | func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) { | 232 | func TestIntegration_CommandPaletteRenamesEphemeralSession(t *testing.T) { | ||
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 +3 -3
| @@ -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,13 +150,13 @@ 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, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars) | |
| 156 | 156 | ||
| 157 | 157 | require.Equal(t, 80, frame.Width) | |
| 158 | 158 | require.Equal(t, 25, frame.Height) | |
| 159 | - | require.Equal(t, " 1", strings.TrimRight(rowText(frame.Row(0)), " ")) | |
| 159 | + | require.Equal(t, " 1 (sh)", strings.TrimRight(rowText(frame.Row(0)), " ")) | |
| 160 | 160 | require.Contains(t, rowText(frame.Row(1)), "live") | |
| 161 | 161 | require.Contains(t, rowText(frame.Row(24)), "[SCROLL]") | |
| 162 | 162 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| @@ -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,13 +150,13 @@ 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, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars) | 155 | frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars) | ||
| 156 | 156 | ||||
| 157 | require.Equal(t, 80, frame.Width) | 157 | require.Equal(t, 80, frame.Width) | ||
| 158 | require.Equal(t, 25, frame.Height) | 158 | require.Equal(t, 25, frame.Height) | ||
| 159 | - | require.Equal(t, " 1", strings.TrimRight(rowText(frame.Row(0)), " ")) | 159 | + | require.Equal(t, " 1 (sh)", strings.TrimRight(rowText(frame.Row(0)), " ")) |
| 160 | require.Contains(t, rowText(frame.Row(1)), "live") | 160 | require.Contains(t, rowText(frame.Row(1)), "live") | ||
| 161 | require.Contains(t, rowText(frame.Row(24)), "[SCROLL]") | 161 | require.Contains(t, rowText(frame.Row(24)), "[SCROLL]") | ||
| 162 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | 162 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | ||
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 +56 -0
| @@ -47,6 +47,10 @@ func (p *pane) displayTitleLocked() string { | |||
| 47 | 47 | // cached composition unless that fallback changes the displayed title. | |
| 48 | 48 | func (p *pane) setDisplayFallback(fallback string) { | |
| 49 | 49 | p.mu.Lock() | |
| 50 | + | if p.title.displayFallback == fallback { | |
| 51 | + | p.mu.Unlock() | |
| 52 | + | return | |
| 53 | + | } | |
| 50 | 54 | oldTitle := p.displayTitleLocked() | |
| 51 | 55 | p.title.displayFallback = fallback | |
| 52 | 56 | if oldTitle != p.displayTitleLocked() { | |
| @@ -65,6 +69,58 @@ func (p *pane) refreshTerminalTitleLocked() { | |||
| 65 | 69 | } | |
| 66 | 70 | } | |
| 67 | 71 | ||
| 72 | + | // composeTabTitle renders "name (paneTitle)". An empty paneTitle, or one equal | |
| 73 | + | // to the tab name, collapses to just the name. | |
| 74 | + | func composeTabTitle(tabName, paneTitle string) string { | |
| 75 | + | if paneTitle == "" || paneTitle == tabName { | |
| 76 | + | return tabName | |
| 77 | + | } | |
| 78 | + | return tabName + " (" + paneTitle + ")" | |
| 79 | + | } | |
| 80 | + | ||
| 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 { | |
| 86 | + | tb.mu.Lock() | |
| 87 | + | p := tb.focusedPane() | |
| 88 | + | tb.mu.Unlock() | |
| 89 | + | if p == nil { | |
| 90 | + | return "" | |
| 91 | + | } | |
| 92 | + | p.mu.Lock() | |
| 93 | + | defer p.mu.Unlock() | |
| 94 | + | if includeTerminalTitle { | |
| 95 | + | return p.displayTitleLocked() | |
| 96 | + | } | |
| 97 | + | return formatPaneTitle(p.title.processName, "", p.title.displayFallback) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // refreshSessionFocusedTitles opportunistically refreshes the process-name half | |
| 101 | + | // of every tab's focused pane. Snapshot panes under locks, then refresh with no | |
| 102 | + | // lock held (refreshPaneDisplayTitle re-enters p.mu itself). TTL-throttled. | |
| 103 | + | func (d *Daemon) refreshSessionFocusedTitles(sess *session) { | |
| 104 | + | if sess == nil { | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | fallback := d.paneTitleFallback() | |
| 108 | + | sess.mu.Lock() | |
| 109 | + | panes := make([]*pane, 0, len(sess.tabs)) | |
| 110 | + | for _, tb := range sess.tabs { | |
| 111 | + | tb.mu.Lock() | |
| 112 | + | if p := tb.focusedPane(); p != nil { | |
| 113 | + | panes = append(panes, p) | |
| 114 | + | } | |
| 115 | + | tb.mu.Unlock() | |
| 116 | + | } | |
| 117 | + | sess.mu.Unlock() | |
| 118 | + | for _, p := range panes { | |
| 119 | + | p.setDisplayFallback(fallback) | |
| 120 | + | d.refreshPaneDisplayTitle(sess, p, false) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 68 | 124 | func (d *Daemon) paneTitleFallback() string { | |
| 69 | 125 | fallback := filepath.Base(d.shell) | |
| 70 | 126 | if fallback == "." || fallback == string(filepath.Separator) || fallback == "" { | |
| @@ -47,6 +47,10 @@ func (p *pane) displayTitleLocked() string { | |||||
| 47 | // cached composition unless that fallback changes the displayed title. | 47 | // cached composition unless that fallback changes the displayed title. | ||
| 48 | func (p *pane) setDisplayFallback(fallback string) { | 48 | func (p *pane) setDisplayFallback(fallback string) { | ||
| 49 | p.mu.Lock() | 49 | p.mu.Lock() | ||
| 50 | + | if p.title.displayFallback == fallback { | |||
| 51 | + | p.mu.Unlock() | |||
| 52 | + | return | |||
| 53 | + | } | |||
| 50 | oldTitle := p.displayTitleLocked() | 54 | oldTitle := p.displayTitleLocked() | ||
| 51 | p.title.displayFallback = fallback | 55 | p.title.displayFallback = fallback | ||
| 52 | if oldTitle != p.displayTitleLocked() { | 56 | if oldTitle != p.displayTitleLocked() { | ||
| @@ -65,6 +69,58 @@ func (p *pane) refreshTerminalTitleLocked() { | |||||
| 65 | } | 69 | } | ||
| 66 | } | 70 | } | ||
| 67 | 71 | ||||
| 72 | + | // composeTabTitle renders "name (paneTitle)". An empty paneTitle, or one equal | |||
| 73 | + | // to the tab name, collapses to just the name. | |||
| 74 | + | func composeTabTitle(tabName, paneTitle string) string { | |||
| 75 | + | if paneTitle == "" || paneTitle == tabName { | |||
| 76 | + | return tabName | |||
| 77 | + | } | |||
| 78 | + | return tabName + " (" + paneTitle + ")" | |||
| 79 | + | } | |||
| 80 | + | ||||
| 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 { | |||
| 86 | + | tb.mu.Lock() | |||
| 87 | + | p := tb.focusedPane() | |||
| 88 | + | tb.mu.Unlock() | |||
| 89 | + | if p == nil { | |||
| 90 | + | return "" | |||
| 91 | + | } | |||
| 92 | + | p.mu.Lock() | |||
| 93 | + | defer p.mu.Unlock() | |||
| 94 | + | if includeTerminalTitle { | |||
| 95 | + | return p.displayTitleLocked() | |||
| 96 | + | } | |||
| 97 | + | return formatPaneTitle(p.title.processName, "", p.title.displayFallback) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // refreshSessionFocusedTitles opportunistically refreshes the process-name half | |||
| 101 | + | // of every tab's focused pane. Snapshot panes under locks, then refresh with no | |||
| 102 | + | // lock held (refreshPaneDisplayTitle re-enters p.mu itself). TTL-throttled. | |||
| 103 | + | func (d *Daemon) refreshSessionFocusedTitles(sess *session) { | |||
| 104 | + | if sess == nil { | |||
| 105 | + | return | |||
| 106 | + | } | |||
| 107 | + | fallback := d.paneTitleFallback() | |||
| 108 | + | sess.mu.Lock() | |||
| 109 | + | panes := make([]*pane, 0, len(sess.tabs)) | |||
| 110 | + | for _, tb := range sess.tabs { | |||
| 111 | + | tb.mu.Lock() | |||
| 112 | + | if p := tb.focusedPane(); p != nil { | |||
| 113 | + | panes = append(panes, p) | |||
| 114 | + | } | |||
| 115 | + | tb.mu.Unlock() | |||
| 116 | + | } | |||
| 117 | + | sess.mu.Unlock() | |||
| 118 | + | for _, p := range panes { | |||
| 119 | + | p.setDisplayFallback(fallback) | |||
| 120 | + | d.refreshPaneDisplayTitle(sess, p, false) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 68 | func (d *Daemon) paneTitleFallback() string { | 124 | func (d *Daemon) paneTitleFallback() string { | ||
| 69 | fallback := filepath.Base(d.shell) | 125 | fallback := filepath.Base(d.shell) | ||
| 70 | if fallback == "." || fallback == string(filepath.Separator) || fallback == "" { | 126 | if fallback == "." || fallback == string(filepath.Separator) || fallback == "" { | ||
M internal/usecase/daemon/pane_titles_test.go +16 -0
| @@ -28,6 +28,22 @@ func TestFormatPaneTitle(t *testing.T) { | |||
| 28 | 28 | } | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | + | func TestComposeTabTitle(t *testing.T) { | |
| 32 | + | tests := []struct { | |
| 33 | + | name, tabName, paneTitle, want string | |
| 34 | + | }{ | |
| 35 | + | {name: "name and title", tabName: "api", paneTitle: "nvim: server.go — vev", want: "api (nvim: server.go — vev)"}, | |
| 36 | + | {name: "empty pane title", tabName: "api", paneTitle: "", want: "api"}, | |
| 37 | + | {name: "pane title equals name", tabName: "api", paneTitle: "api", want: "api"}, | |
| 38 | + | {name: "unnamed tab style", tabName: "1", paneTitle: "zsh", want: "1 (zsh)"}, | |
| 39 | + | } | |
| 40 | + | for _, tt := range tests { | |
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |
| 42 | + | require.Equal(t, tt.want, composeTabTitle(tt.tabName, tt.paneTitle)) | |
| 43 | + | }) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 31 | 47 | func TestPaneTerminalTitleGenerationChangesOnlyWhenTitleChanges(t *testing.T) { | |
| 32 | 48 | p := newPane("pane", nil, domain.Size{Cols: 20, Rows: 5}) | |
| 33 | 49 | p.mu.Lock() | |
| @@ -28,6 +28,22 @@ func TestFormatPaneTitle(t *testing.T) { | |||||
| 28 | } | 28 | } | ||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | + | func TestComposeTabTitle(t *testing.T) { | |||
| 32 | + | tests := []struct { | |||
| 33 | + | name, tabName, paneTitle, want string | |||
| 34 | + | }{ | |||
| 35 | + | {name: "name and title", tabName: "api", paneTitle: "nvim: server.go — vev", want: "api (nvim: server.go — vev)"}, | |||
| 36 | + | {name: "empty pane title", tabName: "api", paneTitle: "", want: "api"}, | |||
| 37 | + | {name: "pane title equals name", tabName: "api", paneTitle: "api", want: "api"}, | |||
| 38 | + | {name: "unnamed tab style", tabName: "1", paneTitle: "zsh", want: "1 (zsh)"}, | |||
| 39 | + | } | |||
| 40 | + | for _, tt := range tests { | |||
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 42 | + | require.Equal(t, tt.want, composeTabTitle(tt.tabName, tt.paneTitle)) | |||
| 43 | + | }) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 31 | func TestPaneTerminalTitleGenerationChangesOnlyWhenTitleChanges(t *testing.T) { | 47 | func TestPaneTerminalTitleGenerationChangesOnlyWhenTitleChanges(t *testing.T) { | ||
| 32 | p := newPane("pane", nil, domain.Size{Cols: 20, Rows: 5}) | 48 | p := newPane("pane", nil, domain.Size{Cols: 20, Rows: 5}) | ||
| 33 | p.mu.Lock() | 49 | p.mu.Lock() | ||
M internal/usecase/daemon/picker.go +6 -1
| @@ -63,6 +63,11 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 63 | 63 | sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name }) | |
| 64 | 64 | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | |
| 65 | 65 | ||
| 66 | + | for _, s := range sessions { | |
| 67 | + | d.refreshSessionFocusedTitles(s) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | includeTerminalTitle := d.currentTabsConfig().TerminalTitle | |
| 66 | 71 | views := make([]picker.SessionView, 0, len(sessions)+len(stopped)) | |
| 67 | 72 | curTab := 0 | |
| 68 | 73 | for _, s := range sessions { | |
| @@ -70,7 +75,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 70 | 75 | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))} | |
| 71 | 76 | sessionAttention := false | |
| 72 | 77 | for i, tb := range s.tabs { | |
| 73 | - | label := tabDisplayName(tb, i) | |
| 78 | + | label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitle(includeTerminalTitle)) | |
| 74 | 79 | if tb.attention { | |
| 75 | 80 | label = attentionSuffix(label) | |
| 76 | 81 | sessionAttention = true | |
| @@ -63,6 +63,11 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 63 | sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name }) | 63 | sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name }) | ||
| 64 | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | 64 | sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name }) | ||
| 65 | 65 | ||||
| 66 | + | for _, s := range sessions { | |||
| 67 | + | d.refreshSessionFocusedTitles(s) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | includeTerminalTitle := d.currentTabsConfig().TerminalTitle | |||
| 66 | views := make([]picker.SessionView, 0, len(sessions)+len(stopped)) | 71 | views := make([]picker.SessionView, 0, len(sessions)+len(stopped)) | ||
| 67 | curTab := 0 | 72 | curTab := 0 | ||
| 68 | for _, s := range sessions { | 73 | for _, s := range sessions { | ||
| @@ -70,7 +75,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||||
| 70 | 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))} | ||
| 71 | sessionAttention := false | 76 | sessionAttention := false | ||
| 72 | for i, tb := range s.tabs { | 77 | for i, tb := range s.tabs { | ||
| 73 | - | label := tabDisplayName(tb, i) | 78 | + | label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitle(includeTerminalTitle)) |
| 74 | if tb.attention { | 79 | if tb.attention { | ||
| 75 | label = attentionSuffix(label) | 80 | label = attentionSuffix(label) | ||
| 76 | sessionAttention = true | 81 | sessionAttention = true | ||
M internal/usecase/daemon/picker_test.go +49 -0
| @@ -66,6 +66,55 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 66 | 66 | require.Equal(t, []string{"shell", "logs "}, views[1].Tabs) | |
| 67 | 67 | } | |
| 68 | 68 | ||
| 69 | + | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |
| 70 | + | p1, releasePTY1 := newBlockingPTY(t) | |
| 71 | + | p2, releasePTY2 := newBlockingPTY(t) | |
| 72 | + | defer releasePTY1() | |
| 73 | + | defer releasePTY2() | |
| 74 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 75 | + | d.shell = "/bin/sh" | |
| 76 | + | sess.tabs[1].name = "logs" | |
| 77 | + | ||
| 78 | + | pane0 := sess.tabs[0].focusedPane() | |
| 79 | + | pane0.mu.Lock() | |
| 80 | + | pane0.title.processName = "vim" | |
| 81 | + | pane0.title.processNameValid = true | |
| 82 | + | pane0.mu.Unlock() | |
| 83 | + | ||
| 84 | + | sess.mu.Lock() | |
| 85 | + | sess.tabs[1].attention = true | |
| 86 | + | sess.mu.Unlock() | |
| 87 | + | ||
| 88 | + | views, _ := d.pickerViews(sess) | |
| 89 | + | ||
| 90 | + | require.Len(t, views, 1) | |
| 91 | + | require.Equal(t, []string{"1 (vim)", attentionSuffix("logs (sh)")}, views[0].Tabs) | |
| 92 | + | } | |
| 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 | + | ||
| 69 | 118 | func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | |
| 70 | 119 | p1, release1 := newBlockingPTY(t) | |
| 71 | 120 | p2, release2 := newBlockingPTY(t) | |
| @@ -66,6 +66,55 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||||
| 66 | require.Equal(t, []string{"shell", "logs "}, views[1].Tabs) | 66 | require.Equal(t, []string{"shell", "logs "}, views[1].Tabs) | ||
| 67 | } | 67 | } | ||
| 68 | 68 | ||||
| 69 | + | func TestPickerViewsComposesFocusedPaneTitleWithAttentionSuffix(t *testing.T) { | |||
| 70 | + | p1, releasePTY1 := newBlockingPTY(t) | |||
| 71 | + | p2, releasePTY2 := newBlockingPTY(t) | |||
| 72 | + | defer releasePTY1() | |||
| 73 | + | defer releasePTY2() | |||
| 74 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p1, p2) | |||
| 75 | + | d.shell = "/bin/sh" | |||
| 76 | + | sess.tabs[1].name = "logs" | |||
| 77 | + | ||||
| 78 | + | pane0 := sess.tabs[0].focusedPane() | |||
| 79 | + | pane0.mu.Lock() | |||
| 80 | + | pane0.title.processName = "vim" | |||
| 81 | + | pane0.title.processNameValid = true | |||
| 82 | + | pane0.mu.Unlock() | |||
| 83 | + | ||||
| 84 | + | sess.mu.Lock() | |||
| 85 | + | sess.tabs[1].attention = true | |||
| 86 | + | sess.mu.Unlock() | |||
| 87 | + | ||||
| 88 | + | views, _ := d.pickerViews(sess) | |||
| 89 | + | ||||
| 90 | + | require.Len(t, views, 1) | |||
| 91 | + | require.Equal(t, []string{"1 (vim)", attentionSuffix("logs (sh)")}, views[0].Tabs) | |||
| 92 | + | } | |||
| 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 | + | ||||
| 69 | func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | 118 | func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) { | ||
| 70 | p1, release1 := newBlockingPTY(t) | 119 | p1, release1 := newBlockingPTY(t) | ||
| 71 | p2, release2 := newBlockingPTY(t) | 120 | p2, release2 := newBlockingPTY(t) | ||
M internal/usecase/daemon/render.go +2 -1
| @@ -344,6 +344,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 344 | 344 | if overlays.previewTab != tb { | |
| 345 | 345 | preview = snapshotPickerPreview(overlays.previewTab) | |
| 346 | 346 | } | |
| 347 | + | d.refreshSessionFocusedTitles(sess) | |
| 347 | 348 | bars := d.barStateForClient(sess, ac, overlays.copyFeedback) | |
| 348 | 349 | bars.theme = ac.getTheme() | |
| 349 | 350 | _, attentionVisible := pulseStyle(bars.attentionFrame) | |
| @@ -603,7 +604,7 @@ func (c *composedFrameCache) invalidate() { | |||
| 603 | 604 | } | |
| 604 | 605 | ||
| 605 | 606 | func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) { | |
| 606 | - | return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...) | |
| 607 | + | return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...) | |
| 607 | 608 | } | |
| 608 | 609 | ||
| 609 | 610 | func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) { | |
| @@ -344,6 +344,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 344 | if overlays.previewTab != tb { | 344 | if overlays.previewTab != tb { | ||
| 345 | preview = snapshotPickerPreview(overlays.previewTab) | 345 | preview = snapshotPickerPreview(overlays.previewTab) | ||
| 346 | } | 346 | } | ||
| 347 | + | d.refreshSessionFocusedTitles(sess) | |||
| 347 | bars := d.barStateForClient(sess, ac, overlays.copyFeedback) | 348 | bars := d.barStateForClient(sess, ac, overlays.copyFeedback) | ||
| 348 | bars.theme = ac.getTheme() | 349 | bars.theme = ac.getTheme() | ||
| 349 | _, attentionVisible := pulseStyle(bars.attentionFrame) | 350 | _, attentionVisible := pulseStyle(bars.attentionFrame) | ||
| @@ -603,7 +604,7 @@ func (c *composedFrameCache) invalidate() { | |||||
| 603 | } | 604 | } | ||
| 604 | 605 | ||||
| 605 | 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) { | ||
| 606 | - | return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...) | 607 | + | return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...) |
| 607 | } | 608 | } | ||
| 608 | 609 | ||||
| 609 | 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
| @@ -523,13 +523,13 @@ func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) { | |||
| 523 | 523 | var bars barCache | |
| 524 | 524 | var composed composedFrameCache | |
| 525 | 525 | sess := &session{id: "s", name: "work", tabs: []*tab{win}} | |
| 526 | - | composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, true, solveTabLayoutLocked(win), &bars, &composed) | |
| 526 | + | composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, true, solveTabLayoutLocked(win), &bars, &composed) | |
| 527 | 527 | left.screen.ClearDamage() | |
| 528 | 528 | right.screen.ClearDamage() | |
| 529 | 529 | left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'}) | |
| 530 | 530 | right.screen.Write([]byte("x")) | |
| 531 | 531 | ||
| 532 | - | frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, false, solveTabLayoutLocked(win), &bars, &composed) | |
| 532 | + | frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, false, solveTabLayoutLocked(win), &bars, &composed) | |
| 533 | 533 | ||
| 534 | 534 | require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted") | |
| 535 | 535 | require.Equal(t, 'x', frame.At(22, 1).Rune) | |
| @@ -542,7 +542,7 @@ func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) { | |||
| 542 | 542 | p.screen.Write([]byte("old")) | |
| 543 | 543 | var bars barCache | |
| 544 | 544 | var composed composedFrameCache | |
| 545 | - | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | |
| 545 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)} | |
| 546 | 546 | ||
| 547 | 547 | _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |
| 548 | 548 | require.NotEmpty(t, damage) | |
| @@ -566,7 +566,7 @@ func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) { | |||
| 566 | 566 | p2.screen.Write([]byte("shown")) | |
| 567 | 567 | var bars barCache | |
| 568 | 568 | var composed composedFrameCache | |
| 569 | - | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | |
| 569 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)} | |
| 570 | 570 | ||
| 571 | 571 | _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | |
| 572 | 572 | ||
| @@ -585,7 +585,7 @@ func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) { | |||
| 585 | 585 | 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}} | |
| 586 | 586 | var bars barCache | |
| 587 | 587 | var composed composedFrameCache | |
| 588 | - | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(), theme: theme} | |
| 588 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true), theme: theme} | |
| 589 | 589 | win.tree.Focus = left.id | |
| 590 | 590 | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | |
| 591 | 591 | left.screen.ClearDamage() | |
| @@ -610,7 +610,7 @@ func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) { | |||
| 610 | 610 | right.screen.Write([]byte("R")) | |
| 611 | 611 | var bars barCache | |
| 612 | 612 | var composed composedFrameCache | |
| 613 | - | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | |
| 613 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)} | |
| 614 | 614 | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | |
| 615 | 615 | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | |
| 616 | 616 | require.Equal(t, '│', composed.frame.At(20, 1).Rune) | |
| @@ -689,7 +689,7 @@ func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) { | |||
| 689 | 689 | win.panes[p2.id] = p2 | |
| 690 | 690 | var bars barCache | |
| 691 | 691 | var composed composedFrameCache | |
| 692 | - | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | |
| 692 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)} | |
| 693 | 693 | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | |
| 694 | 694 | p1.screen.ClearDamage() | |
| 695 | 695 | p2.screen.ClearDamage() | |
| @@ -523,13 +523,13 @@ func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) { | |||||
| 523 | var bars barCache | 523 | var bars barCache | ||
| 524 | var composed composedFrameCache | 524 | var composed composedFrameCache | ||
| 525 | sess := &session{id: "s", name: "work", tabs: []*tab{win}} | 525 | sess := &session{id: "s", name: "work", tabs: []*tab{win}} | ||
| 526 | - | composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, true, solveTabLayoutLocked(win), &bars, &composed) | 526 | + | composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, true, solveTabLayoutLocked(win), &bars, &composed) |
| 527 | left.screen.ClearDamage() | 527 | left.screen.ClearDamage() | ||
| 528 | right.screen.ClearDamage() | 528 | right.screen.ClearDamage() | ||
| 529 | left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'}) | 529 | left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'}) | ||
| 530 | right.screen.Write([]byte("x")) | 530 | right.screen.Write([]byte("x")) | ||
| 531 | 531 | ||||
| 532 | - | frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, false, solveTabLayoutLocked(win), &bars, &composed) | 532 | + | frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, false, solveTabLayoutLocked(win), &bars, &composed) |
| 533 | 533 | ||||
| 534 | require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted") | 534 | require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted") | ||
| 535 | require.Equal(t, 'x', frame.At(22, 1).Rune) | 535 | require.Equal(t, 'x', frame.At(22, 1).Rune) | ||
| @@ -542,7 +542,7 @@ func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) { | |||||
| 542 | p.screen.Write([]byte("old")) | 542 | p.screen.Write([]byte("old")) | ||
| 543 | var bars barCache | 543 | var bars barCache | ||
| 544 | var composed composedFrameCache | 544 | var composed composedFrameCache | ||
| 545 | - | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | 545 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)} |
| 546 | 546 | ||||
| 547 | _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | 547 | _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | ||
| 548 | require.NotEmpty(t, damage) | 548 | require.NotEmpty(t, damage) | ||
| @@ -566,7 +566,7 @@ func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) { | |||||
| 566 | p2.screen.Write([]byte("shown")) | 566 | p2.screen.Write([]byte("shown")) | ||
| 567 | var bars barCache | 567 | var bars barCache | ||
| 568 | var composed composedFrameCache | 568 | var composed composedFrameCache | ||
| 569 | - | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | 569 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)} |
| 570 | 570 | ||||
| 571 | _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | 571 | _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed) | ||
| 572 | 572 | ||||
| @@ -585,7 +585,7 @@ func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) { | |||||
| 585 | 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}} | 585 | 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}} | ||
| 586 | var bars barCache | 586 | var bars barCache | ||
| 587 | var composed composedFrameCache | 587 | var composed composedFrameCache | ||
| 588 | - | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(), theme: theme} | 588 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true), theme: theme} |
| 589 | win.tree.Focus = left.id | 589 | win.tree.Focus = left.id | ||
| 590 | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | 590 | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | ||
| 591 | left.screen.ClearDamage() | 591 | left.screen.ClearDamage() | ||
| @@ -610,7 +610,7 @@ func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) { | |||||
| 610 | right.screen.Write([]byte("R")) | 610 | right.screen.Write([]byte("R")) | ||
| 611 | var bars barCache | 611 | var bars barCache | ||
| 612 | var composed composedFrameCache | 612 | var composed composedFrameCache | ||
| 613 | - | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | 613 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)} |
| 614 | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | 614 | win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}} | ||
| 615 | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | 615 | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | ||
| 616 | require.Equal(t, '│', composed.frame.At(20, 1).Rune) | 616 | require.Equal(t, '│', composed.frame.At(20, 1).Rune) | ||
| @@ -689,7 +689,7 @@ func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) { | |||||
| 689 | win.panes[p2.id] = p2 | 689 | win.panes[p2.id] = p2 | ||
| 690 | var bars barCache | 690 | var bars barCache | ||
| 691 | var composed composedFrameCache | 691 | var composed composedFrameCache | ||
| 692 | - | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()} | 692 | + | state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)} |
| 693 | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | 693 | composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed) | ||
| 694 | p1.screen.ClearDamage() | 694 | p1.screen.ClearDamage() | ||
| 695 | p2.screen.ClearDamage() | 695 | p2.screen.ClearDamage() | ||
M internal/usecase/daemon/status.go +75 -6
| @@ -33,6 +33,7 @@ import ( | |||
| 33 | 33 | ||
| 34 | 34 | "github.com/bnema/vev/internal/domain" | |
| 35 | 35 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 36 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 36 | 37 | "github.com/bnema/vev/pkg/renderer" | |
| 37 | 38 | ) | |
| 38 | 39 | ||
| @@ -63,12 +64,13 @@ func pulseStyle(frame int) (renderer.Style, bool) { | |||
| 63 | 64 | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) { | |
| 64 | 65 | clearStatusRow(row) | |
| 65 | 66 | x := 0 | |
| 66 | - | for _, w := range status.tabs { | |
| 67 | + | labels := fitTabLabels(status.tabs, len(row), topRight) | |
| 68 | + | for i, w := range status.tabs { | |
| 67 | 69 | style := styles.statusBar | |
| 68 | 70 | if w.active { | |
| 69 | 71 | style = styles.accent | |
| 70 | 72 | } | |
| 71 | - | writeStatusText(row, &x, " "+w.name, style) | |
| 73 | + | writeStatusText(row, &x, " "+labels[i], style) | |
| 72 | 74 | if w.attention { | |
| 73 | 75 | writeStatusText(row, &x, " ", style) | |
| 74 | 76 | writeBell(row, &x, frame) | |
| @@ -190,12 +192,13 @@ type statusSnapshot struct { | |||
| 190 | 192 | } | |
| 191 | 193 | ||
| 192 | 194 | type statusTab struct { | |
| 193 | - | name string | |
| 195 | + | name string // tab display name (degradation target) | |
| 196 | + | paneTitle string // formatPaneTitle output of the focused pane | |
| 194 | 197 | active bool | |
| 195 | 198 | attention bool | |
| 196 | 199 | } | |
| 197 | 200 | ||
| 198 | - | func (s *session) statusSegments() statusSnapshot { | |
| 201 | + | func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot { | |
| 199 | 202 | s.mu.Lock() | |
| 200 | 203 | defer s.mu.Unlock() | |
| 201 | 204 | name := s.name | |
| @@ -207,7 +210,7 @@ func (s *session) statusSegments() statusSnapshot { | |||
| 207 | 210 | name := tabDisplayName(tb, i) | |
| 208 | 211 | active := i == s.active | |
| 209 | 212 | attention := tb.attention && (!active || tb.attentionVisiblePaint) | |
| 210 | - | snap.tabs[i] = statusTab{name: name, active: active, attention: attention} | |
| 213 | + | snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention} | |
| 211 | 214 | } | |
| 212 | 215 | return snap | |
| 213 | 216 | } | |
| @@ -272,7 +275,11 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | |||
| 272 | 275 | state.attentionFrame = d.attentionFrame() | |
| 273 | 276 | } | |
| 274 | 277 | if cur != nil { | |
| 275 | - | state.status = cur.statusSegments() | |
| 278 | + | includeTerminalTitle := true | |
| 279 | + | if d != nil { | |
| 280 | + | includeTerminalTitle = d.currentTabsConfig().TerminalTitle | |
| 281 | + | } | |
| 282 | + | state.status = cur.statusSegments(includeTerminalTitle) | |
| 276 | 283 | } | |
| 277 | 284 | if d != nil { | |
| 278 | 285 | state.topRight, state.bottomRight = d.barScriptSnapshot(cur) | |
| @@ -376,6 +383,68 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s | |||
| 376 | 383 | return entries[start:end] | |
| 377 | 384 | } | |
| 378 | 385 | ||
| 386 | + | // fitTabLabels returns, per tab, the text drawn between its surrounding | |
| 387 | + | // spaces (attention glyph handled by the caller), guaranteeing all tabs fit. | |
| 388 | + | func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |
| 389 | + | reserve := 1 | |
| 390 | + | if rightText != "" { | |
| 391 | + | reserve = statusTextWidth(rightText) + 2 | |
| 392 | + | } | |
| 393 | + | budget := rowLen - reserve | |
| 394 | + | ||
| 395 | + | full := make([]string, len(tabs)) | |
| 396 | + | overhead := make([]int, len(tabs)) | |
| 397 | + | widths := make([]int, len(tabs)) | |
| 398 | + | total := 0 | |
| 399 | + | for i, t := range tabs { | |
| 400 | + | full[i] = composeTabTitle(t.name, t.paneTitle) | |
| 401 | + | o := 2 | |
| 402 | + | if t.attention { | |
| 403 | + | o += 1 + renderer.RuneWidth(attentionGlyph) | |
| 404 | + | } | |
| 405 | + | overhead[i] = o | |
| 406 | + | widths[i] = statusTextWidth(full[i]) | |
| 407 | + | total += o + widths[i] | |
| 408 | + | } | |
| 409 | + | labels := make([]string, len(tabs)) | |
| 410 | + | if total <= budget { | |
| 411 | + | copy(labels, full) | |
| 412 | + | return labels | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | // Overflow: greedy water-filling in ascending width order (shortest tabs | |
| 416 | + | // first) so unused budget from short tabs flows to longer ones, while | |
| 417 | + | // output order (labels indexed by original tab index) stays stable. | |
| 418 | + | order := make([]int, len(tabs)) | |
| 419 | + | for i := range order { | |
| 420 | + | order[i] = i | |
| 421 | + | } | |
| 422 | + | sort.SliceStable(order, func(a, b int) bool { | |
| 423 | + | return widths[order[a]] < widths[order[b]] | |
| 424 | + | }) | |
| 425 | + | ||
| 426 | + | remaining := budget | |
| 427 | + | remainingTabs := len(tabs) | |
| 428 | + | for _, i := range order { | |
| 429 | + | share := remaining / remainingTabs | |
| 430 | + | textBudget := share - overhead[i] | |
| 431 | + | switch { | |
| 432 | + | case textBudget <= 0: | |
| 433 | + | labels[i] = "" | |
| 434 | + | case widths[i] <= textBudget: | |
| 435 | + | labels[i] = full[i] | |
| 436 | + | case textBudget >= statusTextWidth(tabs[i].name)+4: | |
| 437 | + | labels[i] = ui.TruncateText(full[i], textBudget) | |
| 438 | + | default: | |
| 439 | + | labels[i] = ui.TruncateText(tabs[i].name, textBudget) | |
| 440 | + | } | |
| 441 | + | consumed := overhead[i] + min(widths[i], max(textBudget, 0)) | |
| 442 | + | remaining -= consumed | |
| 443 | + | remainingTabs-- | |
| 444 | + | } | |
| 445 | + | return labels | |
| 446 | + | } | |
| 447 | + | ||
| 379 | 448 | func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession { | |
| 380 | 449 | // With no feedback, keep one blank trailing cell; with feedback, reserve | |
| 381 | 450 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | |
| @@ -33,6 +33,7 @@ import ( | |||||
| 33 | 33 | ||||
| 34 | "github.com/bnema/vev/internal/domain" | 34 | "github.com/bnema/vev/internal/domain" | ||
| 35 | themeui "github.com/bnema/vev/internal/usecase/theme" | 35 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 36 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 36 | "github.com/bnema/vev/pkg/renderer" | 37 | "github.com/bnema/vev/pkg/renderer" | ||
| 37 | ) | 38 | ) | ||
| 38 | 39 | ||||
| @@ -63,12 +64,13 @@ func pulseStyle(frame int) (renderer.Style, bool) { | |||||
| 63 | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) { | 64 | func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) { | ||
| 64 | clearStatusRow(row) | 65 | clearStatusRow(row) | ||
| 65 | x := 0 | 66 | x := 0 | ||
| 66 | - | for _, w := range status.tabs { | 67 | + | labels := fitTabLabels(status.tabs, len(row), topRight) |
| 68 | + | for i, w := range status.tabs { | |||
| 67 | style := styles.statusBar | 69 | style := styles.statusBar | ||
| 68 | if w.active { | 70 | if w.active { | ||
| 69 | style = styles.accent | 71 | style = styles.accent | ||
| 70 | } | 72 | } | ||
| 71 | - | writeStatusText(row, &x, " "+w.name, style) | 73 | + | writeStatusText(row, &x, " "+labels[i], style) |
| 72 | if w.attention { | 74 | if w.attention { | ||
| 73 | writeStatusText(row, &x, " ", style) | 75 | writeStatusText(row, &x, " ", style) | ||
| 74 | writeBell(row, &x, frame) | 76 | writeBell(row, &x, frame) | ||
| @@ -190,12 +192,13 @@ type statusSnapshot struct { | |||||
| 190 | } | 192 | } | ||
| 191 | 193 | ||||
| 192 | type statusTab struct { | 194 | type statusTab struct { | ||
| 193 | - | name string | 195 | + | name string // tab display name (degradation target) |
| 196 | + | paneTitle string // formatPaneTitle output of the focused pane | |||
| 194 | active bool | 197 | active bool | ||
| 195 | attention bool | 198 | attention bool | ||
| 196 | } | 199 | } | ||
| 197 | 200 | ||||
| 198 | - | func (s *session) statusSegments() statusSnapshot { | 201 | + | func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot { |
| 199 | s.mu.Lock() | 202 | s.mu.Lock() | ||
| 200 | defer s.mu.Unlock() | 203 | defer s.mu.Unlock() | ||
| 201 | name := s.name | 204 | name := s.name | ||
| @@ -207,7 +210,7 @@ func (s *session) statusSegments() statusSnapshot { | |||||
| 207 | name := tabDisplayName(tb, i) | 210 | name := tabDisplayName(tb, i) | ||
| 208 | active := i == s.active | 211 | active := i == s.active | ||
| 209 | attention := tb.attention && (!active || tb.attentionVisiblePaint) | 212 | attention := tb.attention && (!active || tb.attentionVisiblePaint) | ||
| 210 | - | snap.tabs[i] = statusTab{name: name, active: active, attention: attention} | 213 | + | snap.tabs[i] = statusTab{name: name, paneTitle: tb.focusedPaneTitle(includeTerminalTitle), active: active, attention: attention} |
| 211 | } | 214 | } | ||
| 212 | return snap | 215 | return snap | ||
| 213 | } | 216 | } | ||
| @@ -272,7 +275,11 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | |||||
| 272 | state.attentionFrame = d.attentionFrame() | 275 | state.attentionFrame = d.attentionFrame() | ||
| 273 | } | 276 | } | ||
| 274 | if cur != nil { | 277 | if cur != nil { | ||
| 275 | - | state.status = cur.statusSegments() | 278 | + | includeTerminalTitle := true |
| 279 | + | if d != nil { | |||
| 280 | + | includeTerminalTitle = d.currentTabsConfig().TerminalTitle | |||
| 281 | + | } | |||
| 282 | + | state.status = cur.statusSegments(includeTerminalTitle) | |||
| 276 | } | 283 | } | ||
| 277 | if d != nil { | 284 | if d != nil { | ||
| 278 | state.topRight, state.bottomRight = d.barScriptSnapshot(cur) | 285 | state.topRight, state.bottomRight = d.barScriptSnapshot(cur) | ||
| @@ -376,6 +383,68 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s | |||||
| 376 | return entries[start:end] | 383 | return entries[start:end] | ||
| 377 | } | 384 | } | ||
| 378 | 385 | ||||
| 386 | + | // fitTabLabels returns, per tab, the text drawn between its surrounding | |||
| 387 | + | // spaces (attention glyph handled by the caller), guaranteeing all tabs fit. | |||
| 388 | + | func fitTabLabels(tabs []statusTab, rowLen int, rightText string) []string { | |||
| 389 | + | reserve := 1 | |||
| 390 | + | if rightText != "" { | |||
| 391 | + | reserve = statusTextWidth(rightText) + 2 | |||
| 392 | + | } | |||
| 393 | + | budget := rowLen - reserve | |||
| 394 | + | ||||
| 395 | + | full := make([]string, len(tabs)) | |||
| 396 | + | overhead := make([]int, len(tabs)) | |||
| 397 | + | widths := make([]int, len(tabs)) | |||
| 398 | + | total := 0 | |||
| 399 | + | for i, t := range tabs { | |||
| 400 | + | full[i] = composeTabTitle(t.name, t.paneTitle) | |||
| 401 | + | o := 2 | |||
| 402 | + | if t.attention { | |||
| 403 | + | o += 1 + renderer.RuneWidth(attentionGlyph) | |||
| 404 | + | } | |||
| 405 | + | overhead[i] = o | |||
| 406 | + | widths[i] = statusTextWidth(full[i]) | |||
| 407 | + | total += o + widths[i] | |||
| 408 | + | } | |||
| 409 | + | labels := make([]string, len(tabs)) | |||
| 410 | + | if total <= budget { | |||
| 411 | + | copy(labels, full) | |||
| 412 | + | return labels | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | // Overflow: greedy water-filling in ascending width order (shortest tabs | |||
| 416 | + | // first) so unused budget from short tabs flows to longer ones, while | |||
| 417 | + | // output order (labels indexed by original tab index) stays stable. | |||
| 418 | + | order := make([]int, len(tabs)) | |||
| 419 | + | for i := range order { | |||
| 420 | + | order[i] = i | |||
| 421 | + | } | |||
| 422 | + | sort.SliceStable(order, func(a, b int) bool { | |||
| 423 | + | return widths[order[a]] < widths[order[b]] | |||
| 424 | + | }) | |||
| 425 | + | ||||
| 426 | + | remaining := budget | |||
| 427 | + | remainingTabs := len(tabs) | |||
| 428 | + | for _, i := range order { | |||
| 429 | + | share := remaining / remainingTabs | |||
| 430 | + | textBudget := share - overhead[i] | |||
| 431 | + | switch { | |||
| 432 | + | case textBudget <= 0: | |||
| 433 | + | labels[i] = "" | |||
| 434 | + | case widths[i] <= textBudget: | |||
| 435 | + | labels[i] = full[i] | |||
| 436 | + | case textBudget >= statusTextWidth(tabs[i].name)+4: | |||
| 437 | + | labels[i] = ui.TruncateText(full[i], textBudget) | |||
| 438 | + | default: | |||
| 439 | + | labels[i] = ui.TruncateText(tabs[i].name, textBudget) | |||
| 440 | + | } | |||
| 441 | + | consumed := overhead[i] + min(widths[i], max(textBudget, 0)) | |||
| 442 | + | remaining -= consumed | |||
| 443 | + | remainingTabs-- | |||
| 444 | + | } | |||
| 445 | + | return labels | |||
| 446 | + | } | |||
| 447 | + | ||||
| 379 | func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession { | 448 | func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession { | ||
| 380 | // With no feedback, keep one blank trailing cell; with feedback, reserve | 449 | // With no feedback, keep one blank trailing cell; with feedback, reserve | ||
| 381 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | 450 | // its " text" width plus a one-cell gap so drawRightPlainText always fits. | ||
M internal/usecase/daemon/status_test.go +146 -6
| @@ -97,6 +97,140 @@ func TestStatusCompositionGolden(t *testing.T) { | |||
| 97 | 97 | } | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | + | func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) { | |
| 101 | + | p1, releasePTY1 := newBlockingPTY(t) | |
| 102 | + | p2, releasePTY2 := newBlockingPTY(t) | |
| 103 | + | _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2) | |
| 104 | + | defer releasePTY1() | |
| 105 | + | defer releasePTY2() | |
| 106 | + | ||
| 107 | + | sess.tabs[1].name = "logs" | |
| 108 | + | ||
| 109 | + | pane0 := sess.tabs[0].focusedPane() | |
| 110 | + | pane0.mu.Lock() | |
| 111 | + | pane0.title.processName = "vim" | |
| 112 | + | pane0.title.processNameValid = true | |
| 113 | + | pane0.mu.Unlock() | |
| 114 | + | ||
| 115 | + | pane1 := sess.tabs[1].focusedPane() | |
| 116 | + | pane1.mu.Lock() | |
| 117 | + | pane1.title.terminalTitle = "build output" | |
| 118 | + | pane1.mu.Unlock() | |
| 119 | + | ||
| 120 | + | snap := sess.statusSegments(true) | |
| 121 | + | ||
| 122 | + | require.Len(t, snap.tabs, 2) | |
| 123 | + | require.Equal(t, "1", snap.tabs[0].name) | |
| 124 | + | require.Equal(t, "vim", snap.tabs[0].paneTitle) | |
| 125 | + | require.Equal(t, "logs", snap.tabs[1].name) | |
| 126 | + | require.Equal(t, "build output", snap.tabs[1].paneTitle) | |
| 127 | + | } | |
| 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 | + | ||
| 159 | + | func TestFitTabLabels(t *testing.T) { | |
| 160 | + | tests := []struct { | |
| 161 | + | name string | |
| 162 | + | tabs []statusTab | |
| 163 | + | rowLen int | |
| 164 | + | rightText string | |
| 165 | + | want []string | |
| 166 | + | }{ | |
| 167 | + | { | |
| 168 | + | name: "no tabs", | |
| 169 | + | tabs: nil, | |
| 170 | + | rowLen: 10, | |
| 171 | + | want: []string{}, | |
| 172 | + | }, | |
| 173 | + | { | |
| 174 | + | name: "everything fits stays untruncated", | |
| 175 | + | tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}}, | |
| 176 | + | rowLen: 40, | |
| 177 | + | want: []string{"one", "two (shell)"}, | |
| 178 | + | }, | |
| 179 | + | { | |
| 180 | + | name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis", | |
| 181 | + | tabs: []statusTab{ | |
| 182 | + | {name: "a"}, | |
| 183 | + | {name: "ed", paneTitle: "verylongtitle"}, | |
| 184 | + | }, | |
| 185 | + | rowLen: 20, | |
| 186 | + | want: []string{"a", "ed (verylongt…"}, | |
| 187 | + | }, | |
| 188 | + | { | |
| 189 | + | name: "tight budget degrades to a truncated tab name only", | |
| 190 | + | tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}}, | |
| 191 | + | rowLen: 7, | |
| 192 | + | want: []string{"edi…"}, | |
| 193 | + | }, | |
| 194 | + | { | |
| 195 | + | name: "non-empty rightText shrinks the budget", | |
| 196 | + | tabs: []statusTab{{name: "aaaaaaaaaa"}}, | |
| 197 | + | rowLen: 20, | |
| 198 | + | rightText: "0123456789", | |
| 199 | + | want: []string{"aaaaa…"}, | |
| 200 | + | }, | |
| 201 | + | { | |
| 202 | + | name: "single tab wider than the whole row collapses to empty", | |
| 203 | + | tabs: []statusTab{{name: "reallyverylongtabname"}}, | |
| 204 | + | rowLen: 3, | |
| 205 | + | want: []string{""}, | |
| 206 | + | }, | |
| 207 | + | } | |
| 208 | + | for _, tt := range tests { | |
| 209 | + | t.Run(tt.name, func(t *testing.T) { | |
| 210 | + | got := fitTabLabels(tt.tabs, tt.rowLen, tt.rightText) | |
| 211 | + | ||
| 212 | + | require.Equal(t, tt.want, got) | |
| 213 | + | ||
| 214 | + | reserve := 1 | |
| 215 | + | if tt.rightText != "" { | |
| 216 | + | reserve = statusTextWidth(tt.rightText) + 2 | |
| 217 | + | } | |
| 218 | + | budget := tt.rowLen - reserve | |
| 219 | + | drawn := 0 | |
| 220 | + | for i, tb := range tt.tabs { | |
| 221 | + | overhead := 2 | |
| 222 | + | if tb.attention { | |
| 223 | + | overhead += 1 + renderer.RuneWidth(attentionGlyph) | |
| 224 | + | } | |
| 225 | + | drawn += overhead + statusTextWidth(got[i]) | |
| 226 | + | } | |
| 227 | + | if budget > 0 { | |
| 228 | + | require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget") | |
| 229 | + | } | |
| 230 | + | }) | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 100 | 234 | func TestTabDisplayName(t *testing.T) { | |
| 101 | 235 | tests := []struct { | |
| 102 | 236 | name string | |
| @@ -130,7 +264,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) { | |||
| 130 | 264 | Known: true, | |
| 131 | 265 | }) | |
| 132 | 266 | ||
| 133 | - | bars := barState{status: sess.statusSegments(), theme: ac.getTheme()} | |
| 267 | + | bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()} | |
| 134 | 268 | frame, damage := composeClientFrameWithState(bars, win, true) | |
| 135 | 269 | out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage) | |
| 136 | 270 | ||
| @@ -512,7 +646,7 @@ func TestStatusMarksEphemeralSession(t *testing.T) { | |||
| 512 | 646 | ||
| 513 | 647 | frame, _ := composeClientFrame(sess, win, true, "") | |
| 514 | 648 | ||
| 515 | - | require.Equal(t, " 1 ", rowText(frame.Row(0))) | |
| 649 | + | require.Equal(t, " 1 (sh) ", rowText(frame.Row(0))) | |
| 516 | 650 | require.Equal(t, " 0* ", rowText(frame.Row(3))) | |
| 517 | 651 | } | |
| 518 | 652 | ||
| @@ -533,11 +667,15 @@ func TestTopBarRightAnchor(t *testing.T) { | |||
| 533 | 667 | want: " 1 14:32", | |
| 534 | 668 | }, | |
| 535 | 669 | { | |
| 536 | - | name: "hides on overlap", | |
| 670 | + | // The tab budget reserves room for topRight up front (fitTabLabels), | |
| 671 | + | // so tab labels shrink to nothing before the right-anchored text is | |
| 672 | + | // dropped; unlike the bottom bar's MRU, top-bar tabs never hide | |
| 673 | + | // entirely, so topRight still renders once tabs are squeezed out. | |
| 674 | + | name: "squeezes tabs empty to keep topRight visible", | |
| 537 | 675 | width: 10, | |
| 538 | 676 | status: statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}}, | |
| 539 | 677 | topRight: "12345", | |
| 540 | - | want: " 1 2 ", | |
| 678 | + | want: " 12345", | |
| 541 | 679 | }, | |
| 542 | 680 | { | |
| 543 | 681 | name: "uses display width", | |
| @@ -612,9 +750,11 @@ func TestTopBarRendersAttentionBell(t *testing.T) { | |||
| 612 | 750 | sess.tabs[1].attentionAt = time.Unix(10, 0) | |
| 613 | 751 | sess.mu.Unlock() | |
| 614 | 752 | ||
| 615 | - | frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(), attentionFrame: 1}, win, true) | |
| 753 | + | frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true) | |
| 616 | 754 | ||
| 617 | - | require.Contains(t, rowText(frame.Row(0)), "2 ") | |
| 755 | + | // Tab 2's label is enriched with its focused pane's title ("sh", the | |
| 756 | + | // default shell fallback) and truncated to fit alongside the bell. | |
| 757 | + | require.Contains(t, rowText(frame.Row(0)), "2 (s") | |
| 618 | 758 | for _, c := range frame.Row(0) { | |
| 619 | 759 | if c.Rune == attentionGlyph { | |
| 620 | 760 | require.True(t, c.Style.Bold) | |
| @@ -97,6 +97,140 @@ func TestStatusCompositionGolden(t *testing.T) { | |||||
| 97 | } | 97 | } | ||
| 98 | } | 98 | } | ||
| 99 | 99 | ||||
| 100 | + | func TestStatusSegmentsIncludesFocusedPaneTitle(t *testing.T) { | |||
| 101 | + | p1, releasePTY1 := newBlockingPTY(t) | |||
| 102 | + | p2, releasePTY2 := newBlockingPTY(t) | |||
| 103 | + | _, sess, _, _ := newManualSessionWithPTYs(t, p1, p2) | |||
| 104 | + | defer releasePTY1() | |||
| 105 | + | defer releasePTY2() | |||
| 106 | + | ||||
| 107 | + | sess.tabs[1].name = "logs" | |||
| 108 | + | ||||
| 109 | + | pane0 := sess.tabs[0].focusedPane() | |||
| 110 | + | pane0.mu.Lock() | |||
| 111 | + | pane0.title.processName = "vim" | |||
| 112 | + | pane0.title.processNameValid = true | |||
| 113 | + | pane0.mu.Unlock() | |||
| 114 | + | ||||
| 115 | + | pane1 := sess.tabs[1].focusedPane() | |||
| 116 | + | pane1.mu.Lock() | |||
| 117 | + | pane1.title.terminalTitle = "build output" | |||
| 118 | + | pane1.mu.Unlock() | |||
| 119 | + | ||||
| 120 | + | snap := sess.statusSegments(true) | |||
| 121 | + | ||||
| 122 | + | require.Len(t, snap.tabs, 2) | |||
| 123 | + | require.Equal(t, "1", snap.tabs[0].name) | |||
| 124 | + | require.Equal(t, "vim", snap.tabs[0].paneTitle) | |||
| 125 | + | require.Equal(t, "logs", snap.tabs[1].name) | |||
| 126 | + | require.Equal(t, "build output", snap.tabs[1].paneTitle) | |||
| 127 | + | } | |||
| 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 | + | ||||
| 159 | + | func TestFitTabLabels(t *testing.T) { | |||
| 160 | + | tests := []struct { | |||
| 161 | + | name string | |||
| 162 | + | tabs []statusTab | |||
| 163 | + | rowLen int | |||
| 164 | + | rightText string | |||
| 165 | + | want []string | |||
| 166 | + | }{ | |||
| 167 | + | { | |||
| 168 | + | name: "no tabs", | |||
| 169 | + | tabs: nil, | |||
| 170 | + | rowLen: 10, | |||
| 171 | + | want: []string{}, | |||
| 172 | + | }, | |||
| 173 | + | { | |||
| 174 | + | name: "everything fits stays untruncated", | |||
| 175 | + | tabs: []statusTab{{name: "one"}, {name: "two", paneTitle: "shell"}}, | |||
| 176 | + | rowLen: 40, | |||
| 177 | + | want: []string{"one", "two (shell)"}, | |||
| 178 | + | }, | |||
| 179 | + | { | |||
| 180 | + | name: "overflow redistributes budget: short tab stays full, long tab gets ellipsis", | |||
| 181 | + | tabs: []statusTab{ | |||
| 182 | + | {name: "a"}, | |||
| 183 | + | {name: "ed", paneTitle: "verylongtitle"}, | |||
| 184 | + | }, | |||
| 185 | + | rowLen: 20, | |||
| 186 | + | want: []string{"a", "ed (verylongt…"}, | |||
| 187 | + | }, | |||
| 188 | + | { | |||
| 189 | + | name: "tight budget degrades to a truncated tab name only", | |||
| 190 | + | tabs: []statusTab{{name: "editor", paneTitle: "some pane title text"}}, | |||
| 191 | + | rowLen: 7, | |||
| 192 | + | want: []string{"edi…"}, | |||
| 193 | + | }, | |||
| 194 | + | { | |||
| 195 | + | name: "non-empty rightText shrinks the budget", | |||
| 196 | + | tabs: []statusTab{{name: "aaaaaaaaaa"}}, | |||
| 197 | + | rowLen: 20, | |||
| 198 | + | rightText: "0123456789", | |||
| 199 | + | want: []string{"aaaaa…"}, | |||
| 200 | + | }, | |||
| 201 | + | { | |||
| 202 | + | name: "single tab wider than the whole row collapses to empty", | |||
| 203 | + | tabs: []statusTab{{name: "reallyverylongtabname"}}, | |||
| 204 | + | rowLen: 3, | |||
| 205 | + | want: []string{""}, | |||
| 206 | + | }, | |||
| 207 | + | } | |||
| 208 | + | for _, tt := range tests { | |||
| 209 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 210 | + | got := fitTabLabels(tt.tabs, tt.rowLen, tt.rightText) | |||
| 211 | + | ||||
| 212 | + | require.Equal(t, tt.want, got) | |||
| 213 | + | ||||
| 214 | + | reserve := 1 | |||
| 215 | + | if tt.rightText != "" { | |||
| 216 | + | reserve = statusTextWidth(tt.rightText) + 2 | |||
| 217 | + | } | |||
| 218 | + | budget := tt.rowLen - reserve | |||
| 219 | + | drawn := 0 | |||
| 220 | + | for i, tb := range tt.tabs { | |||
| 221 | + | overhead := 2 | |||
| 222 | + | if tb.attention { | |||
| 223 | + | overhead += 1 + renderer.RuneWidth(attentionGlyph) | |||
| 224 | + | } | |||
| 225 | + | drawn += overhead + statusTextWidth(got[i]) | |||
| 226 | + | } | |||
| 227 | + | if budget > 0 { | |||
| 228 | + | require.LessOrEqual(t, drawn, budget, "drawn width must not exceed the derived budget") | |||
| 229 | + | } | |||
| 230 | + | }) | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | ||||
| 100 | func TestTabDisplayName(t *testing.T) { | 234 | func TestTabDisplayName(t *testing.T) { | ||
| 101 | tests := []struct { | 235 | tests := []struct { | ||
| 102 | name string | 236 | name string | ||
| @@ -130,7 +264,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) { | |||||
| 130 | Known: true, | 264 | Known: true, | ||
| 131 | }) | 265 | }) | ||
| 132 | 266 | ||||
| 133 | - | bars := barState{status: sess.statusSegments(), theme: ac.getTheme()} | 267 | + | bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()} |
| 134 | frame, damage := composeClientFrameWithState(bars, win, true) | 268 | frame, damage := composeClientFrameWithState(bars, win, true) | ||
| 135 | out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage) | 269 | out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage) | ||
| 136 | 270 | ||||
| @@ -512,7 +646,7 @@ func TestStatusMarksEphemeralSession(t *testing.T) { | |||||
| 512 | 646 | ||||
| 513 | frame, _ := composeClientFrame(sess, win, true, "") | 647 | frame, _ := composeClientFrame(sess, win, true, "") | ||
| 514 | 648 | ||||
| 515 | - | require.Equal(t, " 1 ", rowText(frame.Row(0))) | 649 | + | require.Equal(t, " 1 (sh) ", rowText(frame.Row(0))) |
| 516 | require.Equal(t, " 0* ", rowText(frame.Row(3))) | 650 | require.Equal(t, " 0* ", rowText(frame.Row(3))) | ||
| 517 | } | 651 | } | ||
| 518 | 652 | ||||
| @@ -533,11 +667,15 @@ func TestTopBarRightAnchor(t *testing.T) { | |||||
| 533 | want: " 1 14:32", | 667 | want: " 1 14:32", | ||
| 534 | }, | 668 | }, | ||
| 535 | { | 669 | { | ||
| 536 | - | name: "hides on overlap", | 670 | + | // The tab budget reserves room for topRight up front (fitTabLabels), |
| 671 | + | // so tab labels shrink to nothing before the right-anchored text is | |||
| 672 | + | // dropped; unlike the bottom bar's MRU, top-bar tabs never hide | |||
| 673 | + | // entirely, so topRight still renders once tabs are squeezed out. | |||
| 674 | + | name: "squeezes tabs empty to keep topRight visible", | |||
| 537 | width: 10, | 675 | width: 10, | ||
| 538 | status: statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}}, | 676 | status: statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}}, | ||
| 539 | topRight: "12345", | 677 | topRight: "12345", | ||
| 540 | - | want: " 1 2 ", | 678 | + | want: " 12345", |
| 541 | }, | 679 | }, | ||
| 542 | { | 680 | { | ||
| 543 | name: "uses display width", | 681 | name: "uses display width", | ||
| @@ -612,9 +750,11 @@ func TestTopBarRendersAttentionBell(t *testing.T) { | |||||
| 612 | sess.tabs[1].attentionAt = time.Unix(10, 0) | 750 | sess.tabs[1].attentionAt = time.Unix(10, 0) | ||
| 613 | sess.mu.Unlock() | 751 | sess.mu.Unlock() | ||
| 614 | 752 | ||||
| 615 | - | frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(), attentionFrame: 1}, win, true) | 753 | + | frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true) |
| 616 | 754 | ||||
| 617 | - | require.Contains(t, rowText(frame.Row(0)), "2 ") | 755 | + | // Tab 2's label is enriched with its focused pane's title ("sh", the |
| 756 | + | // default shell fallback) and truncated to fit alongside the bell. | |||
| 757 | + | require.Contains(t, rowText(frame.Row(0)), "2 (s") | |||
| 618 | for _, c := range frame.Row(0) { | 758 | for _, c := range frame.Row(0) { | ||
| 619 | if c.Rune == attentionGlyph { | 759 | if c.Rune == attentionGlyph { | ||
| 620 | require.True(t, c.Style.Bold) | 760 | require.True(t, c.Style.Bold) | ||
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)) | ||
M internal/usecase/picker/model.go +1 -0
| @@ -204,6 +204,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection ren | |||
| 204 | 204 | if !r.header { | |
| 205 | 205 | label = " " + label | |
| 206 | 206 | } | |
| 207 | + | label = ui.TruncateText(label, rect.Width) | |
| 207 | 208 | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | |
| 208 | 209 | ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style) | |
| 209 | 210 | } | |
| @@ -204,6 +204,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection ren | |||||
| 204 | if !r.header { | 204 | if !r.header { | ||
| 205 | label = " " + label | 205 | label = " " + label | ||
| 206 | } | 206 | } | ||
| 207 | + | label = ui.TruncateText(label, rect.Width) | |||
| 207 | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | 208 | ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style}) | ||
| 208 | ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style) | 209 | ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style) | ||
| 209 | } | 210 | } | ||
M internal/usecase/picker/model_test.go +11 -0
| @@ -143,6 +143,17 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||
| 143 | 143 | require.True(t, frame.At(0, 3).Style.Inverse) | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | + | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |
| 147 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a-really-long-focused-pane-tab-label"}, Active: 0}}, "s1", 0) | |
| 148 | + | ||
| 149 | + | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |
| 150 | + | ||
| 151 | + | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | |
| 152 | + | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | |
| 153 | + | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge") | |
| 154 | + | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing should be drawn past the list width") | |
| 155 | + | } | |
| 156 | + | ||
| 146 | 157 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | |
| 147 | 158 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | |
| 148 | 159 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | |
| @@ -143,6 +143,17 @@ func TestRenderListScrollsSelectionIntoView(t *testing.T) { | |||||
| 143 | require.True(t, frame.At(0, 3).Style.Inverse) | 143 | require.True(t, frame.At(0, 3).Style.Inverse) | ||
| 144 | } | 144 | } | ||
| 145 | 145 | ||||
| 146 | + | func TestRenderListTruncatesLabelWithEllipsis(t *testing.T) { | |||
| 147 | + | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"a-really-long-focused-pane-tab-label"}, Active: 0}}, "s1", 0) | |||
| 148 | + | ||||
| 149 | + | frame := m.Render(domain.Size{Cols: 45, Rows: 5}, Preview{}) | |||
| 150 | + | ||||
| 151 | + | layout := ChooseLayout(domain.Size{Cols: 45, Rows: 5}) | |||
| 152 | + | require.Equal(t, 16, layout.List.Width, "test assumes a narrow list column") | |||
| 153 | + | require.Equal(t, '…', frame.At(layout.List.Width-1, 1).Rune, "truncated label should end with an ellipsis at the list edge") | |||
| 154 | + | require.Equal(t, ' ', frame.At(layout.List.Width, 1).Rune, "nothing should be drawn past the list width") | |||
| 155 | + | } | |||
| 156 | + | ||||
| 146 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | 157 | func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) { | ||
| 147 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | 158 | m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0) | ||
| 148 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | 159 | preview := Preview{Width: 1, Height: 1, Rows: [][]renderer.Cell{{cell('x')}}} | ||
M internal/usecase/ui/toast.go +3 -0
| @@ -223,6 +223,9 @@ func drawToast(frame renderer.Frame, toast Toast, styles ToastStyles) { | |||
| 223 | 223 | DrawText(frame, textLeft, textY, textRight, truncateText(toast.Message, textRight-textLeft), styles.Text) | |
| 224 | 224 | } | |
| 225 | 225 | ||
| 226 | + | // TruncateText clips text to maxWidth cells, appending an ellipsis when cut. | |
| 227 | + | func TruncateText(text string, maxWidth int) string { return truncateText(text, maxWidth) } | |
| 228 | + | ||
| 226 | 229 | func truncateText(text string, maxWidth int) string { | |
| 227 | 230 | if maxWidth <= 0 { | |
| 228 | 231 | return "" | |
| @@ -223,6 +223,9 @@ func drawToast(frame renderer.Frame, toast Toast, styles ToastStyles) { | |||||
| 223 | DrawText(frame, textLeft, textY, textRight, truncateText(toast.Message, textRight-textLeft), styles.Text) | 223 | DrawText(frame, textLeft, textY, textRight, truncateText(toast.Message, textRight-textLeft), styles.Text) | ||
| 224 | } | 224 | } | ||
| 225 | 225 | ||||
| 226 | + | // TruncateText clips text to maxWidth cells, appending an ellipsis when cut. | |||
| 227 | + | func TruncateText(text string, maxWidth int) string { return truncateText(text, maxWidth) } | |||
| 228 | + | ||||
| 226 | func truncateText(text string, maxWidth int) string { | 229 | func truncateText(text string, maxWidth int) string { | ||
| 227 | if maxWidth <= 0 { | 230 | if maxWidth <= 0 { | ||
| 228 | return "" | 231 | return "" | ||
M internal/usecase/ui/toast_test.go +24 -0
| @@ -81,6 +81,30 @@ func TestToastManagerDurationDismissAndClear(t *testing.T) { | |||
| 81 | 81 | }) | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | + | func TestTruncateText(t *testing.T) { | |
| 85 | + | tests := []struct { | |
| 86 | + | name string | |
| 87 | + | text string | |
| 88 | + | maxWidth int | |
| 89 | + | want string | |
| 90 | + | }{ | |
| 91 | + | {"no truncation needed", "hello", 10, "hello"}, | |
| 92 | + | {"truncation with ellipsis", "hello world", 8, "hello w…"}, | |
| 93 | + | {"maxWidth zero", "hello", 0, ""}, | |
| 94 | + | // The ellipsis itself is one cell wide, so any non-positive maxWidth is | |
| 95 | + | // also "smaller than the ellipsis" and collapses to the same empty result. | |
| 96 | + | {"maxWidth smaller than ellipsis", "hello world", -1, ""}, | |
| 97 | + | {"wide runes", "日本語のテキスト", 5, "日本…"}, | |
| 98 | + | } | |
| 99 | + | for _, tt := range tests { | |
| 100 | + | t.Run(tt.name, func(t *testing.T) { | |
| 101 | + | if got := TruncateText(tt.text, tt.maxWidth); got != tt.want { | |
| 102 | + | t.Fatalf("TruncateText(%q, %d) = %q, want %q", tt.text, tt.maxWidth, got, tt.want) | |
| 103 | + | } | |
| 104 | + | }) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 84 | 108 | func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) { | |
| 85 | 109 | message := "Reconnection attempts…" | |
| 86 | 110 | width := textWidth(message) + 4 | |
| @@ -81,6 +81,30 @@ func TestToastManagerDurationDismissAndClear(t *testing.T) { | |||||
| 81 | }) | 81 | }) | ||
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | + | func TestTruncateText(t *testing.T) { | |||
| 85 | + | tests := []struct { | |||
| 86 | + | name string | |||
| 87 | + | text string | |||
| 88 | + | maxWidth int | |||
| 89 | + | want string | |||
| 90 | + | }{ | |||
| 91 | + | {"no truncation needed", "hello", 10, "hello"}, | |||
| 92 | + | {"truncation with ellipsis", "hello world", 8, "hello w…"}, | |||
| 93 | + | {"maxWidth zero", "hello", 0, ""}, | |||
| 94 | + | // The ellipsis itself is one cell wide, so any non-positive maxWidth is | |||
| 95 | + | // also "smaller than the ellipsis" and collapses to the same empty result. | |||
| 96 | + | {"maxWidth smaller than ellipsis", "hello world", -1, ""}, | |||
| 97 | + | {"wide runes", "日本語のテキスト", 5, "日本…"}, | |||
| 98 | + | } | |||
| 99 | + | for _, tt := range tests { | |||
| 100 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 101 | + | if got := TruncateText(tt.text, tt.maxWidth); got != tt.want { | |||
| 102 | + | t.Fatalf("TruncateText(%q, %d) = %q, want %q", tt.text, tt.maxWidth, got, tt.want) | |||
| 103 | + | } | |||
| 104 | + | }) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 84 | func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) { | 108 | func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) { | ||
| 85 | message := "Reconnection attempts…" | 109 | message := "Reconnection attempts…" | ||
| 86 | width := textWidth(message) + 4 | 110 | width := textWidth(message) + 4 | ||