detached 81e6be1

feat(tabs): persist custom tab names

commit
81e6be1793604a659582334077b77dcc466d72a8
author
brice · · 2026-07-04 01:55 UTC
parent
ebfcd26
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +349 -37
M README.md +2 -1
@@ -65,9 +65,10 @@ The status bar marks ephemeral sessions with `*`, for example `0*`. Ephemeral se
6565 | SSP | switch session or tab |
6666 | VIS | enter visual mode |
6767 | RNS | rename session |
68+| RNT | rename current tab |
6869 | DET | detach |
6970
70-Rename prompts are prefilled with the current session name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty names and names already used by another session are rejected; the prompt stays open and shows the validation error.
71+Rename prompts are prefilled with the current session or tab name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty session names and names already used by another session are rejected; the prompt stays open and shows the validation error. Custom tab names are saved for named sessions and are not persisted for ephemeral sessions.
7172
7273 ## Visual mode
7374
@@ -65,9 +65,10 @@ The status bar marks ephemeral sessions with `*`, for example `0*`. Ephemeral se
65 | SSP | switch session or tab |65 | SSP | switch session or tab |
66 | VIS | enter visual mode |66 | VIS | enter visual mode |
67 | RNS | rename session |67 | RNS | rename session |
68+| RNT | rename current tab |
68 | DET | detach |69 | DET | detach |
69 70
70-Rename prompts are prefilled with the current session name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty names and names already used by another session are rejected; the prompt stays open and shows the validation error.71+Rename prompts are prefilled with the current session or tab name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty session names and names already used by another session are rejected; the prompt stays open and shows the validation error. Custom tab names are saved for named sessions and are not persisted for ephemeral sessions.
71 72
72 ## Visual mode73 ## Visual mode
73 74
M internal/persist/persist.go +3 -1
@@ -70,13 +70,15 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
7070 }
7171
7272 createdAt := at
73+ var tabNames []string
7374 if v, ok := p.store.Get([]byte(name)); ok {
7475 if r, err := decodeRecordValue(name, v); err == nil {
7576 createdAt = r.CreatedAt
77+ tabNames = r.TabNames
7678 }
7779 }
7880
79- value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at})
81+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, TabNames: tabNames})
8082 if err != nil {
8183 return err
8284 }
@@ -70,13 +70,15 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
70 }70 }
71 71
72 createdAt := at72 createdAt := at
73+ var tabNames []string
73 if v, ok := p.store.Get([]byte(name)); ok {74 if v, ok := p.store.Get([]byte(name)); ok {
74 if r, err := decodeRecordValue(name, v); err == nil {75 if r, err := decodeRecordValue(name, v); err == nil {
75 createdAt = r.CreatedAt76 createdAt = r.CreatedAt
77+ tabNames = r.TabNames
76 }78 }
77 }79 }
78 80
79- value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at})81+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, TabNames: tabNames})
80 if err != nil {82 if err != nil {
81 return err83 return err
82 }84 }
M internal/persist/persist_test.go +20 -4
@@ -1,6 +1,7 @@
11 package persist
22
33 import (
4+ "encoding/binary"
45 "errors"
56 "path/filepath"
67 "testing"
@@ -12,7 +13,7 @@ import (
1213 )
1314
1415 func TestRecordCodecRoundTrip(t *testing.T) {
15- r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22}
16+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, TabNames: []string{"shell", "logs"}}
1617 value, err := encodeRecordValue(r)
1718 require.NoError(t, err)
1819
@@ -21,6 +22,20 @@ func TestRecordCodecRoundTrip(t *testing.T) {
2122 require.Equal(t, r, got)
2223 }
2324
25+func TestRecordCodecReadsLegacyRecordWithoutTabNames(t *testing.T) {
26+ cwd := "/tmp/project"
27+ value := make([]byte, 4+len(cwd)+8+8)
28+ binary.BigEndian.PutUint32(value[:4], uint32(len(cwd)))
29+ copy(value[4:], cwd)
30+ off := 4 + len(cwd)
31+ binary.BigEndian.PutUint64(value[off:off+8], 11)
32+ binary.BigEndian.PutUint64(value[off+8:off+16], 22)
33+
34+ got, err := decodeRecordValue("work", value)
35+ require.NoError(t, err)
36+ require.Equal(t, Record{Name: "work", Cwd: cwd, CreatedAt: 11, UpdatedAt: 22}, got)
37+}
38+
2439 func TestRecordCodecRejectsMalformedData(t *testing.T) {
2540 _, err := encodeRecordValue(Record{Cwd: "/tmp"})
2641 require.ErrorIs(t, err, errEmptyName)
@@ -32,6 +47,7 @@ func TestRecordCodecRejectsMalformedData(t *testing.T) {
3247 {0, 0, 0, 1, 'a', 0},
3348 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
3449 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
50+ {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255},
3551 }
3652 for _, value := range badValues {
3753 _, err := decodeRecordValue("name", value)
@@ -67,7 +83,7 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
6783 p, err := Open(dir)
6884 require.NoError(t, err)
6985
70- require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10}))
86+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, TabNames: []string{"shell", "logs"}}))
7187 require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}))
7288 require.NoError(t, p.Touch("b", "/b/next", 30))
7389
@@ -75,13 +91,13 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
7591 require.NoError(t, err)
7692 require.Equal(t, []Record{
7793 {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20},
78- {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30},
94+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, TabNames: []string{"shell", "logs"}},
7995 }, records)
8096
8197 require.NoError(t, p.Delete("a"))
8298 records, err = p.LoadAll()
8399 require.NoError(t, err)
84- require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}}, records)
100+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, TabNames: []string{"shell", "logs"}}}, records)
85101 require.NoError(t, p.Close())
86102 }
87103
@@ -1,6 +1,7 @@
1 package persist1 package persist
2 2
3 import (3 import (
4+ "encoding/binary"
4 "errors"5 "errors"
5 "path/filepath"6 "path/filepath"
6 "testing"7 "testing"
@@ -12,7 +13,7 @@ import (
12 )13 )
13 14
14 func TestRecordCodecRoundTrip(t *testing.T) {15 func TestRecordCodecRoundTrip(t *testing.T) {
15- r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22}16+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, TabNames: []string{"shell", "logs"}}
16 value, err := encodeRecordValue(r)17 value, err := encodeRecordValue(r)
17 require.NoError(t, err)18 require.NoError(t, err)
18 19
@@ -21,6 +22,20 @@ func TestRecordCodecRoundTrip(t *testing.T) {
21 require.Equal(t, r, got)22 require.Equal(t, r, got)
22 }23 }
23 24
25+func TestRecordCodecReadsLegacyRecordWithoutTabNames(t *testing.T) {
26+ cwd := "/tmp/project"
27+ value := make([]byte, 4+len(cwd)+8+8)
28+ binary.BigEndian.PutUint32(value[:4], uint32(len(cwd)))
29+ copy(value[4:], cwd)
30+ off := 4 + len(cwd)
31+ binary.BigEndian.PutUint64(value[off:off+8], 11)
32+ binary.BigEndian.PutUint64(value[off+8:off+16], 22)
33+
34+ got, err := decodeRecordValue("work", value)
35+ require.NoError(t, err)
36+ require.Equal(t, Record{Name: "work", Cwd: cwd, CreatedAt: 11, UpdatedAt: 22}, got)
37+}
38+
24 func TestRecordCodecRejectsMalformedData(t *testing.T) {39 func TestRecordCodecRejectsMalformedData(t *testing.T) {
25 _, err := encodeRecordValue(Record{Cwd: "/tmp"})40 _, err := encodeRecordValue(Record{Cwd: "/tmp"})
26 require.ErrorIs(t, err, errEmptyName)41 require.ErrorIs(t, err, errEmptyName)
@@ -32,6 +47,7 @@ func TestRecordCodecRejectsMalformedData(t *testing.T) {
32 {0, 0, 0, 1, 'a', 0},47 {0, 0, 0, 1, 'a', 0},
33 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},48 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
34 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},49 {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
50+ {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255},
35 }51 }
36 for _, value := range badValues {52 for _, value := range badValues {
37 _, err := decodeRecordValue("name", value)53 _, err := decodeRecordValue("name", value)
@@ -67,7 +83,7 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
67 p, err := Open(dir)83 p, err := Open(dir)
68 require.NoError(t, err)84 require.NoError(t, err)
69 85
70- require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10}))86+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, TabNames: []string{"shell", "logs"}}))
71 require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}))87 require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20}))
72 require.NoError(t, p.Touch("b", "/b/next", 30))88 require.NoError(t, p.Touch("b", "/b/next", 30))
73 89
@@ -75,13 +91,13 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
75 require.NoError(t, err)91 require.NoError(t, err)
76 require.Equal(t, []Record{92 require.Equal(t, []Record{
77 {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20},93 {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20},
78- {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30},94+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, TabNames: []string{"shell", "logs"}},
79 }, records)95 }, records)
80 96
81 require.NoError(t, p.Delete("a"))97 require.NoError(t, p.Delete("a"))
82 records, err = p.LoadAll()98 records, err = p.LoadAll()
83 require.NoError(t, err)99 require.NoError(t, err)
84- require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30}}, records)100+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, TabNames: []string{"shell", "logs"}}}, records)
85 require.NoError(t, p.Close())101 require.NoError(t, p.Close())
86 }102 }
87 103
M internal/persist/record.go +58 -4
@@ -13,6 +13,7 @@ type Record struct {
1313 Cwd string
1414 CreatedAt int64
1515 UpdatedAt int64
16+ TabNames []string
1617 }
1718
1819 var (
@@ -27,13 +28,32 @@ func encodeRecordValue(r Record) ([]byte, error) {
2728 if uint64(len(r.Cwd)) > math.MaxUint32 {
2829 return nil, fmt.Errorf("persist: cwd too large")
2930 }
31+ if uint64(len(r.TabNames)) > math.MaxUint32 {
32+ return nil, fmt.Errorf("persist: too many tab names")
33+ }
34+ tabNamesSize := 4
35+ for _, name := range r.TabNames {
36+ if uint64(len(name)) > math.MaxUint32 {
37+ return nil, fmt.Errorf("persist: tab name too large")
38+ }
39+ tabNamesSize += 4 + len(name)
40+ }
3041
31- buf := make([]byte, 4+len(r.Cwd)+8+8)
42+ buf := make([]byte, 4+len(r.Cwd)+8+8+tabNamesSize)
3243 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
3344 copy(buf[4:], r.Cwd)
3445 off := 4 + len(r.Cwd)
3546 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
3647 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
48+ off += 16
49+ binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(r.TabNames)))
50+ off += 4
51+ for _, name := range r.TabNames {
52+ binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(name)))
53+ off += 4
54+ copy(buf[off:], name)
55+ off += len(name)
56+ }
3757 return buf, nil
3858 }
3959
@@ -50,14 +70,48 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
5070 return Record{}, errMalformedRecord
5171 }
5272 cwdLen := int(cwdLen32)
53- if len(value) != 4+cwdLen+8+8 {
73+ baseLen := 4 + cwdLen + 8 + 8
74+ if len(value) < baseLen {
5475 return Record{}, errMalformedRecord
5576 }
5677 off := 4 + cwdLen
57- return Record{
78+ r := Record{
5879 Name: name,
5980 Cwd: string(value[4:off]),
6081 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
6182 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
62- }, nil
83+ }
84+ off += 16
85+ if len(value) == baseLen {
86+ return r, nil
87+ }
88+ if len(value)-off < 4 {
89+ return Record{}, errMalformedRecord
90+ }
91+ tabCount32 := binary.BigEndian.Uint32(value[off : off+4])
92+ off += 4
93+ if uint64(tabCount32) > uint64(len(value)-off)/4 {
94+ return Record{}, errMalformedRecord
95+ }
96+ tabCount := int(tabCount32)
97+ if tabCount > 0 {
98+ r.TabNames = make([]string, 0, tabCount)
99+ }
100+ for range tabCount {
101+ if len(value)-off < 4 {
102+ return Record{}, errMalformedRecord
103+ }
104+ nameLen32 := binary.BigEndian.Uint32(value[off : off+4])
105+ off += 4
106+ if uint64(nameLen32) > uint64(len(value)-off) {
107+ return Record{}, errMalformedRecord
108+ }
109+ nameLen := int(nameLen32)
110+ r.TabNames = append(r.TabNames, string(value[off:off+nameLen]))
111+ off += nameLen
112+ }
113+ if off != len(value) {
114+ return Record{}, errMalformedRecord
115+ }
116+ return r, nil
63117 }
@@ -13,6 +13,7 @@ type Record struct {
13 Cwd string13 Cwd string
14 CreatedAt int6414 CreatedAt int64
15 UpdatedAt int6415 UpdatedAt int64
16+ TabNames []string
16 }17 }
17 18
18 var (19 var (
@@ -27,13 +28,32 @@ func encodeRecordValue(r Record) ([]byte, error) {
27 if uint64(len(r.Cwd)) > math.MaxUint32 {28 if uint64(len(r.Cwd)) > math.MaxUint32 {
28 return nil, fmt.Errorf("persist: cwd too large")29 return nil, fmt.Errorf("persist: cwd too large")
29 }30 }
31+ if uint64(len(r.TabNames)) > math.MaxUint32 {
32+ return nil, fmt.Errorf("persist: too many tab names")
33+ }
34+ tabNamesSize := 4
35+ for _, name := range r.TabNames {
36+ if uint64(len(name)) > math.MaxUint32 {
37+ return nil, fmt.Errorf("persist: tab name too large")
38+ }
39+ tabNamesSize += 4 + len(name)
40+ }
30 41
31- buf := make([]byte, 4+len(r.Cwd)+8+8)42+ buf := make([]byte, 4+len(r.Cwd)+8+8+tabNamesSize)
32 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))43 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
33 copy(buf[4:], r.Cwd)44 copy(buf[4:], r.Cwd)
34 off := 4 + len(r.Cwd)45 off := 4 + len(r.Cwd)
35 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))46 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
36 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))47 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
48+ off += 16
49+ binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(r.TabNames)))
50+ off += 4
51+ for _, name := range r.TabNames {
52+ binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(name)))
53+ off += 4
54+ copy(buf[off:], name)
55+ off += len(name)
56+ }
37 return buf, nil57 return buf, nil
38 }58 }
39 59
@@ -50,14 +70,48 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
50 return Record{}, errMalformedRecord70 return Record{}, errMalformedRecord
51 }71 }
52 cwdLen := int(cwdLen32)72 cwdLen := int(cwdLen32)
53- if len(value) != 4+cwdLen+8+8 {73+ baseLen := 4 + cwdLen + 8 + 8
74+ if len(value) < baseLen {
54 return Record{}, errMalformedRecord75 return Record{}, errMalformedRecord
55 }76 }
56 off := 4 + cwdLen77 off := 4 + cwdLen
57- return Record{78+ r := Record{
58 Name: name,79 Name: name,
59 Cwd: string(value[4:off]),80 Cwd: string(value[4:off]),
60 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),81 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
61 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),82 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
62- }, nil83+ }
84+ off += 16
85+ if len(value) == baseLen {
86+ return r, nil
87+ }
88+ if len(value)-off < 4 {
89+ return Record{}, errMalformedRecord
90+ }
91+ tabCount32 := binary.BigEndian.Uint32(value[off : off+4])
92+ off += 4
93+ if uint64(tabCount32) > uint64(len(value)-off)/4 {
94+ return Record{}, errMalformedRecord
95+ }
96+ tabCount := int(tabCount32)
97+ if tabCount > 0 {
98+ r.TabNames = make([]string, 0, tabCount)
99+ }
100+ for range tabCount {
101+ if len(value)-off < 4 {
102+ return Record{}, errMalformedRecord
103+ }
104+ nameLen32 := binary.BigEndian.Uint32(value[off : off+4])
105+ off += 4
106+ if uint64(nameLen32) > uint64(len(value)-off) {
107+ return Record{}, errMalformedRecord
108+ }
109+ nameLen := int(nameLen32)
110+ r.TabNames = append(r.TabNames, string(value[off:off+nameLen]))
111+ off += nameLen
112+ }
113+ if off != len(value) {
114+ return Record{}, errMalformedRecord
115+ }
116+ return r, nil
63 }117 }
M internal/usecase/command/command.go +1 -0
@@ -10,6 +10,7 @@ type Context interface {
1010 Detach() error
1111 EnterVisualMode() error
1212 RenameSession() error
13+ RenameTab() error
1314 OpenSessionPicker() error
1415 }
1516
@@ -10,6 +10,7 @@ type Context interface {
10 Detach() error10 Detach() error
11 EnterVisualMode() error11 EnterVisualMode() error
12 RenameSession() error12 RenameSession() error
13+ RenameTab() error
13 OpenSessionPicker() error14 OpenSessionPicker() error
14 }15 }
15 16
M internal/usecase/command/registry.go +1 -0
@@ -13,6 +13,7 @@ func Registry() []Command {
1313 {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
1414 {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
1515 {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
16+ {Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},
1617 {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
1718 }
1819
@@ -13,6 +13,7 @@ func Registry() []Command {
13 {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},13 {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
14 {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},14 {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
15 {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},15 {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
16+ {Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},
16 {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},17 {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
17 }18 }
18 19
M internal/usecase/command/registry_test.go +3 -1
@@ -8,7 +8,7 @@ import (
88
99 func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
1010 commands := Registry()
11- wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}
11+ wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "RNT", "DET"}
1212
1313 if len(commands) != len(wantCodes) {
1414 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -59,6 +59,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
5959 {code: "SSP", want: "OpenSessionPicker"},
6060 {code: "VIS", want: "EnterVisualMode"},
6161 {code: "RNS", want: "RenameSession"},
62+ {code: "RNT", want: "RenameTab"},
6263 {code: "DET", want: "Detach"},
6364 }
6465
@@ -104,4 +105,5 @@ func (s *spyContext) PrevTab() error { return s.record("PrevTab") }
104105 func (s *spyContext) Detach() error { return s.record("Detach") }
105106 func (s *spyContext) EnterVisualMode() error { return s.record("EnterVisualMode") }
106107 func (s *spyContext) RenameSession() error { return s.record("RenameSession") }
108+func (s *spyContext) RenameTab() error { return s.record("RenameTab") }
107109 func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") }
@@ -8,7 +8,7 @@ import (
8 8
9 func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {9 func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) {
10 commands := Registry()10 commands := Registry()
11- wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"}11+ wantCodes := []string{"CNT", "CNS", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "RNT", "DET"}
12 12
13 if len(commands) != len(wantCodes) {13 if len(commands) != len(wantCodes) {
14 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))14 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -59,6 +59,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
59 {code: "SSP", want: "OpenSessionPicker"},59 {code: "SSP", want: "OpenSessionPicker"},
60 {code: "VIS", want: "EnterVisualMode"},60 {code: "VIS", want: "EnterVisualMode"},
61 {code: "RNS", want: "RenameSession"},61 {code: "RNS", want: "RenameSession"},
62+ {code: "RNT", want: "RenameTab"},
62 {code: "DET", want: "Detach"},63 {code: "DET", want: "Detach"},
63 }64 }
64 65
@@ -104,4 +105,5 @@ func (s *spyContext) PrevTab() error { return s.record("PrevTab") }
104 func (s *spyContext) Detach() error { return s.record("Detach") }105 func (s *spyContext) Detach() error { return s.record("Detach") }
105 func (s *spyContext) EnterVisualMode() error { return s.record("EnterVisualMode") }106 func (s *spyContext) EnterVisualMode() error { return s.record("EnterVisualMode") }
106 func (s *spyContext) RenameSession() error { return s.record("RenameSession") }107 func (s *spyContext) RenameSession() error { return s.record("RenameSession") }
108+func (s *spyContext) RenameTab() error { return s.record("RenameTab") }
107 func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") }109 func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") }
M internal/usecase/daemon/daemon.go +15 -5
@@ -30,6 +30,7 @@ import (
3030 "errors"
3131 "log/slog"
3232 "os"
33+ "slices"
3334 "sort"
3435 "sync"
3536 "time"
@@ -120,9 +121,18 @@ type stoppedSession struct {
120121 name string
121122 cwd string
122123 createdAt int64
124+ tabNames []string
123125 purging bool
124126 }
125127
128+func (s stoppedSession) same(other stoppedSession) bool {
129+ return s.name == other.name &&
130+ s.cwd == other.cwd &&
131+ s.createdAt == other.createdAt &&
132+ s.purging == other.purging &&
133+ slices.Equal(s.tabNames, other.tabNames)
134+}
135+
126136 type Option func(*Daemon)
127137
128138 // WithShell overrides the command (and its args) each session spawns. The
@@ -189,7 +199,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
189199 d.log.Warn("loading persisted sessions failed", "err", err)
190200 } else {
191201 for _, r := range records {
192- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt}
202+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, tabNames: r.TabNames}
193203 }
194204 }
195205 return d
@@ -370,7 +380,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
370380 _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))
371381 return
372382 }
373- if cur, ok := d.stopped[k.Name]; ok && cur == stopped {
383+ if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) {
374384 delete(d.stopped, k.Name)
375385 }
376386 d.mu.Unlock()
@@ -455,7 +465,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
455465 switch h.Intent {
456466 case ports.IntentEphemeral:
457467 name := d.allocEphemeralNameLocked()
458- sess, err := d.createSessionLocked(name, true, h.Cwd, sz)
468+ sess, err := d.createSessionLocked(name, true, h.Cwd, sz, nil)
459469 if err != nil {
460470 d.mu.Unlock()
461471 return nil, nil, err
@@ -474,7 +484,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
474484 d.mu.Unlock()
475485 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
476486 }
477- sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz)
487+ sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, nil)
478488 if err != nil {
479489 d.mu.Unlock()
480490 return nil, nil, err
@@ -494,7 +504,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
494504 }
495505 cwd := platform.DirOrHome(stopped.cwd)
496506 var err error
497- sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
507+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, stopped.tabNames)
498508 if err != nil {
499509 d.mu.Unlock()
500510 return nil, nil, err
@@ -30,6 +30,7 @@ import (
30 "errors"30 "errors"
31 "log/slog"31 "log/slog"
32 "os"32 "os"
33+ "slices"
33 "sort"34 "sort"
34 "sync"35 "sync"
35 "time"36 "time"
@@ -120,9 +121,18 @@ type stoppedSession struct {
120 name string121 name string
121 cwd string122 cwd string
122 createdAt int64123 createdAt int64
124+ tabNames []string
123 purging bool125 purging bool
124 }126 }
125 127
128+func (s stoppedSession) same(other stoppedSession) bool {
129+ return s.name == other.name &&
130+ s.cwd == other.cwd &&
131+ s.createdAt == other.createdAt &&
132+ s.purging == other.purging &&
133+ slices.Equal(s.tabNames, other.tabNames)
134+}
135+
126 type Option func(*Daemon)136 type Option func(*Daemon)
127 137
128 // WithShell overrides the command (and its args) each session spawns. The138 // WithShell overrides the command (and its args) each session spawns. The
@@ -189,7 +199,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
189 d.log.Warn("loading persisted sessions failed", "err", err)199 d.log.Warn("loading persisted sessions failed", "err", err)
190 } else {200 } else {
191 for _, r := range records {201 for _, r := range records {
192- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt}202+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, tabNames: r.TabNames}
193 }203 }
194 }204 }
195 return d205 return d
@@ -370,7 +380,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
370 _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))380 _ = tr.Send(frameError(ports.ErrInternal, "deleting persisted stopped session failed"))
371 return381 return
372 }382 }
373- if cur, ok := d.stopped[k.Name]; ok && cur == stopped {383+ if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) {
374 delete(d.stopped, k.Name)384 delete(d.stopped, k.Name)
375 }385 }
376 d.mu.Unlock()386 d.mu.Unlock()
@@ -455,7 +465,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
455 switch h.Intent {465 switch h.Intent {
456 case ports.IntentEphemeral:466 case ports.IntentEphemeral:
457 name := d.allocEphemeralNameLocked()467 name := d.allocEphemeralNameLocked()
458- sess, err := d.createSessionLocked(name, true, h.Cwd, sz)468+ sess, err := d.createSessionLocked(name, true, h.Cwd, sz, nil)
459 if err != nil {469 if err != nil {
460 d.mu.Unlock()470 d.mu.Unlock()
461 return nil, nil, err471 return nil, nil, err
@@ -474,7 +484,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
474 d.mu.Unlock()484 d.mu.Unlock()
475 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}485 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
476 }486 }
477- sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz)487+ sess, err := d.createSessionLocked(h.Name, false, h.Cwd, sz, nil)
478 if err != nil {488 if err != nil {
479 d.mu.Unlock()489 d.mu.Unlock()
480 return nil, nil, err490 return nil, nil, err
@@ -494,7 +504,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
494 }504 }
495 cwd := platform.DirOrHome(stopped.cwd)505 cwd := platform.DirOrHome(stopped.cwd)
496 var err error506 var err error
497- sess, err = d.createSessionLocked(h.Name, false, cwd, sz)507+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, stopped.tabNames)
498 if err != nil {508 if err != nil {
499 d.mu.Unlock()509 d.mu.Unlock()
500 return nil, nil, err510 return nil, nil, err
M internal/usecase/daemon/input_test.go +23 -0
@@ -164,6 +164,29 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
164164 require.Equal(t, "0", sess.name)
165165 }
166166
167+func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
168+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
169+ defer func() {
170+ for _, release := range releases {
171+ release()
172+ }
173+ }()
174+ sess.active = 1
175+
176+ d.handleInput(sess, ac, []byte("\x1b "))
177+ awaitFrame(t, sends, ports.MsgOutput)
178+ d.handleInput(sess, ac, []byte("RNT\r"))
179+ awaitFrame(t, sends, ports.MsgOutput)
180+ require.True(t, ac.promptActive())
181+
182+ d.handleInput(sess, ac, []byte("logs\r"))
183+ awaitFrame(t, sends, ports.MsgOutput)
184+
185+ require.False(t, ac.promptActive())
186+ require.Equal(t, "logs", sess.tabs[1].name)
187+ require.Empty(t, sess.tabs[0].name)
188+}
189+
167190 func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
168191 writes := make(chan []byte, 4)
169192 p, _ := newBlockingPTYWithWrites(t, writes)
@@ -164,6 +164,29 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
164 require.Equal(t, "0", sess.name)164 require.Equal(t, "0", sess.name)
165 }165 }
166 166
167+func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
168+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
169+ defer func() {
170+ for _, release := range releases {
171+ release()
172+ }
173+ }()
174+ sess.active = 1
175+
176+ d.handleInput(sess, ac, []byte("\x1b "))
177+ awaitFrame(t, sends, ports.MsgOutput)
178+ d.handleInput(sess, ac, []byte("RNT\r"))
179+ awaitFrame(t, sends, ports.MsgOutput)
180+ require.True(t, ac.promptActive())
181+
182+ d.handleInput(sess, ac, []byte("logs\r"))
183+ awaitFrame(t, sends, ports.MsgOutput)
184+
185+ require.False(t, ac.promptActive())
186+ require.Equal(t, "logs", sess.tabs[1].name)
187+ require.Empty(t, sess.tabs[0].name)
188+}
189+
167 func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {190 func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
168 writes := make(chan []byte, 4)191 writes := make(chan []byte, 4)
169 p, _ := newBlockingPTYWithWrites(t, writes)192 p, _ := newBlockingPTYWithWrites(t, writes)
M internal/usecase/daemon/palette.go +14 -0
@@ -147,6 +147,20 @@ func (e paletteExec) RenameSession() error {
147147 return nil
148148 }
149149
150+func (e paletteExec) RenameTab() error {
151+ tb := e.sess.activeTab()
152+ if tb == nil {
153+ return nil
154+ }
155+ e.sess.mu.Lock()
156+ currentName := tb.name
157+ e.sess.mu.Unlock()
158+ e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
159+ return e.d.renameTab(e.sess, tb, name)
160+ })
161+ return nil
162+}
163+
150164 func (e paletteExec) OpenSessionPicker() error {
151165 e.d.enterPicker(e.sess, e.ac)
152166 return nil
@@ -147,6 +147,20 @@ func (e paletteExec) RenameSession() error {
147 return nil147 return nil
148 }148 }
149 149
150+func (e paletteExec) RenameTab() error {
151+ tb := e.sess.activeTab()
152+ if tb == nil {
153+ return nil
154+ }
155+ e.sess.mu.Lock()
156+ currentName := tb.name
157+ e.sess.mu.Unlock()
158+ e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
159+ return e.d.renameTab(e.sess, tb, name)
160+ })
161+ return nil
162+}
163+
150 func (e paletteExec) OpenSessionPicker() error {164 func (e paletteExec) OpenSessionPicker() error {
151 e.d.enterPicker(e.sess, e.ac)165 e.d.enterPicker(e.sess, e.ac)
152 return nil166 return nil
M internal/usecase/daemon/picker.go +2 -2
@@ -420,7 +420,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
420420 return
421421 }
422422 cwd := platform.DirOrHome(stopped.cwd)
423- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size)
423+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, stopped.tabNames)
424424 if err != nil {
425425 from.mu.Unlock()
426426 d.mu.Unlock()
@@ -449,7 +449,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) {
449449 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
450450 return
451451 }
452- if cur, ok := d.stopped[target.Name]; ok && cur == stopped {
452+ if cur, ok := d.stopped[target.Name]; ok && cur.same(stopped) {
453453 delete(d.stopped, target.Name)
454454 }
455455 }
@@ -420,7 +420,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
420 return420 return
421 }421 }
422 cwd := platform.DirOrHome(stopped.cwd)422 cwd := platform.DirOrHome(stopped.cwd)
423- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size)423+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, stopped.tabNames)
424 if err != nil {424 if err != nil {
425 from.mu.Unlock()425 from.mu.Unlock()
426 d.mu.Unlock()426 d.mu.Unlock()
@@ -449,7 +449,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) {
449 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)449 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
450 return450 return
451 }451 }
452- if cur, ok := d.stopped[target.Name]; ok && cur == stopped {452+ if cur, ok := d.stopped[target.Name]; ok && cur.same(stopped) {
453 delete(d.stopped, target.Name)453 delete(d.stopped, target.Name)
454 }454 }
455 }455 }
M internal/usecase/daemon/picker_test.go +23 -0
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/vev/internal/domain"
1212 "github.com/bnema/vev/internal/ports"
1313 "github.com/bnema/vev/internal/usecase/keys"
14+ "github.com/bnema/vev/internal/usecase/picker"
1415 "github.com/bnema/vev/pkg/renderer"
1516 "github.com/bnema/vev/pkg/vt"
1617 )
@@ -55,6 +56,28 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
5556 require.Equal(t, []string{"1", "2 "}, views[1].Tabs)
5657 }
5758
59+func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
60+ p1, release1 := newBlockingPTY(t)
61+ p2, release2 := newBlockingPTY(t)
62+ p3, release3 := newBlockingPTY(t)
63+ defer release1()
64+ defer release2()
65+ defer release3()
66+ d, from, ac, sends := newManualSessionWithPTYs(t, p1)
67+ d.ptys = newFactorySeq(t, p2, p3)
68+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
69+
70+ d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
71+ awaitFrame(t, sends, ports.MsgOutput)
72+
73+ target := ac.currentSession()
74+ require.NotNil(t, target)
75+ require.Equal(t, "work", target.name)
76+ require.Len(t, target.tabs, 2)
77+ require.Equal(t, "shell", target.tabs[0].name)
78+ require.Equal(t, "logs", target.tabs[1].name)
79+}
80+
5881 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
5982 d, sess, ac, sends, releases := newManualTabSession(t, 2)
6083 defer func() {
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/vev/internal/domain"11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13 "github.com/bnema/vev/internal/usecase/keys"13 "github.com/bnema/vev/internal/usecase/keys"
14+ "github.com/bnema/vev/internal/usecase/picker"
14 "github.com/bnema/vev/pkg/renderer"15 "github.com/bnema/vev/pkg/renderer"
15 "github.com/bnema/vev/pkg/vt"16 "github.com/bnema/vev/pkg/vt"
16 )17 )
@@ -55,6 +56,28 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
55 require.Equal(t, []string{"1", "2 "}, views[1].Tabs)56 require.Equal(t, []string{"1", "2 "}, views[1].Tabs)
56 }57 }
57 58
59+func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
60+ p1, release1 := newBlockingPTY(t)
61+ p2, release2 := newBlockingPTY(t)
62+ p3, release3 := newBlockingPTY(t)
63+ defer release1()
64+ defer release2()
65+ defer release3()
66+ d, from, ac, sends := newManualSessionWithPTYs(t, p1)
67+ d.ptys = newFactorySeq(t, p2, p3)
68+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
69+
70+ d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
71+ awaitFrame(t, sends, ports.MsgOutput)
72+
73+ target := ac.currentSession()
74+ require.NotNil(t, target)
75+ require.Equal(t, "work", target.name)
76+ require.Len(t, target.tabs, 2)
77+ require.Equal(t, "shell", target.tabs[0].name)
78+ require.Equal(t, "logs", target.tabs[1].name)
79+}
80+
58 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {81 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
59 d, sess, ac, sends, releases := newManualTabSession(t, 2)82 d, sess, ac, sends, releases := newManualTabSession(t, 2)
60 defer func() {83 defer func() {
M internal/usecase/daemon/session.go +117 -13
@@ -61,6 +61,7 @@ type session struct {
6161 // already pending"), never by blocking the reader.
6262 type tab struct {
6363 pty ports.PTY
64+ name string
6465 mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
6566 screen *vt.Screen
6667 scrollback *scopy.Scrollback
@@ -83,40 +84,58 @@ type tab struct {
8384 // what it has actually seen). sendMu serialises the two senders — the render
8485 // scheduler and the connection handler — so the transport's single-writer
8586
86-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {
87+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, tabNames []string) (*session, error) {
8788 tbSize := tabSize(sz)
88- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
89- if err != nil {
90- return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
89+ names := append([]string(nil), tabNames...)
90+ tabCount := max(1, len(names))
91+ tabs := make([]*tab, 0, tabCount)
92+ for i := range tabCount {
93+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
94+ if err != nil {
95+ for _, tb := range tabs {
96+ _ = tb.pty.Close()
97+ }
98+ return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
99+ }
100+ tb := newTab(pty, tbSize)
101+ if i < len(names) {
102+ tb.name = names[i]
103+ }
104+ tabs = append(tabs, tb)
91105 }
92106
93107 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
94108 d.nextID++
95109 createdAt := time.Now().UnixNano()
96110
97- tb := newTab(pty, tbSize)
98111 sctx, cancel := context.WithCancel(d.serveCtx)
99- tb.ctx, tb.cancel = context.WithCancel(sctx)
112+ for _, tb := range tabs {
113+ tb.ctx, tb.cancel = context.WithCancel(sctx)
114+ }
100115 sess := &session{
101116 id: id,
102117 name: name,
103118 ephemeral: ephemeral,
104119 ctx: sctx,
105120 cancel: cancel,
106- tabs: []*tab{tb},
121+ tabs: tabs,
107122 cwd: cwd,
108123 createdAt: createdAt,
109124 }
110125 if !ephemeral {
111- if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt}); err != nil {
112- _ = pty.Close()
126+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, TabNames: names}); err != nil {
127+ for _, tb := range tabs {
128+ _ = tb.pty.Close()
129+ }
113130 cancel()
114131 return nil, err
115132 }
116133 delete(d.stopped, name)
117134 }
118135 d.sessions[id] = sess
119- d.startTabGoroutines(sess, tb)
136+ for _, tb := range tabs {
137+ d.startTabGoroutines(sess, tb)
138+ }
120139 return sess, nil
121140 }
122141
@@ -143,7 +162,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
143162 }
144163 from.mu.Unlock()
145164
146- newSess, err := d.createSessionLocked(name, false, cwd, sz)
165+ newSess, err := d.createSessionLocked(name, false, cwd, sz, nil)
147166 if err != nil {
148167 d.mu.Unlock()
149168 return err
@@ -193,8 +212,21 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
193212 }
194213 tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
195214 sess.mu.Lock()
215+ oldActive := sess.active
196216 sess.tabs = append(sess.tabs, tb)
197217 sess.active = len(sess.tabs) - 1
218+ record := sess.persistRecordLocked(time.Now().UnixNano())
219+ ephemeral := sess.ephemeral
220+ if !ephemeral {
221+ if err := d.persist.Save(record); err != nil {
222+ sess.tabs = sess.tabs[:len(sess.tabs)-1]
223+ sess.active = oldActive
224+ sess.mu.Unlock()
225+ d.mu.Unlock()
226+ _ = pty.Close()
227+ return err
228+ }
229+ }
198230 sess.mu.Unlock()
199231 d.startTabGoroutines(sess, tb)
200232 d.mu.Unlock()
@@ -294,7 +326,10 @@ func (d *Daemon) renameSession(sess *session, name string) error {
294326 sess.createdAt = createdAt
295327 }
296328 if wasEphemeral || oldName != name {
297- if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano()}); err != nil {
329+ record := sess.persistRecordLocked(time.Now().UnixNano())
330+ record.Name = name
331+ record.CreatedAt = createdAt
332+ if err := d.persist.Save(record); err != nil {
298333 return err
299334 }
300335 }
@@ -313,6 +348,67 @@ func (d *Daemon) renameSession(sess *session, name string) error {
313348 return nil
314349 }
315350
351+func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
352+ if tb == nil {
353+ return errors.New("tab required")
354+ }
355+ d.mu.Lock()
356+ if d.sessions[sess.id] != sess {
357+ d.mu.Unlock()
358+ return errors.New("daemon: session closed")
359+ }
360+ sess.mu.Lock()
361+ found := false
362+ for _, candidate := range sess.tabs {
363+ if candidate == tb {
364+ found = true
365+ break
366+ }
367+ }
368+ if !found {
369+ sess.mu.Unlock()
370+ d.mu.Unlock()
371+ return errors.New("tab not found")
372+ }
373+ oldName := tb.name
374+ tb.name = name
375+ record := sess.persistRecordLocked(time.Now().UnixNano())
376+ ephemeral := sess.ephemeral
377+ if !ephemeral {
378+ if err := d.persist.Save(record); err != nil {
379+ tb.name = oldName
380+ sess.mu.Unlock()
381+ d.mu.Unlock()
382+ return err
383+ }
384+ }
385+ sess.mu.Unlock()
386+ d.mu.Unlock()
387+ return nil
388+}
389+
390+func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
391+ createdAt := s.createdAt
392+ if createdAt == 0 {
393+ createdAt = updatedAt
394+ s.createdAt = createdAt
395+ }
396+ tabNames := make([]string, len(s.tabs))
397+ lastCustom := -1
398+ for i, tb := range s.tabs {
399+ tabNames[i] = tb.name
400+ if tb.name != "" {
401+ lastCustom = i
402+ }
403+ }
404+ if lastCustom == -1 {
405+ tabNames = nil
406+ } else {
407+ tabNames = tabNames[:lastCustom+1]
408+ }
409+ return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, TabNames: tabNames}
410+}
411+
316412 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
317413 sess.mu.Lock()
318414 idx := -1
@@ -339,8 +435,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
339435 sess.active--
340436 }
341437 ac := sess.client
438+ ephemeral := sess.ephemeral
439+ record := sess.persistRecordLocked(time.Now().UnixNano())
342440 sess.mu.Unlock()
343441
442+ if !ephemeral {
443+ if err := d.persist.Save(record); err != nil {
444+ d.log.Warn("persisting closed tab failed", "err", err, "session", record.Name)
445+ }
446+ }
344447 d.clearDestroyedTabPreview(tb)
345448 if tb.cancel != nil {
346449 tb.cancel()
@@ -375,10 +478,11 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
375478 stoppedName := sess.name
376479 stoppedCwd := sess.cwd
377480 createdAt := sess.createdAt
481+ tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames
378482 ephemeral := sess.ephemeral
379483 sess.mu.Unlock()
380484 if !ephemeral {
381- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, purging: purge}
485+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, tabNames: tabNames, purging: purge}
382486 d.stopped[stoppedName] = stopped
383487 }
384488 empty := len(d.sessions) == 0
@@ -61,6 +61,7 @@ type session struct {
61 // already pending"), never by blocking the reader.61 // already pending"), never by blocking the reader.
62 type tab struct {62 type tab struct {
63 pty ports.PTY63 pty ports.PTY
64+ name string
64 mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow65 mu sync.Mutex // guards screen, syncGen, and every attached renderer's shadow
65 screen *vt.Screen66 screen *vt.Screen
66 scrollback *scopy.Scrollback67 scrollback *scopy.Scrollback
@@ -83,40 +84,58 @@ type tab struct {
83 // what it has actually seen). sendMu serialises the two senders — the render84 // what it has actually seen). sendMu serialises the two senders — the render
84 // scheduler and the connection handler — so the transport's single-writer85 // scheduler and the connection handler — so the transport's single-writer
85 86
86-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size) (*session, error) {87+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, tabNames []string) (*session, error) {
87 tbSize := tabSize(sz)88 tbSize := tabSize(sz)
88- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)89+ names := append([]string(nil), tabNames...)
89- if err != nil {90+ tabCount := max(1, len(names))
90- return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)91+ tabs := make([]*tab, 0, tabCount)
92+ for i := range tabCount {
93+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name), cwd, tbSize)
94+ if err != nil {
95+ for _, tb := range tabs {
96+ _ = tb.pty.Close()
97+ }
98+ return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
99+ }
100+ tb := newTab(pty, tbSize)
101+ if i < len(names) {
102+ tb.name = names[i]
103+ }
104+ tabs = append(tabs, tb)
91 }105 }
92 106
93 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))107 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
94 d.nextID++108 d.nextID++
95 createdAt := time.Now().UnixNano()109 createdAt := time.Now().UnixNano()
96 110
97- tb := newTab(pty, tbSize)
98 sctx, cancel := context.WithCancel(d.serveCtx)111 sctx, cancel := context.WithCancel(d.serveCtx)
99- tb.ctx, tb.cancel = context.WithCancel(sctx)112+ for _, tb := range tabs {
113+ tb.ctx, tb.cancel = context.WithCancel(sctx)
114+ }
100 sess := &session{115 sess := &session{
101 id: id,116 id: id,
102 name: name,117 name: name,
103 ephemeral: ephemeral,118 ephemeral: ephemeral,
104 ctx: sctx,119 ctx: sctx,
105 cancel: cancel,120 cancel: cancel,
106- tabs: []*tab{tb},121+ tabs: tabs,
107 cwd: cwd,122 cwd: cwd,
108 createdAt: createdAt,123 createdAt: createdAt,
109 }124 }
110 if !ephemeral {125 if !ephemeral {
111- if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt}); err != nil {126+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, TabNames: names}); err != nil {
112- _ = pty.Close()127+ for _, tb := range tabs {
128+ _ = tb.pty.Close()
129+ }
113 cancel()130 cancel()
114 return nil, err131 return nil, err
115 }132 }
116 delete(d.stopped, name)133 delete(d.stopped, name)
117 }134 }
118 d.sessions[id] = sess135 d.sessions[id] = sess
119- d.startTabGoroutines(sess, tb)136+ for _, tb := range tabs {
137+ d.startTabGoroutines(sess, tb)
138+ }
120 return sess, nil139 return sess, nil
121 }140 }
122 141
@@ -143,7 +162,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
143 }162 }
144 from.mu.Unlock()163 from.mu.Unlock()
145 164
146- newSess, err := d.createSessionLocked(name, false, cwd, sz)165+ newSess, err := d.createSessionLocked(name, false, cwd, sz, nil)
147 if err != nil {166 if err != nil {
148 d.mu.Unlock()167 d.mu.Unlock()
149 return err168 return err
@@ -193,8 +212,21 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
193 }212 }
194 tb.ctx, tb.cancel = context.WithCancel(sess.ctx)213 tb.ctx, tb.cancel = context.WithCancel(sess.ctx)
195 sess.mu.Lock()214 sess.mu.Lock()
215+ oldActive := sess.active
196 sess.tabs = append(sess.tabs, tb)216 sess.tabs = append(sess.tabs, tb)
197 sess.active = len(sess.tabs) - 1217 sess.active = len(sess.tabs) - 1
218+ record := sess.persistRecordLocked(time.Now().UnixNano())
219+ ephemeral := sess.ephemeral
220+ if !ephemeral {
221+ if err := d.persist.Save(record); err != nil {
222+ sess.tabs = sess.tabs[:len(sess.tabs)-1]
223+ sess.active = oldActive
224+ sess.mu.Unlock()
225+ d.mu.Unlock()
226+ _ = pty.Close()
227+ return err
228+ }
229+ }
198 sess.mu.Unlock()230 sess.mu.Unlock()
199 d.startTabGoroutines(sess, tb)231 d.startTabGoroutines(sess, tb)
200 d.mu.Unlock()232 d.mu.Unlock()
@@ -294,7 +326,10 @@ func (d *Daemon) renameSession(sess *session, name string) error {
294 sess.createdAt = createdAt326 sess.createdAt = createdAt
295 }327 }
296 if wasEphemeral || oldName != name {328 if wasEphemeral || oldName != name {
297- if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano()}); err != nil {329+ record := sess.persistRecordLocked(time.Now().UnixNano())
330+ record.Name = name
331+ record.CreatedAt = createdAt
332+ if err := d.persist.Save(record); err != nil {
298 return err333 return err
299 }334 }
300 }335 }
@@ -313,6 +348,67 @@ func (d *Daemon) renameSession(sess *session, name string) error {
313 return nil348 return nil
314 }349 }
315 350
351+func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
352+ if tb == nil {
353+ return errors.New("tab required")
354+ }
355+ d.mu.Lock()
356+ if d.sessions[sess.id] != sess {
357+ d.mu.Unlock()
358+ return errors.New("daemon: session closed")
359+ }
360+ sess.mu.Lock()
361+ found := false
362+ for _, candidate := range sess.tabs {
363+ if candidate == tb {
364+ found = true
365+ break
366+ }
367+ }
368+ if !found {
369+ sess.mu.Unlock()
370+ d.mu.Unlock()
371+ return errors.New("tab not found")
372+ }
373+ oldName := tb.name
374+ tb.name = name
375+ record := sess.persistRecordLocked(time.Now().UnixNano())
376+ ephemeral := sess.ephemeral
377+ if !ephemeral {
378+ if err := d.persist.Save(record); err != nil {
379+ tb.name = oldName
380+ sess.mu.Unlock()
381+ d.mu.Unlock()
382+ return err
383+ }
384+ }
385+ sess.mu.Unlock()
386+ d.mu.Unlock()
387+ return nil
388+}
389+
390+func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
391+ createdAt := s.createdAt
392+ if createdAt == 0 {
393+ createdAt = updatedAt
394+ s.createdAt = createdAt
395+ }
396+ tabNames := make([]string, len(s.tabs))
397+ lastCustom := -1
398+ for i, tb := range s.tabs {
399+ tabNames[i] = tb.name
400+ if tb.name != "" {
401+ lastCustom = i
402+ }
403+ }
404+ if lastCustom == -1 {
405+ tabNames = nil
406+ } else {
407+ tabNames = tabNames[:lastCustom+1]
408+ }
409+ return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, TabNames: tabNames}
410+}
411+
316 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {412 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
317 sess.mu.Lock()413 sess.mu.Lock()
318 idx := -1414 idx := -1
@@ -339,8 +435,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
339 sess.active--435 sess.active--
340 }436 }
341 ac := sess.client437 ac := sess.client
438+ ephemeral := sess.ephemeral
439+ record := sess.persistRecordLocked(time.Now().UnixNano())
342 sess.mu.Unlock()440 sess.mu.Unlock()
343 441
442+ if !ephemeral {
443+ if err := d.persist.Save(record); err != nil {
444+ d.log.Warn("persisting closed tab failed", "err", err, "session", record.Name)
445+ }
446+ }
344 d.clearDestroyedTabPreview(tb)447 d.clearDestroyedTabPreview(tb)
345 if tb.cancel != nil {448 if tb.cancel != nil {
346 tb.cancel()449 tb.cancel()
@@ -375,10 +478,11 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
375 stoppedName := sess.name478 stoppedName := sess.name
376 stoppedCwd := sess.cwd479 stoppedCwd := sess.cwd
377 createdAt := sess.createdAt480 createdAt := sess.createdAt
481+ tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames
378 ephemeral := sess.ephemeral482 ephemeral := sess.ephemeral
379 sess.mu.Unlock()483 sess.mu.Unlock()
380 if !ephemeral {484 if !ephemeral {
381- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, purging: purge}485+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, tabNames: tabNames, purging: purge}
382 d.stopped[stoppedName] = stopped486 d.stopped[stoppedName] = stopped
383 }487 }
384 empty := len(d.sessions) == 0488 empty := len(d.sessions) == 0
M internal/usecase/daemon/session_test.go +63 -5
@@ -667,7 +667,7 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
667667 d := newTestDaemon(t, newFactory(t, p), stubClock{})
668668 WithStore(store)(d)
669669
670- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
670+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil)
671671 require.NoError(t, err)
672672 require.True(t, state.has("work"))
673673
@@ -679,6 +679,64 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
679679 require.False(t, state.has("renamed"))
680680 }
681681
682+func TestRenameTabPersistsForNamedSession(t *testing.T) {
683+ sz := domain.Size{Cols: 80, Rows: 24}
684+ p, release := newBlockingPTY(t)
685+ defer release()
686+ store, _ := newMockStore(t)
687+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
688+ WithStore(store)(d)
689+
690+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil)
691+ require.NoError(t, err)
692+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
693+
694+ records, err := d.persist.LoadAll()
695+ require.NoError(t, err)
696+ require.Equal(t, []persist.Record{{Name: "work", Cwd: "/tmp/work", CreatedAt: sess.createdAt, UpdatedAt: records[0].UpdatedAt, TabNames: []string{"shell"}}}, records)
697+}
698+
699+func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
700+ sz := domain.Size{Cols: 80, Rows: 24}
701+ p, release := newBlockingPTY(t)
702+ defer release()
703+ store, state := newMockStore(t)
704+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
705+ WithStore(store)(d)
706+
707+ sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, nil)
708+ require.NoError(t, err)
709+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
710+
711+ require.False(t, state.has("0"))
712+ records, err := d.persist.LoadAll()
713+ require.NoError(t, err)
714+ require.Empty(t, records)
715+}
716+
717+func TestAttachRestoresPersistedTabNames(t *testing.T) {
718+ sz := domain.Size{Cols: 80, Rows: 24}
719+ p1, release1 := newBlockingPTY(t)
720+ p2, release2 := newBlockingPTY(t)
721+ defer release1()
722+ defer release2()
723+ store, _ := newMockStore(t)
724+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
725+ WithStore(store)(d)
726+ require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
727+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
728+ tr := portsmocks.NewMockTransport(t)
729+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
730+ tr.EXPECT().Close().Return(nil).Maybe()
731+
732+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
733+ require.NoError(t, err)
734+ require.NotNil(t, ac)
735+ require.Len(t, sess.tabs, 2)
736+ require.Equal(t, "shell", sess.tabs[0].name)
737+ require.Equal(t, "logs", sess.tabs[1].name)
738+}
739+
682740 func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
683741 sz := domain.Size{Cols: 80, Rows: 24}
684742 p, release := newBlockingPTY(t)
@@ -688,7 +746,7 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
688746 WithStore(store)(d)
689747 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
690748
691- sess, err := d.createSessionLocked("0", true, "/tmp/e", sz)
749+ sess, err := d.createSessionLocked("0", true, "/tmp/e", sz, nil)
692750 require.NoError(t, err)
693751 require.False(t, state.has("0"))
694752 require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")
@@ -706,7 +764,7 @@ func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
706764 cwd := "/tmp/work"
707765 WithCwdReader(func(int) (string, error) { return cwd, nil })(d)
708766
709- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)
767+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil)
710768 require.NoError(t, err)
711769 state.mu.Lock()
712770 setsAfterCreate := state.sets
@@ -798,9 +856,9 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
798856 WithStore(store)(d)
799857 WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)
800858
801- natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz)
859+ natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz, nil)
802860 require.NoError(t, err)
803- other, err := d.createSessionLocked("other", false, "/tmp/other", sz)
861+ other, err := d.createSessionLocked("other", false, "/tmp/other", sz, nil)
804862 require.NoError(t, err)
805863 _ = d.killSession(natural, ports.ReasonSessionKilled, false)
806864 require.True(t, state.has("natural"))
@@ -667,7 +667,7 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
667 d := newTestDaemon(t, newFactory(t, p), stubClock{})667 d := newTestDaemon(t, newFactory(t, p), stubClock{})
668 WithStore(store)(d)668 WithStore(store)(d)
669 669
670- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)670+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil)
671 require.NoError(t, err)671 require.NoError(t, err)
672 require.True(t, state.has("work"))672 require.True(t, state.has("work"))
673 673
@@ -679,6 +679,64 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
679 require.False(t, state.has("renamed"))679 require.False(t, state.has("renamed"))
680 }680 }
681 681
682+func TestRenameTabPersistsForNamedSession(t *testing.T) {
683+ sz := domain.Size{Cols: 80, Rows: 24}
684+ p, release := newBlockingPTY(t)
685+ defer release()
686+ store, _ := newMockStore(t)
687+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
688+ WithStore(store)(d)
689+
690+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil)
691+ require.NoError(t, err)
692+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
693+
694+ records, err := d.persist.LoadAll()
695+ require.NoError(t, err)
696+ require.Equal(t, []persist.Record{{Name: "work", Cwd: "/tmp/work", CreatedAt: sess.createdAt, UpdatedAt: records[0].UpdatedAt, TabNames: []string{"shell"}}}, records)
697+}
698+
699+func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
700+ sz := domain.Size{Cols: 80, Rows: 24}
701+ p, release := newBlockingPTY(t)
702+ defer release()
703+ store, state := newMockStore(t)
704+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
705+ WithStore(store)(d)
706+
707+ sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, nil)
708+ require.NoError(t, err)
709+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
710+
711+ require.False(t, state.has("0"))
712+ records, err := d.persist.LoadAll()
713+ require.NoError(t, err)
714+ require.Empty(t, records)
715+}
716+
717+func TestAttachRestoresPersistedTabNames(t *testing.T) {
718+ sz := domain.Size{Cols: 80, Rows: 24}
719+ p1, release1 := newBlockingPTY(t)
720+ p2, release2 := newBlockingPTY(t)
721+ defer release1()
722+ defer release2()
723+ store, _ := newMockStore(t)
724+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
725+ WithStore(store)(d)
726+ require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
727+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
728+ tr := portsmocks.NewMockTransport(t)
729+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
730+ tr.EXPECT().Close().Return(nil).Maybe()
731+
732+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
733+ require.NoError(t, err)
734+ require.NotNil(t, ac)
735+ require.Len(t, sess.tabs, 2)
736+ require.Equal(t, "shell", sess.tabs[0].name)
737+ require.Equal(t, "logs", sess.tabs[1].name)
738+}
739+
682 func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {740 func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
683 sz := domain.Size{Cols: 80, Rows: 24}741 sz := domain.Size{Cols: 80, Rows: 24}
684 p, release := newBlockingPTY(t)742 p, release := newBlockingPTY(t)
@@ -688,7 +746,7 @@ func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
688 WithStore(store)(d)746 WithStore(store)(d)
689 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}747 d.stopped["taken"] = stoppedSession{name: "taken", cwd: "/tmp", createdAt: 1}
690 748
691- sess, err := d.createSessionLocked("0", true, "/tmp/e", sz)749+ sess, err := d.createSessionLocked("0", true, "/tmp/e", sz, nil)
692 require.NoError(t, err)750 require.NoError(t, err)
693 require.False(t, state.has("0"))751 require.False(t, state.has("0"))
694 require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")752 require.EqualError(t, d.renameSession(sess, "taken"), "name already in use")
@@ -706,7 +764,7 @@ func TestRefreshSessionCwdTouchesOnlyOnChange(t *testing.T) {
706 cwd := "/tmp/work"764 cwd := "/tmp/work"
707 WithCwdReader(func(int) (string, error) { return cwd, nil })(d)765 WithCwdReader(func(int) (string, error) { return cwd, nil })(d)
708 766
709- sess, err := d.createSessionLocked("work", false, "/tmp/work", sz)767+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, nil)
710 require.NoError(t, err)768 require.NoError(t, err)
711 state.mu.Lock()769 state.mu.Lock()
712 setsAfterCreate := state.sets770 setsAfterCreate := state.sets
@@ -798,9 +856,9 @@ func TestNaturalExitStoppedButExplicitKillPurges(t *testing.T) {
798 WithStore(store)(d)856 WithStore(store)(d)
799 WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)857 WithCwdReader(func(int) (string, error) { return "/tmp/latest", nil })(d)
800 858
801- natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz)859+ natural, err := d.createSessionLocked("natural", false, "/tmp/old", sz, nil)
802 require.NoError(t, err)860 require.NoError(t, err)
803- other, err := d.createSessionLocked("other", false, "/tmp/other", sz)861+ other, err := d.createSessionLocked("other", false, "/tmp/other", sz, nil)
804 require.NoError(t, err)862 require.NoError(t, err)
805 _ = d.killSession(natural, ports.ReasonSessionKilled, false)863 _ = d.killSession(natural, ports.ReasonSessionKilled, false)
806 require.True(t, state.has("natural"))864 require.True(t, state.has("natural"))
M internal/usecase/daemon/status.go +4 -1
@@ -141,7 +141,10 @@ func (s *session) statusSegments() statusSnapshot {
141141 }
142142 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
143143 for i, tb := range s.tabs {
144- name := strconv.Itoa(i + 1)
144+ name := tb.name
145+ if name == "" {
146+ name = strconv.Itoa(i + 1)
147+ }
145148 snap.tabs[i] = statusTab{name: name, active: i == s.active, attention: tb.attention}
146149 }
147150 return snap
@@ -141,7 +141,10 @@ func (s *session) statusSegments() statusSnapshot {
141 }141 }
142 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}142 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
143 for i, tb := range s.tabs {143 for i, tb := range s.tabs {
144- name := strconv.Itoa(i + 1)144+ name := tb.name
145+ if name == "" {
146+ name = strconv.Itoa(i + 1)
147+ }
145 snap.tabs[i] = statusTab{name: name, active: i == s.active, attention: tb.attention}148 snap.tabs[i] = statusTab{name: name, active: i == s.active, attention: tb.attention}
146 }149 }
147 return snap150 return snap

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 2

Tags 13

↑↓ moveenter openesc close