detached a86b2c8

Merge pull request #70 from bnema/feature/enriched-tab-titles

feat: enrich tab titles with focused pane process titles (top bar + session picker)
commit
a86b2c8af7e2975e43eb0b3550d69ed4ae09cd5b
author
bnema · · 2026-07-11 10:39 UTC
committer
GitHub · · 2026-07-11 10:39 UTC
merge of
52c0e49 + 7bfa6fa · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
22 files · +527 -29
M README.md +8 -2
@@ -58,6 +58,10 @@ floating.command =
5858 floating.width = 80%
5959 floating.height = 80%
6060
61+# Include the focused pane's OSC terminal title in tab labels (top bar and
62+# session picker). The process name always shows; off keeps only that.
63+tabs.terminal-title = on
64+
6165 # Rebindable actions. Leave a line out to keep its built-in binding.
6266 open-palette = alt+space
6367 toggle-floating-pane = alt+f
@@ -118,6 +122,8 @@ Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit
118122
119123 `floating.command` runs through the normal shell; leave it empty to launch that shell directly. `floating.width` and `floating.height` require exact percentages from `1%` through `100%`; invalid values log a warning and leave the last accepted value (or the `80%` default) intact. Reloading does not relaunch or immediately alter an installed floating terminal: a changed command is used on its next launch, while changed dimensions take effect when that terminal is next shown or resized. Only configuration—not floating runtime state—is restored after a daemon restart; restored tabs stay cold until first visited.
120124
125+`tabs.terminal-title` accepts `on` or `off` and defaults to `on`; invalid values log a warning and keep the last accepted value. Reload applies a changed value live.
126+
121127 ### Command palette anchoring
122128
123129 `palette.anchor` accepts `auto` or the nine anchors `center`, `top-left`, `top`, `top-right`, `left`, `right`, `bottom-left`, `bottom`, and `bottom-right`. Values are trimmed and case-insensitive. The default is `auto`: palettes use the bottom shelf when the terminal is narrower than 96 columns, and a 64-column bottom-right rail at 96 columns or wider. An explicit anchor changes placement only; palette query and selection are preserved. Invalid values are warned about and leave the last valid value unchanged, as do duplicate entries until a valid replacement is supplied (the last valid value wins). Configuration reload applies a changed anchor live and repositions an open palette without resetting its query or selection.
@@ -199,7 +205,7 @@ Rename prompts are prefilled with the current session name. Empty names and name
199205
200206 ## Session picker
201207
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.
203209
204210 | Key | Action |
205211 |---|---|
@@ -216,7 +222,7 @@ Each tab also owns one independent floating terminal. It prewarms asynchronously
216222
217223 While visible, the floating terminal receives ordinary input, bracketed paste, mouse input, clipboard-path injection, and scrollback/copy mode. Global vev bindings remain active, and a bare Esc is passed to the floating application. `Alt+f` and `FLT` toggle visibility; there is no separate floating-pane close command.
218224
219-The top bar shows 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*`.
220226
221227 ## Scrollback mode and copy
222228
@@ -58,6 +58,10 @@ floating.command =
58 floating.width = 80%58 floating.width = 80%
59 floating.height = 80%59 floating.height = 80%
60 60
61+# Include the focused pane's OSC terminal title in tab labels (top bar and
62+# session picker). The process name always shows; off keeps only that.
63+tabs.terminal-title = on
64+
61 # Rebindable actions. Leave a line out to keep its built-in binding.65 # Rebindable actions. Leave a line out to keep its built-in binding.
62 open-palette = alt+space66 open-palette = alt+space
63 toggle-floating-pane = alt+f67 toggle-floating-pane = alt+f
@@ -118,6 +122,8 @@ Key specs support `alt+<char>`, `alt+space`, and `alt+left/right/up/down`; digit
118 122
119 `floating.command` runs through the normal shell; leave it empty to launch that shell directly. `floating.width` and `floating.height` require exact percentages from `1%` through `100%`; invalid values log a warning and leave the last accepted value (or the `80%` default) intact. Reloading does not relaunch or immediately alter an installed floating terminal: a changed command is used on its next launch, while changed dimensions take effect when that terminal is next shown or resized. Only configuration—not floating runtime state—is restored after a daemon restart; restored tabs stay cold until first visited.123 `floating.command` runs through the normal shell; leave it empty to launch that shell directly. `floating.width` and `floating.height` require exact percentages from `1%` through `100%`; invalid values log a warning and leave the last accepted value (or the `80%` default) intact. Reloading does not relaunch or immediately alter an installed floating terminal: a changed command is used on its next launch, while changed dimensions take effect when that terminal is next shown or resized. Only configuration—not floating runtime state—is restored after a daemon restart; restored tabs stay cold until first visited.
120 124
125+`tabs.terminal-title` accepts `on` or `off` and defaults to `on`; invalid values log a warning and keep the last accepted value. Reload applies a changed value live.
126+
121 ### Command palette anchoring127 ### Command palette anchoring
122 128
123 `palette.anchor` accepts `auto` or the nine anchors `center`, `top-left`, `top`, `top-right`, `left`, `right`, `bottom-left`, `bottom`, and `bottom-right`. Values are trimmed and case-insensitive. The default is `auto`: palettes use the bottom shelf when the terminal is narrower than 96 columns, and a 64-column bottom-right rail at 96 columns or wider. An explicit anchor changes placement only; palette query and selection are preserved. Invalid values are warned about and leave the last valid value unchanged, as do duplicate entries until a valid replacement is supplied (the last valid value wins). Configuration reload applies a changed anchor live and repositions an open palette without resetting its query or selection.129 `palette.anchor` accepts `auto` or the nine anchors `center`, `top-left`, `top`, `top-right`, `left`, `right`, `bottom-left`, `bottom`, and `bottom-right`. Values are trimmed and case-insensitive. The default is `auto`: palettes use the bottom shelf when the terminal is narrower than 96 columns, and a 64-column bottom-right rail at 96 columns or wider. An explicit anchor changes placement only; palette query and selection are preserved. Invalid values are warned about and leave the last valid value unchanged, as do duplicate entries until a valid replacement is supplied (the last valid value wins). Configuration reload applies a changed anchor live and repositions an open palette without resetting its query or selection.
@@ -199,7 +205,7 @@ Rename prompts are prefilled with the current session name. Empty names and name
199 205
200 ## Session picker206 ## 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 copy227 ## Scrollback mode and copy
222 228
M internal/adapters/config/config.go +20 -0
@@ -32,6 +32,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
3232 seenBindingKeys := make(map[string]bool)
3333 seenFloatingKeys := make(map[string]bool)
3434 seenPaletteKeys := make(map[string]bool)
35+ seenTabsKeys := make(map[string]bool)
3536
3637 scanner := bufio.NewScanner(r)
3738 lineNo := 0
@@ -103,6 +104,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
103104 continue
104105 }
105106 cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
107+ case key == "tabs.terminal-title":
108+ warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo)
109+ on, ok := parseOnOff(value)
110+ if !ok {
111+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid tabs.terminal-title %q", value)})
112+ continue
113+ }
114+ cfg.Tabs.TerminalTitle = on
106115 case key == "floating.command":
107116 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
108117 command, ok := parseBarCommand(value)
@@ -281,6 +290,17 @@ func parsePercentage(value string) (int, bool) {
281290 return percentage, err == nil && percentage >= 1 && percentage <= 100
282291 }
283292
293+func parseOnOff(value string) (bool, bool) {
294+ switch strings.ToLower(strings.TrimSpace(value)) {
295+ case "on":
296+ return true, true
297+ case "off":
298+ return false, true
299+ default:
300+ return false, false
301+ }
302+}
303+
284304 func parseBarCommand(value string) (string, bool) {
285305 value = strings.TrimSpace(value)
286306 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
@@ -32,6 +32,7 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
32 seenBindingKeys := make(map[string]bool)32 seenBindingKeys := make(map[string]bool)
33 seenFloatingKeys := make(map[string]bool)33 seenFloatingKeys := make(map[string]bool)
34 seenPaletteKeys := make(map[string]bool)34 seenPaletteKeys := make(map[string]bool)
35+ seenTabsKeys := make(map[string]bool)
35 36
36 scanner := bufio.NewScanner(r)37 scanner := bufio.NewScanner(r)
37 lineNo := 038 lineNo := 0
@@ -103,6 +104,14 @@ func Parse(r io.Reader) (domain.Config, []domain.Warning, error) {
103 continue104 continue
104 }105 }
105 cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}106 cfg.Palette = domain.PaletteConfig{Anchor: anchor, AnchorSet: true}
107+ case key == "tabs.terminal-title":
108+ warnings = warnDuplicateKey(warnings, seenTabsKeys, key, lineNo)
109+ on, ok := parseOnOff(value)
110+ if !ok {
111+ warnings = append(warnings, domain.Warning{Line: lineNo, Msg: fmt.Sprintf("invalid tabs.terminal-title %q", value)})
112+ continue
113+ }
114+ cfg.Tabs.TerminalTitle = on
106 case key == "floating.command":115 case key == "floating.command":
107 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)116 warnings = warnDuplicateKey(warnings, seenFloatingKeys, key, lineNo)
108 command, ok := parseBarCommand(value)117 command, ok := parseBarCommand(value)
@@ -281,6 +290,17 @@ func parsePercentage(value string) (int, bool) {
281 return percentage, err == nil && percentage >= 1 && percentage <= 100290 return percentage, err == nil && percentage >= 1 && percentage <= 100
282 }291 }
283 292
293+func parseOnOff(value string) (bool, bool) {
294+ switch strings.ToLower(strings.TrimSpace(value)) {
295+ case "on":
296+ return true, true
297+ case "off":
298+ return false, true
299+ default:
300+ return false, false
301+ }
302+}
303+
284 func parseBarCommand(value string) (string, bool) {304 func parseBarCommand(value string) (string, bool) {
285 value = strings.TrimSpace(value)305 value = strings.TrimSpace(value)
286 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {306 if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
M internal/adapters/config/config_test.go +55 -0
@@ -338,6 +338,9 @@ func TestParse(t *testing.T) {
338338 if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {
339339 tt.want.Floating = domain.Defaults().Floating
340340 }
341+ if !tt.want.Tabs.TerminalTitle {
342+ tt.want.Tabs = domain.Defaults().Tabs
343+ }
341344 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
342345 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
343346 }
@@ -355,6 +358,58 @@ func TestParse(t *testing.T) {
355358 }
356359 }
357360
361+func TestParseTabsTerminalTitle(t *testing.T) {
362+ t.Parallel()
363+
364+ tests := []struct {
365+ name string
366+ input string
367+ want bool
368+ wantWarnings []domain.Warning
369+ }{
370+ {
371+ name: "on enables terminal title",
372+ input: "tabs.terminal-title = on\n",
373+ want: true,
374+ },
375+ {
376+ name: "off disables terminal title",
377+ input: "tabs.terminal-title = off\n",
378+ want: false,
379+ },
380+ {
381+ name: "invalid value warns and keeps default",
382+ input: "tabs.terminal-title = maybe\n",
383+ want: true,
384+ wantWarnings: []domain.Warning{
385+ {Line: 1, Msg: "invalid tabs.terminal-title \"maybe\""},
386+ },
387+ },
388+ {
389+ name: "absent key keeps default",
390+ input: "theme = dark\n",
391+ want: true,
392+ },
393+ }
394+
395+ for _, tt := range tests {
396+ t.Run(tt.name, func(t *testing.T) {
397+ t.Parallel()
398+
399+ got, warnings, err := Parse(strings.NewReader(tt.input))
400+ if err != nil {
401+ t.Fatalf("Parse() error = %v", err)
402+ }
403+ if got.Tabs.TerminalTitle != tt.want {
404+ t.Fatalf("Parse() Tabs.TerminalTitle = %v, want %v", got.Tabs.TerminalTitle, tt.want)
405+ }
406+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
407+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
408+ }
409+ })
410+ }
411+}
412+
358413 func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
359414 t.Parallel()
360415
@@ -338,6 +338,9 @@ func TestParse(t *testing.T) {
338 if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {338 if tt.want.Floating.Width == 0 && tt.want.Floating.Height == 0 {
339 tt.want.Floating = domain.Defaults().Floating339 tt.want.Floating = domain.Defaults().Floating
340 }340 }
341+ if !tt.want.Tabs.TerminalTitle {
342+ tt.want.Tabs = domain.Defaults().Tabs
343+ }
341 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {344 if tt.want.Snapshot.RestoreProcesses == nil && !tt.want.Snapshot.RestoreProcessesSet {
342 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)345 tt.want.Snapshot.RestoreProcesses = append([]string(nil), domain.DefaultSnapshotRestoreProcesses()...)
343 }346 }
@@ -355,6 +358,58 @@ func TestParse(t *testing.T) {
355 }358 }
356 }359 }
357 360
361+func TestParseTabsTerminalTitle(t *testing.T) {
362+ t.Parallel()
363+
364+ tests := []struct {
365+ name string
366+ input string
367+ want bool
368+ wantWarnings []domain.Warning
369+ }{
370+ {
371+ name: "on enables terminal title",
372+ input: "tabs.terminal-title = on\n",
373+ want: true,
374+ },
375+ {
376+ name: "off disables terminal title",
377+ input: "tabs.terminal-title = off\n",
378+ want: false,
379+ },
380+ {
381+ name: "invalid value warns and keeps default",
382+ input: "tabs.terminal-title = maybe\n",
383+ want: true,
384+ wantWarnings: []domain.Warning{
385+ {Line: 1, Msg: "invalid tabs.terminal-title \"maybe\""},
386+ },
387+ },
388+ {
389+ name: "absent key keeps default",
390+ input: "theme = dark\n",
391+ want: true,
392+ },
393+ }
394+
395+ for _, tt := range tests {
396+ t.Run(tt.name, func(t *testing.T) {
397+ t.Parallel()
398+
399+ got, warnings, err := Parse(strings.NewReader(tt.input))
400+ if err != nil {
401+ t.Fatalf("Parse() error = %v", err)
402+ }
403+ if got.Tabs.TerminalTitle != tt.want {
404+ t.Fatalf("Parse() Tabs.TerminalTitle = %v, want %v", got.Tabs.TerminalTitle, tt.want)
405+ }
406+ if !reflect.DeepEqual(warnings, tt.wantWarnings) {
407+ t.Fatalf("Parse() warnings = %#v, want %#v", warnings, tt.wantWarnings)
408+ }
409+ })
410+ }
411+}
412+
358 func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {413 func TestDefaultsCopiesSnapshotRestoreProcesses(t *testing.T) {
359 t.Parallel()414 t.Parallel()
360 415
M internal/app/daemon_integration_test.go +5 -2
@@ -222,8 +222,11 @@ func TestIntegration_CommandPaletteCreatesTab(t *testing.T) {
222222 awaitText(t, p, sz, "Commands")
223223
224224 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) ")
227230 }
228231
229232 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 {
6161 Height int
6262 }
6363
64+// TabsConfig contains tab label display settings.
65+type TabsConfig struct {
66+ // TerminalTitle includes the focused pane's OSC terminal title in tab
67+ // labels (top bar and session picker). The process name always shows.
68+ TerminalTitle bool
69+}
70+
6471 // Config is the user-editable vev configuration after parsing. Unknown binding
6572 // keys are preserved here (in BindingEntries, in file order) so the usecase
6673 // layer can decide which actions it understands.
@@ -72,6 +79,7 @@ type Config struct {
7279 Snapshot SnapshotConfig
7380 Palette PaletteConfig
7481 Floating FloatingConfig
82+ Tabs TabsConfig
7583 }
7684
7785 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -99,5 +107,8 @@ func Defaults() Config {
99107 Width: 80,
100108 Height: 80,
101109 },
110+ Tabs: TabsConfig{
111+ TerminalTitle: true,
112+ },
102113 }
103114 }
@@ -61,6 +61,13 @@ type FloatingConfig struct {
61 Height int61 Height int
62 }62 }
63 63
64+// TabsConfig contains tab label display settings.
65+type TabsConfig struct {
66+ // TerminalTitle includes the focused pane's OSC terminal title in tab
67+ // labels (top bar and session picker). The process name always shows.
68+ TerminalTitle bool
69+}
70+
64 // Config is the user-editable vev configuration after parsing. Unknown binding71 // Config is the user-editable vev configuration after parsing. Unknown binding
65 // keys are preserved here (in BindingEntries, in file order) so the usecase72 // keys are preserved here (in BindingEntries, in file order) so the usecase
66 // layer can decide which actions it understands.73 // layer can decide which actions it understands.
@@ -72,6 +79,7 @@ type Config struct {
72 Snapshot SnapshotConfig79 Snapshot SnapshotConfig
73 Palette PaletteConfig80 Palette PaletteConfig
74 Floating FloatingConfig81 Floating FloatingConfig
82+ Tabs TabsConfig
75 }83 }
76 84
77 // Warning describes a non-fatal config problem. Parsers and reloaders should85 // Warning describes a non-fatal config problem. Parsers and reloaders should
@@ -99,5 +107,8 @@ func Defaults() Config {
99 Width: 80,107 Width: 80,
100 Height: 80,108 Height: 80,
101 },109 },
110+ Tabs: TabsConfig{
111+ TerminalTitle: true,
112+ },
102 }113 }
103 }114 }
M internal/usecase/daemon/config.go +9 -0
@@ -31,6 +31,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
3131 d.floatingConfig.Store(&floating)
3232 palette := cfg.Palette
3333 d.paletteConfig.Store(&palette)
34+ tabs := cfg.Tabs
35+ d.tabsConfig.Store(&tabs)
3436 d.themeMode.Store(uint32(cfg.Theme))
3537 if d.barScripts != nil {
3638 d.barScripts.mu.Lock()
@@ -187,6 +189,13 @@ func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
187189 return domain.Defaults().Palette
188190 }
189191
192+func (d *Daemon) currentTabsConfig() domain.TabsConfig {
193+ if cfg := d.tabsConfig.Load(); cfg != nil {
194+ return *cfg
195+ }
196+ return domain.Defaults().Tabs
197+}
198+
190199 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
191200 if cfg := d.floatingConfig.Load(); cfg != nil {
192201 return *cfg
@@ -31,6 +31,8 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
31 d.floatingConfig.Store(&floating)31 d.floatingConfig.Store(&floating)
32 palette := cfg.Palette32 palette := cfg.Palette
33 d.paletteConfig.Store(&palette)33 d.paletteConfig.Store(&palette)
34+ tabs := cfg.Tabs
35+ d.tabsConfig.Store(&tabs)
34 d.themeMode.Store(uint32(cfg.Theme))36 d.themeMode.Store(uint32(cfg.Theme))
35 if d.barScripts != nil {37 if d.barScripts != nil {
36 d.barScripts.mu.Lock()38 d.barScripts.mu.Lock()
@@ -187,6 +189,13 @@ func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
187 return domain.Defaults().Palette189 return domain.Defaults().Palette
188 }190 }
189 191
192+func (d *Daemon) currentTabsConfig() domain.TabsConfig {
193+ if cfg := d.tabsConfig.Load(); cfg != nil {
194+ return *cfg
195+ }
196+ return domain.Defaults().Tabs
197+}
198+
190 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {199 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
191 if cfg := d.floatingConfig.Load(); cfg != nil {200 if cfg := d.floatingConfig.Load(); cfg != nil {
192 return *cfg201 return *cfg
M internal/usecase/daemon/config_test.go +18 -0
@@ -119,6 +119,24 @@ func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
119119 require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)
120120 }
121121
122+func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) {
123+ d := newTestDaemon(t, nil, stubClock{})
124+ require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig())
125+
126+ firstConfig := domain.Defaults()
127+ firstConfig.Tabs = domain.TabsConfig{TerminalTitle: false}
128+ d.ApplyConfig(firstConfig)
129+ first := d.currentTabsConfig()
130+
131+ secondConfig := domain.Defaults()
132+ secondConfig.Tabs = domain.TabsConfig{TerminalTitle: true}
133+ d.ApplyConfig(secondConfig)
134+ second := d.currentTabsConfig()
135+
136+ require.Equal(t, domain.TabsConfig{TerminalTitle: false}, first)
137+ require.Equal(t, domain.TabsConfig{TerminalTitle: true}, second)
138+}
139+
122140 func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
123141 tests := []struct {
124142 name string
@@ -119,6 +119,24 @@ func TestApplyConfigPublishesImmutableFloatingSnapshot(t *testing.T) {
119 require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)119 require.Equal(t, domain.FloatingConfig{Command: "lazygit", Width: 90, Height: 85}, second)
120 }120 }
121 121
122+func TestApplyConfigPublishesImmutableTabsSnapshot(t *testing.T) {
123+ d := newTestDaemon(t, nil, stubClock{})
124+ require.Equal(t, domain.Defaults().Tabs, d.currentTabsConfig())
125+
126+ firstConfig := domain.Defaults()
127+ firstConfig.Tabs = domain.TabsConfig{TerminalTitle: false}
128+ d.ApplyConfig(firstConfig)
129+ first := d.currentTabsConfig()
130+
131+ secondConfig := domain.Defaults()
132+ secondConfig.Tabs = domain.TabsConfig{TerminalTitle: true}
133+ d.ApplyConfig(secondConfig)
134+ second := d.currentTabsConfig()
135+
136+ require.Equal(t, domain.TabsConfig{TerminalTitle: false}, first)
137+ require.Equal(t, domain.TabsConfig{TerminalTitle: true}, second)
138+}
139+
122 func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {140 func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) {
123 tests := []struct {141 tests := []struct {
124 name string142 name string
M internal/usecase/daemon/copymode_test.go +3 -3
@@ -115,7 +115,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
115115 snap := scopy.NewSnapshot(pane.scrollback, pane.screen.Frame)
116116 pane.mu.Unlock()
117117 mode := scopy.NewMode(snap)
118- bars := barState{status: sess.statusSegments()}
118+ bars := barState{status: sess.statusSegments(true)}
119119
120120 base := renderer.NewFrame(80, 25)
121121 target := domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}
@@ -150,13 +150,13 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
150150 snap := scopy.NewSnapshot(tb.focusedPane().scrollback, tb.focusedPane().screen.Frame)
151151 mode := scopy.NewMode(snap)
152152
153- bars := barState{status: sess.statusSegments()}
153+ bars := barState{status: sess.statusSegments(true)}
154154 base, _ := composeClientFrameWithState(bars, tb, true)
155155 frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
156156
157157 require.Equal(t, 80, frame.Width)
158158 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)), " "))
160160 require.Contains(t, rowText(frame.Row(1)), "live")
161161 require.Contains(t, rowText(frame.Row(24)), "[SCROLL]")
162162 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 {
137137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
138138 floatingConfig atomic.Pointer[domain.FloatingConfig]
139139 paletteConfig atomic.Pointer[domain.PaletteConfig]
140+ tabsConfig atomic.Pointer[domain.TabsConfig]
140141 themeMode atomic.Uint32
141142 barScripts *barScriptState
142143 resumeParkGrace time.Duration
@@ -137,6 +137,7 @@ type Daemon struct {
137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]137 restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
138 floatingConfig atomic.Pointer[domain.FloatingConfig]138 floatingConfig atomic.Pointer[domain.FloatingConfig]
139 paletteConfig atomic.Pointer[domain.PaletteConfig]139 paletteConfig atomic.Pointer[domain.PaletteConfig]
140+ tabsConfig atomic.Pointer[domain.TabsConfig]
140 themeMode atomic.Uint32141 themeMode atomic.Uint32
141 barScripts *barScriptState142 barScripts *barScriptState
142 resumeParkGrace time.Duration143 resumeParkGrace time.Duration
M internal/usecase/daemon/pane_titles.go +56 -0
@@ -47,6 +47,10 @@ func (p *pane) displayTitleLocked() string {
4747 // cached composition unless that fallback changes the displayed title.
4848 func (p *pane) setDisplayFallback(fallback string) {
4949 p.mu.Lock()
50+ if p.title.displayFallback == fallback {
51+ p.mu.Unlock()
52+ return
53+ }
5054 oldTitle := p.displayTitleLocked()
5155 p.title.displayFallback = fallback
5256 if oldTitle != p.displayTitleLocked() {
@@ -65,6 +69,58 @@ func (p *pane) refreshTerminalTitleLocked() {
6569 }
6670 }
6771
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+
68124 func (d *Daemon) paneTitleFallback() string {
69125 fallback := filepath.Base(d.shell)
70126 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 = fallback55 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) {
2828 }
2929 }
3030
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+
3147 func TestPaneTerminalTitleGenerationChangesOnlyWhenTitleChanges(t *testing.T) {
3248 p := newPane("pane", nil, domain.Size{Cols: 20, Rows: 5})
3349 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) {
6363 sort.Slice(sessions, func(i, j int) bool { return sessions[i].name < sessions[j].name })
6464 sort.Slice(stopped, func(i, j int) bool { return stopped[i].name < stopped[j].name })
6565
66+ for _, s := range sessions {
67+ d.refreshSessionFocusedTitles(s)
68+ }
69+
70+ includeTerminalTitle := d.currentTabsConfig().TerminalTitle
6671 views := make([]picker.SessionView, 0, len(sessions)+len(stopped))
6772 curTab := 0
6873 for _, s := range sessions {
@@ -70,7 +75,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
7075 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
7176 sessionAttention := false
7277 for i, tb := range s.tabs {
73- label := tabDisplayName(tb, i)
78+ label := composeTabTitle(tabDisplayName(tb, i), tb.focusedPaneTitle(includeTerminalTitle))
7479 if tb.attention {
7580 label = attentionSuffix(label)
7681 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 := 072 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 := false76 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 = true81 sessionAttention = true
M internal/usecase/daemon/picker_test.go +49 -0
@@ -66,6 +66,55 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
6666 require.Equal(t, []string{"shell", "logs "}, views[1].Tabs)
6767 }
6868
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+
69118 func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
70119 p1, release1 := newBlockingPTY(t)
71120 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) {
344344 if overlays.previewTab != tb {
345345 preview = snapshotPickerPreview(overlays.previewTab)
346346 }
347+ d.refreshSessionFocusedTitles(sess)
347348 bars := d.barStateForClient(sess, ac, overlays.copyFeedback)
348349 bars.theme = ac.getTheme()
349350 _, attentionVisible := pulseStyle(bars.attentionFrame)
@@ -603,7 +604,7 @@ func (c *composedFrameCache) invalidate() {
603604 }
604605
605606 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...)
607608 }
608609
609610 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) {
523523 var bars barCache
524524 var composed composedFrameCache
525525 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)
527527 left.screen.ClearDamage()
528528 right.screen.ClearDamage()
529529 left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'})
530530 right.screen.Write([]byte("x"))
531531
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)
533533
534534 require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted")
535535 require.Equal(t, 'x', frame.At(22, 1).Rune)
@@ -542,7 +542,7 @@ func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) {
542542 p.screen.Write([]byte("old"))
543543 var bars barCache
544544 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)}
546546
547547 _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
548548 require.NotEmpty(t, damage)
@@ -566,7 +566,7 @@ func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) {
566566 p2.screen.Write([]byte("shown"))
567567 var bars barCache
568568 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)}
570570
571571 _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
572572
@@ -585,7 +585,7 @@ func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) {
585585 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}}
586586 var bars barCache
587587 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}
589589 win.tree.Focus = left.id
590590 composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
591591 left.screen.ClearDamage()
@@ -610,7 +610,7 @@ func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) {
610610 right.screen.Write([]byte("R"))
611611 var bars barCache
612612 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)}
614614 win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
615615 composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
616616 require.Equal(t, '│', composed.frame.At(20, 1).Rune)
@@ -689,7 +689,7 @@ func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
689689 win.panes[p2.id] = p2
690690 var bars barCache
691691 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)}
693693 composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
694694 p1.screen.ClearDamage()
695695 p2.screen.ClearDamage()
@@ -523,13 +523,13 @@ func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) {
523 var bars barCache523 var bars barCache
524 var composed composedFrameCache524 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 barCache543 var bars barCache
544 var composed composedFrameCache544 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 barCache567 var bars barCache
568 var composed composedFrameCache568 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 barCache586 var bars barCache
587 var composed composedFrameCache587 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.id589 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 barCache611 var bars barCache
612 var composed composedFrameCache612 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] = p2689 win.panes[p2.id] = p2
690 var bars barCache690 var bars barCache
691 var composed composedFrameCache691 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 (
3333
3434 "github.com/bnema/vev/internal/domain"
3535 themeui "github.com/bnema/vev/internal/usecase/theme"
36+ "github.com/bnema/vev/internal/usecase/ui"
3637 "github.com/bnema/vev/pkg/renderer"
3738 )
3839
@@ -63,12 +64,13 @@ func pulseStyle(frame int) (renderer.Style, bool) {
6364 func drawTopBarSnapshot(row []renderer.Cell, status statusSnapshot, frame int, topRight string, styles themeStyles) {
6465 clearStatusRow(row)
6566 x := 0
66- for _, w := range status.tabs {
67+ labels := fitTabLabels(status.tabs, len(row), topRight)
68+ for i, w := range status.tabs {
6769 style := styles.statusBar
6870 if w.active {
6971 style = styles.accent
7072 }
71- writeStatusText(row, &x, " "+w.name, style)
73+ writeStatusText(row, &x, " "+labels[i], style)
7274 if w.attention {
7375 writeStatusText(row, &x, " ", style)
7476 writeBell(row, &x, frame)
@@ -190,12 +192,13 @@ type statusSnapshot struct {
190192 }
191193
192194 type statusTab struct {
193- name string
195+ name string // tab display name (degradation target)
196+ paneTitle string // formatPaneTitle output of the focused pane
194197 active bool
195198 attention bool
196199 }
197200
198-func (s *session) statusSegments() statusSnapshot {
201+func (s *session) statusSegments(includeTerminalTitle bool) statusSnapshot {
199202 s.mu.Lock()
200203 defer s.mu.Unlock()
201204 name := s.name
@@ -207,7 +210,7 @@ func (s *session) statusSegments() statusSnapshot {
207210 name := tabDisplayName(tb, i)
208211 active := i == s.active
209212 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}
211214 }
212215 return snap
213216 }
@@ -272,7 +275,11 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
272275 state.attentionFrame = d.attentionFrame()
273276 }
274277 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)
276283 }
277284 if d != nil {
278285 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
@@ -376,6 +383,68 @@ func fitHistoryNav(entries []historyNavSession, rowLen, leftUsed int, feedback s
376383 return entries[start:end]
377384 }
378385
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+
379448 func fitMRU(entries []mruSession, rowLen, leftUsed int, feedback string) []mruSession {
380449 // With no feedback, keep one blank trailing cell; with feedback, reserve
381450 // 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 := 066 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.statusBar69 style := styles.statusBar
68 if w.active {70 if w.active {
69 style = styles.accent71 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 string195+ name string // tab display name (degradation target)
196+ paneTitle string // formatPaneTitle output of the focused pane
194 active bool197 active bool
195 attention bool198 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.name204 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.active211 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 snap215 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, reserve449 // 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) {
9797 }
9898 }
9999
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+
100234 func TestTabDisplayName(t *testing.T) {
101235 tests := []struct {
102236 name string
@@ -130,7 +264,7 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
130264 Known: true,
131265 })
132266
133- bars := barState{status: sess.statusSegments(), theme: ac.getTheme()}
267+ bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()}
134268 frame, damage := composeClientFrameWithState(bars, win, true)
135269 out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage)
136270
@@ -512,7 +646,7 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
512646
513647 frame, _ := composeClientFrame(sess, win, true, "")
514648
515- require.Equal(t, " 1 ", rowText(frame.Row(0)))
649+ require.Equal(t, " 1 (sh) ", rowText(frame.Row(0)))
516650 require.Equal(t, " 0* ", rowText(frame.Row(3)))
517651 }
518652
@@ -533,11 +667,15 @@ func TestTopBarRightAnchor(t *testing.T) {
533667 want: " 1 14:32",
534668 },
535669 {
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",
537675 width: 10,
538676 status: statusSnapshot{tabs: []statusTab{{name: "1"}, {name: "2"}}},
539677 topRight: "12345",
540- want: " 1 2 ",
678+ want: " 12345",
541679 },
542680 {
543681 name: "uses display width",
@@ -612,9 +750,11 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
612750 sess.tabs[1].attentionAt = time.Unix(10, 0)
613751 sess.mu.Unlock()
614752
615- frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(), attentionFrame: 1}, win, true)
753+ frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)
616754
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")
618758 for _, c := range frame.Row(0) {
619759 if c.Rune == attentionGlyph {
620760 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 string236 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
1515 first := win.focusedPane()
1616 var bars barCache
1717 var composed composedFrameCache
18- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
18+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
1919
2020 for i := 0; i < 8; i++ {
2121 id := layout.PaneID(fmt.Sprintf("pane-%d", i+2))
@@ -15,7 +15,7 @@ func TestComposeClientFrameCachePrunesTitleGenerationsAfterLayoutChurn(t *testin
15 first := win.focusedPane()15 first := win.focusedPane()
16 var bars barCache16 var bars barCache
17 var composed composedFrameCache17 var composed composedFrameCache
18- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}18+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
19 19
20 for i := 0; i < 8; i++ {20 for i := 0; i < 8; i++ {
21 id := layout.PaneID(fmt.Sprintf("pane-%d", i+2))21 id := layout.PaneID(fmt.Sprintf("pane-%d", i+2))
M internal/usecase/picker/model.go +1 -0
@@ -204,6 +204,7 @@ func (m *Model) renderList(frame renderer.Frame, rect domain.Rect, selection ren
204204 if !r.header {
205205 label = " " + label
206206 }
207+ label = ui.TruncateText(label, rect.Width)
207208 ui.FillRect(frame, domain.Rect{X: rect.X, Y: rect.Y + y, Width: rect.Width, Height: 1}, renderer.Cell{Rune: ' ', Style: style})
208209 ui.DrawText(frame, rect.X, rect.Y+y, rect.X+rect.Width, label, style)
209210 }
@@ -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 = " " + label205 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) {
143143 require.True(t, frame.At(0, 3).Style.Inverse)
144144 }
145145
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+
146157 func TestRenderListOnlyDoesNotDrawPreview(t *testing.T) {
147158 m := New([]SessionView{{ID: "s1", Name: "one", Tabs: []string{"tab"}, Active: 0}}, "s1", 0)
148159 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) {
223223 DrawText(frame, textLeft, textY, textRight, truncateText(toast.Message, textRight-textLeft), styles.Text)
224224 }
225225
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+
226229 func truncateText(text string, maxWidth int) string {
227230 if maxWidth <= 0 {
228231 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) {
8181 })
8282 }
8383
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+
84108 func TestToastBoundsAnchorsMarginsAndTinyFrames(t *testing.T) {
85109 message := "Reconnection attempts…"
86110 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) + 4110 width := textWidth(message) + 4

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close